[feat] solution/frontend: 여행 스케줄 아이템 — 대합실 시간표

가요다방·일력·승차권에 이어 네 번째 붙여넣기 아이템이다. 승차권(course)은 '어디를 도는가'라
순번이 축인데, 손님이 실제로 묻는 건 '몇 시에 뭘 하나'다. 시각을 축으로 하는 칸이 없었다.

- dataSpec/schedule: ScheduleItem·ScheduleSlot 과 작성 규칙. time 은 "HH:MM" 만,
  장소에 url 대신 searchQuery — 지어낸 주소를 링크하지 않는 규약 그대로다
- registry/schedule.timetable: 역 대합실 플립보드(가운데 접힘선)로 시각을 먼저 읽힌다
- industryData: 레트로 템플릿 시드에 schedule 추가

[+ 섹션 추가] 목록은 dataSpec 에서 파생돼(addable.ts) 따로 손댈 곳이 없다.
tsc·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 15:17:21 +09:00
parent 8410380769
commit 38fbe0ee7b
4 changed files with 257 additions and 1 deletions

View File

@ -103,7 +103,7 @@ function templatesFor(
fontStyle: '옛 간판체', fontStyle: '옛 간판체',
look: LOOK.retro, look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다. // 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course'], defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
}, },
]; ];
} }

View File

@ -55,6 +55,26 @@ export interface CourseItem {
source?: DataSource; 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 SectionDataSpec { export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string; kind: string;
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다. · minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`, · 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
}, },
schedule: {
kind: 'schedule',
label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
[스키마]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
}; };
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
import {SongsTurntable} from './variants/songs/SongsTurntable'; import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar'; import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets'; import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = { export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [ hero: [
@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
}, },
], ],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
exhibition: [ exhibition: [
{ {
id: 'exhibition.gallery', id: 'exhibition.gallery',

View File

@ -0,0 +1,174 @@
/**
* 여행 스케줄 — 대합실 시간표 캐러셀.
*
* 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 —
* 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다.
* ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return {head: time.trim()};
return {head: match[1].padStart(2, '0'), tail: match[2]};
}
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
const {head, tail} = splitTime(slot.time ?? '');
return (
<div className="flex shrink-0 snap-center items-stretch">
<div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}>
<span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
>
{head}
{tail && <span className="text-[#c0b493]">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
{slot.title}
</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}>
{slot.place}
</p>
)}
{slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{slot.note}
</p>
)}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
</div>
</div>
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
</div>
)}
</div>
);
}
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
{schedule.name}
</h3>
<span className="text-[11px] text-stone-500">
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{slots.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
)}
</div>
{slots.length === 0 ? (
<p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{slots.map((slot, index) => (
<Slot
key={`${slot.time}-${slot.title}-${index}`}
slot={slot}
isLast={index === slots.length - 1}
/>
))}
</div>
)}
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
}
export function ScheduleTimetable(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ScheduleItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="여행 스케줄" />
) : (
<div className="space-y-8">
{parsed.items.map((schedule, index) => (
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}