도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
180 lines
7.3 KiB
TypeScript
180 lines
7.3 KiB
TypeScript
/**
|
||
* 추천 일정 — 캔버스. 승차권 한 장이 정거장 하나다.
|
||
*
|
||
* ★ 셋을 합친 자리다(반나절 산책 · 여행 스케줄 · 계절별 추천 하루). 축이 계절이 아니라 **기간**이다.
|
||
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
|
||
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
|
||
* 출발을 당기면 하루가 통째로 밀린다.
|
||
*/
|
||
import {
|
||
currentSeasons,
|
||
inSeason,
|
||
itineraryDays,
|
||
itineraryDurations,
|
||
parseSectionData,
|
||
type ItineraryItem,
|
||
type PlannedStop,
|
||
} from '@o2o/shared';
|
||
import {SectionBody, SectionFrame, Rail} from '../../primitives';
|
||
import type {SectionRenderProps} from '../../types';
|
||
import {
|
||
ITEM_ACCENT,
|
||
ITEM_BODY,
|
||
ITEM_BORDER,
|
||
ITEM_CARD,
|
||
ITEM_HEADING,
|
||
ParseError,
|
||
PasteHint,
|
||
SourceLine,
|
||
} from '../items/common';
|
||
import '../items/items.css';
|
||
|
||
export function ItineraryTickets(props: SectionRenderProps) {
|
||
const {section, isSelected, onSelect} = props;
|
||
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
|
||
const live = currentSeasons();
|
||
const durations = itineraryDurations(parsed.items);
|
||
const groups: (string | undefined)[] =
|
||
durations.length > 0
|
||
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
|
||
: [undefined];
|
||
|
||
return (
|
||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||
<SectionBody width="wide">
|
||
<div className="space-y-1.5">
|
||
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
||
{parsed.title || section.name}
|
||
</h2>
|
||
{(parsed.subtitle || section.description) && (
|
||
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
|
||
{parsed.subtitle || section.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{parsed.error ? (
|
||
<ParseError message={parsed.error} />
|
||
) : parsed.items.length === 0 ? (
|
||
<PasteHint label="추천 일정" />
|
||
) : (
|
||
<div className="space-y-7">
|
||
{groups.map((duration) => {
|
||
const items = parsed.items.filter((item) =>
|
||
duration === undefined
|
||
? !item.duration?.trim()
|
||
: item.duration?.trim() === duration,
|
||
);
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<div key={duration ?? 'etc'} className="space-y-4">
|
||
{duration && (
|
||
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
|
||
{duration}
|
||
</h3>
|
||
)}
|
||
{items.map((item, index) => (
|
||
<div key={`${item.name}-${index}`} className="space-y-2.5">
|
||
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
|
||
{item.name}
|
||
</h4>
|
||
<span className="text-[11px] opacity-60">
|
||
{[item.audience, item.season].filter(Boolean).join(' · ')}
|
||
</span>
|
||
{/* 계절을 적으면 손님 화면에서는 그 계절에만 나간다 — 사장님이 알아야 한다. */}
|
||
{item.season && (
|
||
<span className="text-[11px] opacity-60">
|
||
{inSeason(item.season, live)
|
||
? '· 지금 나갑니다'
|
||
: `· 손님 화면에는 ${item.season} 에만 나갑니다`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{item.why && (
|
||
<p className="max-w-[52ch] text-[13px] leading-relaxed opacity-80">
|
||
{item.why}
|
||
</p>
|
||
)}
|
||
|
||
{itineraryDays(item).map((entry, dayIndex) => (
|
||
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
|
||
{entry.label && (
|
||
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
|
||
{entry.label}
|
||
<span className="ml-2 font-normal opacity-60">
|
||
{entry.day.from}–{entry.day.to}
|
||
</span>
|
||
</p>
|
||
)}
|
||
<Rail label={`${item.name} ${entry.label || '정거장'}`}>
|
||
{entry.day.stops.map((planned, stopIndex) => (
|
||
<Ticket
|
||
key={`${planned.stop.name}-${stopIndex}`}
|
||
planned={planned}
|
||
no={stopIndex + 1}
|
||
isLast={stopIndex === entry.day.stops.length - 1}
|
||
/>
|
||
))}
|
||
</Rail>
|
||
{entry.day.dropped > 0 && (
|
||
<p className="text-[11px] opacity-60">
|
||
밤 9시를 넘기는 {entry.day.dropped}곳은 화면에서 뺐습니다.
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<SourceLine source={item.source} verified={item.verified} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</SectionBody>
|
||
</SectionFrame>
|
||
);
|
||
}
|
||
|
||
/** 승차권 한 장. 마지막 장에는 완주 도장(아티팩트의 스탬프투어 형식). */
|
||
function Ticket({planned, no, isLast}: {planned: PlannedStop; no: number; isLast: boolean}) {
|
||
const {stop, time, until, move} = planned;
|
||
return (
|
||
<article
|
||
className="w4-perf relative w-[214px] shrink-0 border"
|
||
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
|
||
>
|
||
<div
|
||
className="flex items-baseline justify-between gap-2 border-b border-dashed px-3.5 py-2"
|
||
style={{borderColor: ITEM_BORDER}}
|
||
>
|
||
<span className="text-[10px] tracking-[0.2em] opacity-60">
|
||
NO.{String(no).padStart(2, '0')}
|
||
</span>
|
||
<span className="text-[13px] font-bold tabular-nums" style={{fontFamily: ITEM_HEADING}}>
|
||
{time}
|
||
<span className="opacity-55">–{until}</span>
|
||
</span>
|
||
</div>
|
||
<div className="space-y-1 px-3.5 pb-3.5 pt-2.5">
|
||
<h5 className="text-sm font-bold" style={{fontFamily: ITEM_HEADING}}>
|
||
{stop.name}
|
||
</h5>
|
||
{stop.note && <p className="text-[12px] leading-relaxed opacity-80">{stop.note}</p>}
|
||
{move > 0 && <p className="text-[10px] opacity-55">↓ 앞 칸에서 {move}분 이동</p>}
|
||
</div>
|
||
{isLast && (
|
||
<span
|
||
className="absolute bottom-2.5 right-2.5 grid size-10 rotate-[-12deg] place-items-center rounded-full border-2 text-[10px] font-bold"
|
||
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
|
||
aria-hidden
|
||
>
|
||
완주
|
||
</span>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|