[fix] solution/site: 날씨를 HTML 에 굽지 않음 — 발행 시점 날씨가 박히고 매번 하이드레이션 실패

발행본 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 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-10-01 14:33:03 +09:00
parent ef0c44c5f2
commit e33bee7e33
9 changed files with 51 additions and 78 deletions

View File

@ -46,7 +46,7 @@ it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래
payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])]; payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])];
const html = renderToStaticMarkup(<App payload={payload} />); const html = renderToStaticMarkup(<App payload={payload} />);
const hero = html.indexOf('class="hero"'); 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(hero).toBeGreaterThan(-1);
expect(brief).toBeGreaterThan(hero); expect(brief).toBeGreaterThan(hero);
expect(html.slice(hero, brief)).not.toContain('class="sec'); expect(html.slice(hero, brief)).not.toContain('class="sec');

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -11,7 +11,8 @@ export function formatKoreanDate(iso: string | null | undefined): string {
if (!iso) return ''; if (!iso) return '';
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return ''; 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 를 찾아 최신성을 매긴다. */ /** 왜 한글 표기가 아닌가 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. */

View File

@ -41,7 +41,7 @@ export function condition(code: number): string {
return WEATHER_CONDITION_BY_CODE[code] ?? '흐림'; return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
} }
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */ /** 날씨는 열 때마다 바뀌는 값이라 HTML 에 굽지 않는다 — 하이드레이션 뒤에 스냅샷을 띄우고 최신 값으로 갈아 끼운다. */
export function useLiveWeather({ export function useLiveWeather({
initial, initial,
regionCode, regionCode,
@ -53,7 +53,11 @@ export function useLiveWeather({
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
}): LiveWeather | undefined { }): LiveWeather | undefined {
const [weather, setWeather] = useState<LiveWeather | undefined>(initial); const [weather, setWeather] = useState<LiveWeather | undefined>(undefined);
useEffect(() => {
setWeather((current) => current ?? initial);
}, [initial]);
useEffect(() => { useEffect(() => {
if (!regionCode || latitude == null || longitude == null) return; if (!regionCode || latitude == null || longitude == null) return;

View File

@ -1,8 +1,8 @@
/** 오늘의 날씨. */ /** 오늘의 날씨. */
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react'; 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 {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 {useWeatherNotes} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive'; import {weatherBand, weatherMood} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui'; 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} 관측`; 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() { export function WeatherSection() {
const payload = useSite(); const payload = useSite();
const weather = useLiveWeather({ const weather = useLiveWeather({
@ -27,15 +41,9 @@ export function WeatherSection() {
const mood = weatherMood(weather?.condition ?? ''); const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0); const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather; const sources = weatherLineSources(weather, payload.local.weather);
// 하늘 문구는 아홉 갈래로 고른다(WeatherSky).
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
/* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ /* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */
const [line, bandLine] = useWeatherNotes( const [line, bandLine] = useWeatherNotes(sources.sky, sources.skyFallback, sources.temp, sources.tempFallback);
notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band], notes?.tempNotes?.[band],
);
if (!weather) return null; if (!weather) return null;
const observed = observedText(weather.observedAt); const observed = observedText(weather.observedAt);
const SkyIcon = MOOD_ICON[mood]; const SkyIcon = MOOD_ICON[mood];

View File

@ -4,7 +4,7 @@ import {SiteProvider} from '@site/lib/site-context';
import {LayoutProvider} from '@site/lib/layout'; import {LayoutProvider} from '@site/lib/layout';
import {LAYOUTS} from '@site/layouts'; import {LAYOUTS} from '@site/layouts';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {WeatherSection} from './WeatherSection'; import {WeatherSection, weatherLineSources} from './WeatherSection';
import type {WeatherSnapshot} from '@o2o/shared'; import type {WeatherSnapshot} from '@o2o/shared';
function render(weather?: WeatherSnapshot) { function render(weather?: WeatherSnapshot) {
@ -13,29 +13,33 @@ function render(weather?: WeatherSnapshot) {
return renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><WeatherSection /></LayoutProvider></SiteProvider>); return renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><WeatherSection /></LayoutProvider></SiteProvider>);
} }
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: {'혹서': ['더위 첫 줄']}}; const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
expect(render(weather)).toContain('맑음 첫 줄'); const sources = weatherLineSources(weather, weather);
expect(render(weather)).toContain('더위 첫 줄'); expect(sources.sky).toEqual(['맑음 첫 줄', '다음 줄']);
expect(render(weather)).not.toContain('다음 줄'); expect(sources.temp).toEqual(['더위 첫 줄']);
expect(render(weather)).toBe(render(weather));
}); });
it('uses condition-specific notes for fog, showers and thunderstorms', () => { it('uses condition-specific notes for fog, showers and thunderstorms', () => {
const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '보통 소나기', noteSets: {'보통 소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}}; 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', () => { it('uses condition-specific notes for the rain subtypes', () => {
const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}}; const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}};
expect(render(weather)).toContain('어는비 첫 줄'); expect(weatherLineSources(weather, weather).sky).toEqual(['어는비 첫 줄']);
expect(render(weather)).not.toContain('일반 비 첫 줄');
}); });
it('preserves legacy single notes', () => { it('preserves legacy single notes', () => {
const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}}); const weather = {temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}};
expect(html).toContain('기존 비 안내'); const sources = weatherLineSources(weather, weather);
expect(html).toContain('겉옷 안내'); expect(sources.skyFallback).toBe('기존 비 안내');
expect(sources.tempFallback).toBe('겉옷 안내');
}); });
it('does not invent weather when an observation is missing', () => { it('does not invent weather when an observation is missing', () => {