From 65705c5bed43924702d2f69c8a5370070f1cc218 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 21:58:50 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/shared,site,frontend:=20?= =?UTF-8?q?=EA=B3=84=EC=A0=88=EB=B3=84=20=EC=B6=94=EC=B2=9C=20=ED=95=98?= =?UTF-8?q?=EB=A3=A8=EB=8A=94=20=EC=A7=80=EA=B8=88=20=EA=B3=84=EC=A0=88?= =?UTF-8?q?=EB=A7=8C=20=E2=80=94=20=EA=B0=84=EC=A0=88=EA=B8=B0=EC=97=94=20?= =?UTF-8?q?=EB=91=90=20=EA=B3=84=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. 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 에는 네 계절 전부 있는 것을 브라우저에서 확인. --- docs/DEVLOG.md | 25 +++++++++++ .../canvas/variants/planner/PlannerPodium.tsx | 44 ++++++++++++++----- solution/shared/src/lib/section-data.ts | 22 ++++++++++ .../src/sections/items/PlannerSection.tsx | 36 ++++++++++++--- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 78bcc77..0a5e4db 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 **왜** diff --git a/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx index b4bfea8..38e139a 100644 --- a/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/planner/PlannerPodium.tsx @@ -5,12 +5,22 @@ * 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다. * 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다). * ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다. + * ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을 + * 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 — + * 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다. */ import {useMemo, useState} from 'react'; import {cn} from '@/lib/utils'; import {SectionBody, SectionFrame} from '../../primitives'; 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 { ITEM_ACCENT, ITEM_BODY, @@ -126,10 +136,14 @@ export function PlannerPodium(props: SectionRenderProps) { const {section, isSelected, onSelect} = props; const parsed = parseSectionData(section.type, section.data); 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(); + const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? '')); // 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다. - const season = seasons[picked] ?? seasons[0]; + const season = seasons[index] ?? seasons[0]; const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]); return ( @@ -154,31 +168,36 @@ export function PlannerPodium(props: SectionRenderProps) {
{seasons.length > 1 && (
- {seasons.map((name, index) => ( + {seasons.map((name, tabIndex) => ( ))}
)}
- {top.map((item, index) => ( - + {top.map((item, rank) => ( + ))}
@@ -186,6 +205,11 @@ export function PlannerPodium(props: SectionRenderProps) { 시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '} {top.length}개 / 전체 {parsed.items.length}개

+ {live.length > 0 && ( +

+ 손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다. +

+ )}
)} diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index a3f8f0f..be4c7ec 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -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[] { const seen: string[] = []; diff --git a/solution/site/src/sections/items/PlannerSection.tsx b/solution/site/src/sections/items/PlannerSection.tsx index 5053206..3fef443 100644 --- a/solution/site/src/sections/items/PlannerSection.tsx +++ b/solution/site/src/sections/items/PlannerSection.tsx @@ -1,12 +1,18 @@ /** * 계절별 추천 하루 — 발행본. * - * ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.** - * 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다. + * ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게 + * 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다. + * ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 — + * ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도 + * 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). + * ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다. + * → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다. * ★ 시각은 화면이 계산한다(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 {sectionItems, sectionName} from '@/lib/derive'; 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() { const payload = useSite(); const parsed = sectionItems(payload, 'planner'); + + // ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다. + // 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다. + const [now, setNow] = useState(); + useEffect(() => setNow(currentSeasons()), []); + if (parsed.items.length === 0) return null; const seasons = plannerSeasons(parsed.items); // 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다. const groups = seasons.length > 0 ? seasons : [undefined]; + // 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — + // 빈 섹션보다 철 지난 코스가 낫다. + const shown = now?.filter((season) => seasons.includes(season)) ?? []; + const filtering = shown.length > 0; + return (
{groups.map((season) => { const top = plannerTop(parsed.items, season); if (top.length === 0) return null; return ( -
+