[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:
parent
ef0c44c5f2
commit
e33bee7e33
@ -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');
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 를 찾아 최신성을 매긴다. */
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user