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', () => {