diff --git a/solution/site/src/app.test.tsx b/solution/site/src/app.test.tsx index cc5611e..55ffbd9 100644 --- a/solution/site/src/app.test.tsx +++ b/solution/site/src/app.test.tsx @@ -46,7 +46,7 @@ it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래 payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])]; const html = renderToStaticMarkup(); const hero = html.indexOf('class="hero"'); - const brief = html.indexOf('class="wbrief"'); + const brief = html.indexOf('data-editor-id="weather"'); expect(hero).toBeGreaterThan(-1); expect(brief).toBeGreaterThan(hero); expect(html.slice(hero, brief)).not.toContain('class="sec'); diff --git a/solution/site/src/layouts/coral/Weather.tsx b/solution/site/src/layouts/coral/Weather.tsx index f42d0a9..6b478b0 100644 --- a/solution/site/src/layouts/coral/Weather.tsx +++ b/solution/site/src/layouts/coral/Weather.tsx @@ -12,20 +12,9 @@ const MOOD_ICON = { 눈: SnowIcon, } as const; -const OBSERVED = new Intl.DateTimeFormat('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', -}); - function observed(iso: string): string | undefined { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return undefined; - const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value])); - return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`; + const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso); + return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined; } export function Weather() { diff --git a/solution/site/src/layouts/minimal/Weather.tsx b/solution/site/src/layouts/minimal/Weather.tsx index f42d0a9..6b478b0 100644 --- a/solution/site/src/layouts/minimal/Weather.tsx +++ b/solution/site/src/layouts/minimal/Weather.tsx @@ -12,20 +12,9 @@ const MOOD_ICON = { 눈: SnowIcon, } as const; -const OBSERVED = new Intl.DateTimeFormat('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', -}); - function observed(iso: string): string | undefined { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return undefined; - const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value])); - return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`; + const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso); + return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined; } export function Weather() { diff --git a/solution/site/src/layouts/pine/Weather.tsx b/solution/site/src/layouts/pine/Weather.tsx index 9d1b242..b22a820 100644 --- a/solution/site/src/layouts/pine/Weather.tsx +++ b/solution/site/src/layouts/pine/Weather.tsx @@ -12,20 +12,9 @@ const MOOD_ICON = { 눈: SnowIcon, } as const; -const OBSERVED = new Intl.DateTimeFormat('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', -}); - function observed(iso: string): string | undefined { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return undefined; - const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value])); - return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`; + const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso); + return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined; } export function Weather() { diff --git a/solution/site/src/layouts/round/Weather.tsx b/solution/site/src/layouts/round/Weather.tsx index 9d1b242..b22a820 100644 --- a/solution/site/src/layouts/round/Weather.tsx +++ b/solution/site/src/layouts/round/Weather.tsx @@ -12,20 +12,9 @@ const MOOD_ICON = { 눈: SnowIcon, } as const; -const OBSERVED = new Intl.DateTimeFormat('ko-KR', { - timeZone: 'Asia/Seoul', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23', -}); - function observed(iso: string): string | undefined { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return undefined; - const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value])); - return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`; + const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso); + return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined; } export function Weather() { diff --git a/solution/site/src/lib/format.ts b/solution/site/src/lib/format.ts index 63a8194..d72f785 100644 --- a/solution/site/src/lib/format.ts +++ b/solution/site/src/lib/format.ts @@ -11,7 +11,8 @@ export function formatKoreanDate(iso: string | null | undefined): string { if (!iso) return ''; const date = new Date(iso); if (Number.isNaN(date.getTime())) return ''; - return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; + const [year, month, day] = date.toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'}).split('-').map(Number); + return `${year}년 ${month}월 ${day}일`; } /** 왜 한글 표기가 아닌가 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. */ diff --git a/solution/site/src/lib/use-live-weather.ts b/solution/site/src/lib/use-live-weather.ts index 24ee0cd..7405803 100644 --- a/solution/site/src/lib/use-live-weather.ts +++ b/solution/site/src/lib/use-live-weather.ts @@ -41,7 +41,7 @@ export function condition(code: number): string { return WEATHER_CONDITION_BY_CODE[code] ?? '흐림'; } -/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */ +/** 날씨는 열 때마다 바뀌는 값이라 HTML 에 굽지 않는다 — 하이드레이션 뒤에 스냅샷을 띄우고 최신 값으로 갈아 끼운다. */ export function useLiveWeather({ initial, regionCode, @@ -53,7 +53,11 @@ export function useLiveWeather({ latitude?: number; longitude?: number; }): LiveWeather | undefined { - const [weather, setWeather] = useState(initial); + const [weather, setWeather] = useState(undefined); + + useEffect(() => { + setWeather((current) => current ?? initial); + }, [initial]); useEffect(() => { if (!regionCode || latitude == null || longitude == null) return; diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 00da37f..cffea81 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -1,8 +1,8 @@ /** 오늘의 날씨. */ import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react'; -import type {WeatherSky} from '@o2o/shared'; +import type {WeatherSky, WeatherSnapshot} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; -import {useLiveWeather} from '@site/lib/use-live-weather'; +import {useLiveWeather, type LiveWeather} from '@site/lib/use-live-weather'; import {useWeatherNotes} from '@site/lib/use-weather-note'; import {weatherBand, weatherMood} from '@site/lib/derive'; import {Card, Section} from '@site/lib/ui'; @@ -16,6 +16,20 @@ function observedText(iso: string | undefined): string | undefined { return `${Number(month)}월 ${Number(day)}일 ${hour}:${minute} 관측`; } +/** 하늘 문구는 아홉 갈래(WeatherSky), 기온 문구는 기온대로 고른다. */ +export function weatherLineSources(weather: LiveWeather | undefined, notes: WeatherSnapshot | undefined) { + const mood = weatherMood(weather?.condition ?? ''); + const band = weatherBand(weather?.temperature ?? 0); + const condition = (weather?.condition ?? '') as WeatherSky; + const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood; + return { + sky: notes?.noteSets?.[skyKey], + skyFallback: notes?.notes?.[mood] ?? weather?.note, + temp: notes?.tempNoteSets?.[band], + tempFallback: notes?.tempNotes?.[band], + }; +} + export function WeatherSection() { const payload = useSite(); const weather = useLiveWeather({ @@ -27,15 +41,9 @@ export function WeatherSection() { const mood = weatherMood(weather?.condition ?? ''); const band = weatherBand(weather?.temperature ?? 0); - const notes = payload.local.weather; - // 하늘 문구는 아홉 갈래로 고른다(WeatherSky). - const condition = (weather?.condition ?? '') as WeatherSky; - const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood; + const sources = weatherLineSources(weather, payload.local.weather); /* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ - const [line, bandLine] = useWeatherNotes( - notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note, - notes?.tempNoteSets?.[band], notes?.tempNotes?.[band], - ); + const [line, bandLine] = useWeatherNotes(sources.sky, sources.skyFallback, sources.temp, sources.tempFallback); if (!weather) return null; const observed = observedText(weather.observedAt); const SkyIcon = MOOD_ICON[mood]; diff --git a/solution/site/src/sections/weather.test.tsx b/solution/site/src/sections/weather.test.tsx index 6bda9f8..91917ef 100644 --- a/solution/site/src/sections/weather.test.tsx +++ b/solution/site/src/sections/weather.test.tsx @@ -4,7 +4,7 @@ import {SiteProvider} from '@site/lib/site-context'; import {LayoutProvider} from '@site/lib/layout'; import {LAYOUTS} from '@site/layouts'; import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; -import {WeatherSection} from './WeatherSection'; +import {WeatherSection, weatherLineSources} from './WeatherSection'; import type {WeatherSnapshot} from '@o2o/shared'; function render(weather?: WeatherSnapshot) { @@ -13,29 +13,33 @@ function render(weather?: WeatherSnapshot) { return renderToStaticMarkup(); } -it('uses the first sky and temperature lines for deterministic HTML', () => { +it('keeps the weather out of the baked HTML — it is filled in the browser', () => { + const html = render({temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄']}}); + expect(html).toBe(''); +}); + +it('picks sky lines by condition and temperature lines by band', () => { const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}}; - expect(render(weather)).toContain('맑음 첫 줄'); - expect(render(weather)).toContain('더위 첫 줄'); - expect(render(weather)).not.toContain('다음 줄'); - expect(render(weather)).toBe(render(weather)); + const sources = weatherLineSources(weather, weather); + expect(sources.sky).toEqual(['맑음 첫 줄', '다음 줄']); + expect(sources.temp).toEqual(['더위 첫 줄']); }); it('uses condition-specific notes for fog, showers and thunderstorms', () => { const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '보통 소나기', noteSets: {'보통 소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}}; - expect(render(weather)).toContain('소나기 첫 줄'); + expect(weatherLineSources(weather, weather).sky).toEqual(['소나기 첫 줄']); }); it('uses condition-specific notes for the rain subtypes', () => { const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}}; - expect(render(weather)).toContain('어는비 첫 줄'); - expect(render(weather)).not.toContain('일반 비 첫 줄'); + expect(weatherLineSources(weather, weather).sky).toEqual(['어는비 첫 줄']); }); it('preserves legacy single notes', () => { - const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}}); - expect(html).toContain('기존 비 안내'); - expect(html).toContain('겉옷 안내'); + const weather = {temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}}; + const sources = weatherLineSources(weather, weather); + expect(sources.skyFallback).toBe('기존 비 안내'); + expect(sources.tempFallback).toBe('겉옷 안내'); }); it('does not invent weather when an observation is missing', () => {