From e33bee7e33947c3a22dfbd9e6d54352b76d8cc97 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 1 Oct 2026 14:33:03 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/site:=20=EB=82=A0=EC=94=A8?= =?UTF-8?q?=EB=A5=BC=20HTML=20=EC=97=90=20=EA=B5=BD=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=EB=B0=9C=ED=96=89=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=20=EB=82=A0=EC=94=A8=EA=B0=80=20=EB=B0=95=ED=9E=88=EA=B3=A0=20?= =?UTF-8?q?=EB=A7=A4=EB=B2=88=20=ED=95=98=EC=9D=B4=EB=93=9C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=8B=A4=ED=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발행본 HTML 에 그 순간 기온·관측 시각이 박혀 구글과 첫 화면이 며칠 전 날씨를 봤다. 라운드·코랄·미니멀·솔숲은 관측 시각(시간대 없는 KST)을 new Date 로 읽어 굽는 서버(UTC)가 9시간 틀린 값을 박았고, 브라우저 값과 달라 열 때마다 React #418 → 페이지 전체를 다시 그렸다 (실측 /s/joyyy: HTML "22:45 관측" ↔ 브라우저 "13:45 관측"). - lib/use-live-weather.ts: 첫 렌더는 날씨 없음, 하이드레이션 뒤 스냅샷 → 최신 값 - layouts/{round,coral,minimal,pine}/Weather.tsx: 관측 시각을 글자 그대로 읽는다(다른 템플릿과 같게) - lib/format.ts formatKoreanDate: 한국 시간 기준 날짜 — UTC 서버가 0~9시 KST 발행에서 하루 전 날짜를 박던 것 - WeatherSection: 문구 고르는 부분을 weatherLineSources 로 떼어 테스트. HTML 에 날씨가 없음을 테스트로 고정 tsc·eslint 통과 · vitest 128 passed · TZ=UTC 로 구운 10개 사이트 #418 0건 Co-Authored-By: Claude Opus 5.5 --- solution/site/src/app.test.tsx | 2 +- solution/site/src/layouts/coral/Weather.tsx | 15 ++-------- solution/site/src/layouts/minimal/Weather.tsx | 15 ++-------- solution/site/src/layouts/pine/Weather.tsx | 15 ++-------- solution/site/src/layouts/round/Weather.tsx | 15 ++-------- solution/site/src/lib/format.ts | 3 +- solution/site/src/lib/use-live-weather.ts | 8 ++++-- solution/site/src/sections/WeatherSection.tsx | 28 ++++++++++++------- solution/site/src/sections/weather.test.tsx | 28 +++++++++++-------- 9 files changed, 51 insertions(+), 78 deletions(-) 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', () => {