[feat] solution/shared,site,frontend: 계절별 추천 하루는 지금 계절만 — 간절기엔 두 계절

네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.

- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
  계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
  9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
  ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
     계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
  ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
  지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
  "손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다

tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
This commit is contained in:
Mina Choi 2026-09-02 21:58:50 +09:00
parent 71c0c1f6ab
commit 65705c5bed
4 changed files with 112 additions and 15 deletions

View File

@ -5,6 +5,31 @@
--- ---
## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
**왜**
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
**한 일**
- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
**계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다.
9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다.
- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 —
① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다.
그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다.
- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
빈 섹션보다 철 지난 코스가 낫다.
- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와
"손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을
다 본다고 오해한다.
**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인
(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울).
실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고
봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인.
## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 ## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
**왜** **왜**

View File

@ -5,12 +5,22 @@
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다. * 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다). * 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다. * ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
*/ */
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; import {
currentSeasons,
parseSectionData,
planDay,
plannerSeasons,
plannerTop,
type PlannerItem,
} from '@o2o/shared';
import { import {
ITEM_ACCENT, ITEM_ACCENT,
ITEM_BODY, ITEM_BODY,
@ -126,10 +136,14 @@ export function PlannerPodium(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props; const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PlannerItem>(section.type, section.data); const parsed = parseSectionData<PlannerItem>(section.type, section.data);
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]); const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
const [picked, setPicked] = useState(0);
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
const [picked, setPicked] = useState<number>();
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다. // 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[picked] ?? seasons[0]; const season = seasons[index] ?? seasons[0];
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]); const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
return ( return (
@ -154,31 +168,36 @@ export function PlannerPodium(props: SectionRenderProps) {
<div className="space-y-4"> <div className="space-y-4">
{seasons.length > 1 && ( {seasons.length > 1 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{seasons.map((name, index) => ( {seasons.map((name, tabIndex) => (
<button <button
key={name} key={name}
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setPicked(index); setPicked(tabIndex);
}} }}
aria-current={index === picked} aria-current={tabIndex === index}
className={cn('border px-3 py-1 text-xs transition-opacity', index !== picked && 'opacity-55')} className={cn(
'border px-3 py-1 text-xs transition-opacity',
tabIndex !== index && 'opacity-55',
)}
style={ style={
index === picked tabIndex === index
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK} ? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER} : {borderColor: ITEM_BORDER}
} }
> >
{name} {name}
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
</button> </button>
))} ))}
</div> </div>
)} )}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3"> <div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, index) => ( {top.map((item, rank) => (
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} /> <PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
))} ))}
</div> </div>
@ -186,6 +205,11 @@ export function PlannerPodium(props: SectionRenderProps) {
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '} 시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개 {top.length}개 / 전체 {parsed.items.length}개
</p> </p>
{live.length > 0 && (
<p className="text-[11px] opacity-60">
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
</p>
)}
</div> </div>
)} )}
</SectionBody> </SectionBody>

View File

@ -384,6 +384,28 @@ export function planDay(item: PlannerItem): PlannedDay {
}; };
} }
/**
* 지금 계절. **간절기에는 두 개**를 돌려준다.
*
* ★ 왜 둘인가 — 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];
}
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */ /** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
export function plannerSeasons(items: PlannerItem[]): string[] { export function plannerSeasons(items: PlannerItem[]): string[] {
const seen: string[] = []; const seen: string[] = [];

View File

@ -1,12 +1,18 @@
/** /**
* 계절별 추천 하루 — 발행본. * 계절별 추천 하루 — 발행본.
* *
* ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.** * ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게
* 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다. * 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다.
* ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 —
* ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도
* 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
* ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다.
* → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다.
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다. * ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다. * 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
*/ */
import {planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; import {useEffect, useState} from 'react';
import {currentSeasons, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive'; import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common'; import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common';
@ -85,25 +91,45 @@ function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
export function PlannerSection() { export function PlannerSection() {
const payload = useSite(); const payload = useSite();
const parsed = sectionItems<PlannerItem>(payload, 'planner'); const parsed = sectionItems<PlannerItem>(payload, 'planner');
// ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다.
// 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다.
const [now, setNow] = useState<string[]>();
useEffect(() => setNow(currentSeasons()), []);
if (parsed.items.length === 0) return null; if (parsed.items.length === 0) return null;
const seasons = plannerSeasons(parsed.items); const seasons = plannerSeasons(parsed.items);
// 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다. // 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다.
const groups = seasons.length > 0 ? seasons : [undefined]; const groups = seasons.length > 0 ? seasons : [undefined];
// 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
// 빈 섹션보다 철 지난 코스가 낫다.
const shown = now?.filter((season) => seasons.includes(season)) ?? [];
const filtering = shown.length > 0;
return ( return (
<ItemSection <ItemSection
id="planner" id="planner"
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')} name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
subtitle={parsed.subtitle} subtitle={parsed.subtitle}
count="시각은 출발 시각과 머무는 시간으로 계산한 것입니다" count={
filtering
? `지금은 ${shown.join(' · ')} 코스입니다 · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`
: '시각은 출발 시각과 머무는 시간으로 계산한 것입니다'
}
> >
<div className="space-y-10"> <div className="space-y-10">
{groups.map((season) => { {groups.map((season) => {
const top = plannerTop(parsed.items, season); const top = plannerTop(parsed.items, season);
if (top.length === 0) return null; if (top.length === 0) return null;
return ( return (
<div key={season ?? 'all'} className="space-y-3"> <div
key={season ?? 'all'}
className="space-y-3"
// 문서에는 남기고 화면에서만 접는다 — 검색·AI 는 전 계절을 읽는다.
hidden={filtering && season !== undefined && !shown.includes(season)}
>
{season && <h3 className="serif text-lg font-bold">{season}</h3>} {season && <h3 className="serif text-lg font-bold">{season}</h3>}
<Rail label={`${season ?? '추천'} 코스`}> <Rail label={`${season ?? '추천'} 코스`}>
{top.map((item, index) => ( {top.map((item, index) => (