/** * 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가". * * ★ 왜 shared 인가 * 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site). * 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정). * 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`). */ /** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */ export type DataVerified = '확인' | '확인필요'; export interface DataSource { name: string; url?: string; } export interface SongItem { title: string; artist?: string; lyricist?: string; composer?: string; year?: number; label?: string; labelColor?: string; story?: string; connection?: string; verified?: DataVerified; source?: DataSource; } export interface DailyItem { monthDay: string; category?: string; title: string; body?: string; season?: string; tags?: string[]; verified?: DataVerified; source?: DataSource; } export interface CourseStop { order?: number; name: string; minutes?: number; note?: string; searchQuery?: string; } export interface CourseItem { name: string; duration?: string; startsFrom?: string; stops?: CourseStop[]; verified?: DataVerified; source?: DataSource; } export interface ScheduleSlot { /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ time: string; title: string; place?: string; minutes?: number; note?: string; searchQuery?: string; } export interface ScheduleItem { name: string; /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ audience?: string; season?: string; slots?: ScheduleSlot[]; verified?: DataVerified; source?: DataSource; } export interface PeopleItem { name: string; /** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */ aka?: string; years?: string; role?: string; oneLine?: string; /** * 사진 검색어. 사진을 못 구했을 때 어디서 찾을지만 남긴다 — * 모델이 지어낸 주소를 그대로 링크하면 깨진 사진이 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약). */ imageQuery?: string; /** * 사진. 있으면 필름 프레임에 들어가고, 없으면 지금처럼 이니셜 활자가 그 자리를 지킨다 — * 빈 회색 상자를 만들지 않는다(LocalPlace.imageUrl 과 같은 규칙). * ★ 우리가 찾아 붙이는 사진이 아니다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것 중 * **상업적 이용을 허용하는 저작권 유형만** 수집 단계에서 담는다 * (`services/external/tour_places.py` 의 `_IMAGE_OK`). */ imageUrl?: string; /** * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). */ imageCredit?: string; verified?: DataVerified; source?: DataSource; } export interface ChronicleItem { /** 연도가 없으면 레일 끝으로 밀린다 — 순서를 지어내지 않는다. */ year?: number; title: string; summary?: string; place?: string; /** 그 해의 사진. 없으면 연표는 지금처럼 활자만으로 선다 — 자리를 비워 두지 않는다(PeopleItem.imageUrl 과 같은 출처·규칙). */ imageUrl?: string; /** * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). */ imageCredit?: string; /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ turning?: boolean; verified?: DataVerified; source?: DataSource; } /** * 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글. * * ★ 왜 일력(daily)과 따로 있나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는 * 무리다, 하루에 하나씩밖에 안 알려 주니까") * 일력은 오늘·어제·내일 **세 장만** 화면에 선다. 도시를 소개하는 그릇으로는 작다. * 이쪽은 갈래(문학·섬과 바다·역사·장소·음식과 생활)로 묶은 더미에서 매번 몇 개만 뽑아 낸다. * ★ 이미 제 자리를 가진 것은 여기 넣지 않는다 — 인물·가요·축제·연표·명소·맛집. * 같은 이야기를 두 번 세우면 페이지만 길어진다(프롬프트 규칙으로도 막는다). */ export interface ReadingItem { /** 갈래 이름. 카드 위 이름표로만 쓴다 — 갈래별로 묶어 세우지 않는다(뽑기는 갈래를 안 가린다). */ group?: string; title: string; /** 서너 문장. 두 문장짜리는 카드가 한 줄로 접혀 빈 카드처럼 보인다(2026-09-14 대표 지적). */ body: string; /** 연도를 댈 수 있는 꼭지만. 없으면 이름표에 연도를 안 붙인다. */ year?: number; verified?: DataVerified; /** * 출처. **모델이 적지 않는다** — 제목으로 찾아가는 검색 링크를 서버가 붙인다 * (`services/grounding/story.py`). 개별 문서 주소를 짐작해 적으면 없는 문서로 이어진다. */ source?: DataSource; } export interface LiteratureItem { workTitle: string; author?: string; /** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */ year?: string; genre?: string; spineColor?: string; background?: string; whyHere?: string; verified?: DataVerified; source?: DataSource; } export interface PostcardItem { line: string; hashtags?: string[]; place?: string; /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ postmark?: string; /** 엽서 앞면 사진. 없으면 뒷면(문장·우표·소인)만 있는 지금 모양 그대로다(PeopleItem.imageUrl 과 같은 출처·규칙). */ imageUrl?: string; /** * 사진의 출처 표시. CC BY·BY-SA·공공누리 제1유형이 **요구하는 조건**이라 비워 두면 * 그 사진을 쓸 수 없다 — 화면에 그대로 찍는다(`services/external/wikimedia.py`). */ imageCredit?: string; verified?: DataVerified; source?: DataSource; } export interface QuizItem { question: string; /** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */ hint?: string; topic?: string; level?: string; verified?: DataVerified; source?: DataSource; } /** * 추천 일정 — 반나절 · 1박2일 · 2박3일. * * ★ 왜 하나로 합쳤나 * 한때 코스가 셋이었다 — 반나절 산책(course) · 여행 스케줄(schedule) · 계절별 추천 하루(planner). * 축이 다르다고 나눠 뒀는데 실제 데이터를 보니 **같은 장소를 세 번 나열**하고 있었다 * (째보선창이 셋 중 둘에, 초원사진관도 마찬가지). 손님에게는 "군산에서 어디를 도나" * 하나의 질문이고, 다른 건 **며칠짜리인가** 뿐이다. 그래서 축을 기간으로 바꿨다. * ★ 순위를 매기지 않는다. 어느 코스가 1위인지는 우리가 정할 일이 아니다. */ export interface ItineraryDay { /** "첫째 날". 없으면 순번으로 만든다. */ label?: string; /** 그날 나서는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */ startTime?: string; stops?: PlannerStop[]; } export interface ItineraryItem { name: string; /** "반나절" · "1박 2일" · "2박 3일". 이 값이 목록을 가르는 축이다. */ duration?: string; audience?: string; /** 왜 이 일정인가. 고르는 근거 한 문장. */ why?: string; /** * 계절. 적으면 그 계절에만 손님 화면에 나간다(`inSeason`). * 겨울에 벚꽃 코스를 권하지 않기 위한 것이지, 목록을 계절로 가르기 위한 게 아니다. */ season?: string; /** 하루 이상이면 이 배열을 쓴다. */ days?: ItineraryDay[]; /** 하루짜리는 days 없이 이것만 써도 된다 — 한 덩이로 본다. */ stops?: PlannerStop[]; startTime?: string; verified?: DataVerified; source?: DataSource; } export interface VideoItem { /** 유튜브 주소. watch · youtu.be · shorts · embed 넷 다 받는다. */ url: string; caption?: string; verified?: DataVerified; source?: DataSource; } /** * 이벤트·소식 — 사장님이 인스타에 올리는 그때그때의 행사. * * ★ 인스타를 읽어 오지 않는다. 계정 페이지는 로그인·봇 탐지 뒤에 있고, 우회는 영구 금지다 * (DECISIONS 1-1). 사장님이 **본인 게시물을 옮겨 적고 원문 주소를 단다** — * 손님은 우리 요약을 읽고, 정확한 것은 원문에서 본다. * ★ 날짜는 'YYYY-MM-DD'. 끝 날짜가 없으면 상시로 본다. */ export interface EventItem { title: string; /** '이벤트' 면 진행 여부 배지가 붙고, '공지' 면 그냥 공지다. 없으면 이벤트로 본다. */ kind?: '이벤트' | '공지'; startDate?: string; endDate?: string; /** 무엇을 주는 행사인가 — 한두 문장. 카드에 보이는 요약이다. */ summary?: string; /** 긴 본문. 공지는 지켜야 할 것이 여러 줄이라 요약 한두 문장으로는 안 된다. * 카드에서는 잘려 보이고 모달에서 전부 펴진다 — 잘리는 건 화면뿐, 문서에는 다 있다. */ body?: string; /** 어떻게 참여하나. "예약 시 요청사항에 '9월 이벤트' 라고 적어 주세요" 같은 것. */ howTo?: string; /** 원문(인스타 게시물) 주소. */ postUrl?: string; imageUrl?: string; verified?: DataVerified; source?: DataSource; } export interface PlannerStop { name: string; /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ minutes?: number; /** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */ moveMinutes?: number; note?: string; /** 정거장 사진. 없으면 승차권은 지금처럼 활자만으로 선다(PeopleItem.imageUrl 과 같은 출처·규칙). */ imageUrl?: string; searchQuery?: string; /** * 지도에 핀을 찍을 좌표. **둘 다 있어야** 쓴다 — 하나만 있으면 없는 것으로 본다. * 사장님이 손으로 적을 값이 아니다(발행 때 지오코딩으로 채울 자리). 없으면 그 칸은 * 핀 없이 시간표에만 선다 — 틀린 핀을 찍느니 안 찍는다(LocationSection 과 같은 규칙). */ latitude?: number; longitude?: number; } export interface PlannerItem { name: string; /** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */ season?: string; /** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */ rank?: number; /** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */ startTime?: string; audience?: string; /** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */ why?: string; stops?: PlannerStop[]; verified?: DataVerified; source?: DataSource; } /** * 섹션 타입 → 없으면 그 줄을 통째로 버리는 키. * * ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다. * 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다. */ export const SECTION_ITEM_REQUIRED_KEY: Record = { songs: 'title', daily: 'title', course: 'name', schedule: 'name', people: 'name', chronicle: 'title', reading: 'title', literature: 'workTitle', postcard: 'line', quiz: 'question', planner: 'name', itinerary: 'name', video: 'url', event: 'title', }; /** * 유튜브 주소 → 영상 id. * * ★ **유튜브가 아니면 undefined 다.** 아무 주소나 iframe 에 넣으면 남의 페이지를 우리 도메인 * 안에서 여는 통로가 된다. 알아보는 형식만 재생하고, 나머지는 링크로만 남긴다. * ★ 네 가지 형식을 받는다 — 사장님이 복사해 오는 자리가 그때그때 다르다: * `watch?v=ID` · `youtu.be/ID` · `shorts/ID`(모바일 공유) · `embed/ID`. */ export function youtubeId(url: string | undefined): string | undefined { const text = (url ?? '').trim(); if (!text) return undefined; const match = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{6,20})/.exec( text, ); return match?.[1]; } /** * 세로 영상인가. * * ★ 쇼츠는 9:16 이다. 16:9 틀에 넣으면 좌우가 까맣게 비고 영상이 손톱만 해진다 — * 주소가 이미 말해 주는 것을 무시하지 않는다. */ export function isVerticalVideo(url: string | undefined): boolean { return /youtube\.com\/shorts\//.test((url ?? '').trim()); } /** 재생 전에 보여줄 표지. 유튜브가 영상마다 만들어 두는 것이라 우리가 만들지 않는다. */ export function youtubePoster(id: string): string { return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; } /** * 재생용 주소. * * ★ `youtube-nocookie.com` 을 쓴다. 손님이 재생을 누르기 전에는 아무것도 안 붙고, * 눌러도 광고 추적 쿠키를 먼저 심지 않는다. */ export function youtubeEmbed(id: string): string { return `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&modestbranding=1`; } export interface ParsedSectionData { items: T[]; title?: string; subtitle?: string; /** 섹션 머리에 거는 바깥 링크. 그 섹션을 만든 서비스로 보내는 자리다(예: 영상 → ADO2). */ linkUrl?: string; linkLabel?: string; /** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */ error?: string; /** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */ kindMismatch?: string; /** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */ unverified: number; /** source 가 붙은 항목 수. */ sourced: number; } const EMPTY: ParsedSectionData = {items: [], unverified: 0, sourced: 0}; /** * JSON.parse 실패를 "몇 번째 줄"로 바꾼다. * * ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과, * 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다. * 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다. */ function locate(raw: string, message: string): string { const where = (pos: number) => { const before = raw.slice(0, Math.max(0, pos)); const line = before.split('\n').length; const col = pos - before.lastIndexOf('\n'); return `${line}번째 줄 ${col}번째 글자`; }; const token = /Unexpected token '(.)'/.exec(message)?.[1]; // 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다. const hint = token === '}' || token === ']' ? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.' : '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.'; const lineCol = /line (\d+) column (\d+)/.exec(message); if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`; const at = /position (\d+)/.exec(message); if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`; // 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다. const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1]; const found = quoted ? raw.indexOf(quoted) : -1; if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`; return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.'; } /** * 붙여넣은 문자열 → 렌더 가능한 항목. * * ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. * 발행 사이트에서도 같다 — 프리렌더가 예외로 죽으면 사이트 전체가 안 구워진다. */ export function parseSectionData( sectionType: string, raw: string | undefined, ): ParsedSectionData { const requiredKey = SECTION_ITEM_REQUIRED_KEY[sectionType]; const text = (raw ?? '').trim(); if (!requiredKey || !text) return EMPTY as ParsedSectionData; let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')}; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'}; } const envelope = parsed as Record; const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined; const rawItems = envelope.items; if (!Array.isArray(rawItems)) { return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'}; } const items = rawItems.filter( (item): item is T => typeof item === 'object' && item !== null && !Array.isArray(item) && typeof (item as Record)[requiredKey] === 'string' && ((item as Record)[requiredKey] as string).trim().length > 0, ); let unverified = 0; let sourced = 0; for (const item of items) { const row = item as Record; if (row.verified !== '확인') unverified += 1; if (row.source && typeof row.source === 'object') sourced += 1; } return { items, title: typeof envelope.title === 'string' ? envelope.title : undefined, subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined, // http(s) 만 받는다 — javascript: 를 그대로 에 실으면 붙여넣은 JSON 이 스크립트가 된다. linkUrl: typeof envelope.linkUrl === 'string' && /^https?:\/\//.test(envelope.linkUrl) ? envelope.linkUrl : undefined, linkLabel: typeof envelope.linkLabel === 'string' ? envelope.linkLabel : undefined, kindMismatch: kind && kind !== sectionType ? kind : undefined, unverified, sourced, }; } /* ───────────────────────────────────────────────────────────── * 계절별 추천 하루 — 시각을 **계산해서** 짜 준다. * * ★ 왜 shared 인가: 파서와 같은 이유다. 빌더 캔버스와 발행 사이트가 같은 코스에서 **같은 시각**을 * 내놓아야 한다. 조립 규칙이 두 벌이면 사장님이 본 일정과 손님이 보는 일정이 조용히 갈린다. * ★ 여행 스케줄(schedule)과 축이 다르다 — 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다. * 그래서 사장님은 "몇 분 걸리나"만 알면 되고, 출발 시각을 바꾸면 하루가 통째로 밀린다. * ───────────────────────────────────────────────────────────── */ /** 밤 9시를 넘기는 칸은 넣지 않는다 — 짜 준 일정이 손님을 밤까지 끌고 다니면 그 순간 신뢰를 잃는다. */ const PLAN_ENDS_BY = 21 * 60; /** 머무는 시간을 안 적었을 때. 0 으로 두면 한 시각에 칸이 겹쳐 쌓인다. */ const PLAN_DEFAULT_STAY = 60; /** 출발 시각을 안 적었을 때. 체크아웃 뒤 움직이는 시각을 기본으로 잡는다. */ const PLAN_DEFAULT_START = '10:00'; /** 계절 탭 순서. 데이터에 있는 계절만 이 순서로 세운다 — 붙여넣은 순서대로 두면 겨울이 맨 앞에 온다. */ const SEASON_ORDER = ['봄', '여름', '가을', '겨울']; /** "HH:MM" → 자정부터의 분. 형식이 아니면 undefined — 지어내지 않는다. */ export function minutesOfTime(time: string): number | undefined { const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); if (!match) return undefined; const hour = Number(match[1]); const minute = Number(match[2]); if (hour > 23 || minute > 59) return undefined; return hour * 60 + minute; } export function timeOfMinutes(total: number): string { const wrapped = ((total % 1440) + 1440) % 1440; return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`; } export interface PlannedStop { stop: PlannerStop; /** 도착 시각 "HH:MM". */ time: string; /** 떠나는 시각 "HH:MM". */ until: string; /** 앞 칸에서 오는 데 걸린 분. 0 이면 화면이 이동 줄을 그리지 않는다. */ move: number; } export interface PlannedDay { stops: PlannedStop[]; /** 하루가 시작·끝나는 시각. 카드 머리에 "09:30–16:40" 으로 뜬다. */ from: string; to: string; /** 총 소요(분) — 이동 시간까지 포함한다. */ totalMinutes: number; /** 21시 상한에 걸려 못 넣은 칸 수. 숨기지 않고 화면에 밝힌다. */ dropped: number; } /** * 코스 하나 → 시각이 박힌 하루. * * 정거장 순서는 사장님이 적은 그대로다(적은 순서가 곧 도는 순서다). 출발 시각부터 * 이동·머무는 시간을 누적해 칸마다 도착·출발 시각을 박고, 상한을 넘기는 칸은 버린다 — * 넘겨서라도 다 넣으면 자정에 끝나는 일정이 나온다. */ export function planDay(item: PlannerItem): PlannedDay { const start = minutesOfTime(item.startTime ?? '') ?? minutesOfTime(PLAN_DEFAULT_START) ?? 600; const stops: PlannedStop[] = []; let clock = start; let dropped = 0; for (const stop of item.stops ?? []) { const move = Math.max(0, stop.moveMinutes ?? 0); const stay = Math.max(1, stop.minutes ?? PLAN_DEFAULT_STAY); const arrive = clock + move; if (arrive + stay > PLAN_ENDS_BY) { dropped += 1; continue; } stops.push({stop, time: timeOfMinutes(arrive), until: timeOfMinutes(arrive + stay), move}); clock = arrive + stay; } return { stops, from: timeOfMinutes(start), to: timeOfMinutes(clock), totalMinutes: clock - start, dropped, }; } /** * 지금 계절. **간절기에는 두 개**를 돌려준다. * * ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만 * 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다. * ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 — * 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다. * ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데, * 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다). */ export function currentSeasons(now: Date = new Date()): string[] { const month = now.getMonth() + 1; // 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다. const index = Math.floor(((month - 3 + 12) % 12) / 3); const season = SEASON_ORDER[index]; const isFirstMonth = month % 3 === 0; if (isFirstMonth && now.getDate() <= 15) { return [SEASON_ORDER[(index + 3) % 4], season]; } return [season]; } /** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */ /** * 이 항목이 지금 나갈 계절인가. * * ★ 계절을 가진 아이템은 셋이다 — 계절별 추천 하루(planner) · 여행 스케줄(schedule) · * 일력(daily). 한동안 planner 에만 걸려 있었다. "계절별 콘텐츠는 계절 따라 나간다"는 * 규칙은 아이템 하나가 아니라 계절을 적은 모든 아이템의 규칙이다. * ★ 계절을 안 적은 항목은 **계절을 타지 않는 것**으로 본다 — 가려서는 안 된다. * ★ schedule 의 season 은 자유 문구다("장마", "여름 장마"). 네 계절 이름이 섞여 있으면 * 그 계절로 보고, 우리가 못 읽는 값("장마")은 가리지 않는다 — 분류 못 하는 것을 * 숨기면 사장님이 쓴 콘텐츠가 아무 계절에도 안 나가는 일이 생긴다. */ export function inSeason(season: string | undefined, live: string[]): boolean { const text = season?.trim(); if (!text) return true; const named = SEASON_ORDER.filter((name) => text.includes(name)); if (named.length === 0) return true; return named.some((name) => live.includes(name)); } /** * 일정 한 건 → 일자별로 시각이 박힌 결과. * * ★ `days` 가 없으면 하루짜리로 본다 — 반나절 코스가 그렇다. * ★ 시각 계산은 `planDay` 한 벌이다. 하루짜리와 여러 날짜리가 다른 식을 쓰면 * 같은 코스가 자리에 따라 다른 시각을 낸다. */ export function itineraryDays(item: ItineraryItem): {label: string; day: PlannedDay}[] { const raw = item.days && item.days.length > 0 ? item.days : [{label: undefined, startTime: item.startTime, stops: item.stops}]; return raw .map((entry, index) => ({ label: entry.label?.trim() || (raw.length > 1 ? `${index + 1}일차` : ''), day: planDay({name: item.name, startTime: entry.startTime, stops: entry.stops}), })) .filter((entry) => entry.day.stops.length > 0); } /** * 기간별 묶음. 목록의 축이다. * * ★ 적힌 순서를 지킨다 — 사장님이 쓴 순서가 곧 권하는 순서다. * 기간을 안 적은 일정은 맨 뒤 한 덩이로 모은다(빈 소제목을 만들지 않는다). */ export function itineraryDurations(items: ItineraryItem[]): string[] { const seen: string[] = []; for (const item of items) { const key = item.duration?.trim(); if (key && !seen.includes(key)) seen.push(key); } return seen; } export function plannerSeasons(items: PlannerItem[]): string[] { const seen: string[] = []; for (const item of items) { const season = item.season?.trim(); if (season && !seen.includes(season)) seen.push(season); } return seen.sort((a, b) => { const ai = SEASON_ORDER.indexOf(a); const bi = SEASON_ORDER.indexOf(b); return (ai < 0 ? SEASON_ORDER.length : ai) - (bi < 0 ? SEASON_ORDER.length : bi); }); } /** * 그 계절의 추천 코스 — 순위대로 최대 세 개. * * ★ 셋에서 끊는다. 넷째부터는 아무도 안 고르고, 화면에서는 "추천"이 아니라 목록이 된다. * ★ rank 를 안 적었으면 붙여넣은 순서가 순위다 — 순위 없는 코스를 1위로 올리지 않는다. */ export function plannerTop(items: PlannerItem[], season?: string): PlannerItem[] { const picked = season ? items.filter((item) => item.season?.trim() === season) : [...items]; picked.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER)); return picked.slice(0, 3); }