WMO weather_code 51~57(이슬비 3단계), 61~67(비/어는비 혼재) 등을 "이슬비"·"비" 같은 큰 구간으로 뭉쳐 표시하던 것을 코드 하나당 고유 라벨로 바꿨다. 서버 프리렌더 스냅샷(site_payload._WEATHER_CONDITION_BY_CODE)과 브라우저 재조회 (use-live-weather.ts WEATHER_CONDITION_BY_CODE)가 같은 표를 봐야 하이드레이션 전후로 문구가 안 바뀐다는 기존 불변식은 유지한다. - docs/WEATHER.md: 27개 코드 전체를 "구간→분류" 표에서 "코드→고유 라벨" 표로 재작성 - weather_notes.json: 코드별 문구 갱신 - use-live-weather.ts/.test.ts, weather.test.tsx: 새 라벨 반영
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import {useEffect, useState} from 'react';
|
|
import type {WeatherSnapshot} from '@o2o/shared';
|
|
|
|
export interface LiveWeather extends WeatherSnapshot {
|
|
stale?: boolean;
|
|
}
|
|
|
|
/**
|
|
* WMO weather code → 한 줄. 코드 하나마다 고유 문구다(뭉치지 않는다) — ★
|
|
* solution/backend/services/site_payload.py 의 _WEATHER_CONDITION_BY_CODE 와 같은 표여야 한다.
|
|
*/
|
|
const WEATHER_CONDITION_BY_CODE: Record<number, string> = {
|
|
0: '맑음',
|
|
1: '대체로 맑음',
|
|
2: '구름 조금',
|
|
3: '흐림',
|
|
45: '안개',
|
|
48: '착빙성 안개',
|
|
51: '가벼운 이슬비',
|
|
53: '보통 이슬비',
|
|
55: '강한 이슬비',
|
|
56: '가벼운 착빙성 이슬비',
|
|
57: '강한 착빙성 이슬비',
|
|
61: '약한 비',
|
|
63: '보통 비',
|
|
65: '강한 비',
|
|
66: '약한 착빙성 비',
|
|
67: '강한 착빙성 비',
|
|
71: '약한 눈',
|
|
73: '보통 눈',
|
|
75: '강한 눈',
|
|
77: '싸라기눈',
|
|
80: '약한 소나기',
|
|
81: '보통 소나기',
|
|
82: '강한 소나기',
|
|
85: '약한 소나기눈',
|
|
86: '강한 소나기눈',
|
|
95: '뇌우',
|
|
96: '약한 우박 뇌우',
|
|
99: '강한 우박 뇌우',
|
|
};
|
|
|
|
export function condition(code: number): string {
|
|
return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
|
|
}
|
|
|
|
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */
|
|
export function useLiveWeather({
|
|
initial,
|
|
regionCode,
|
|
latitude,
|
|
longitude,
|
|
}: {
|
|
initial?: WeatherSnapshot;
|
|
regionCode?: string;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
}): LiveWeather | undefined {
|
|
const [weather, setWeather] = useState<LiveWeather | undefined>(initial);
|
|
|
|
useEffect(() => {
|
|
if (!regionCode || latitude == null || longitude == null) return;
|
|
const controller = new AbortController();
|
|
|
|
async function refresh() {
|
|
try {
|
|
/*
|
|
* ★ **같은 오리진의 상대 주소로 부른다.**
|
|
* 전에는 `import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'` 이었다.
|
|
* `VITE_*` 는 번들에 구워지므로 그 값이 없는 자리에서 구우면 **발행본이 방문자
|
|
* 브라우저에서 `localhost:9800` 을 부른다** — 실측(2026-09-09, `/s/stay`):
|
|
* CORS 로 전부 막혀 실시간 날씨가 한 번도 반영되지 않았고, 화면에는 굽던 날의
|
|
* 기온(6일 전 27.9°C)이 그대로 떠 있었다. 관측 시각을 같이 내도록 해 둔 덕에
|
|
* 거짓말은 아니었지만, 손님에게는 오늘 날씨로 읽힌다.
|
|
* 애초에 이 렌더러의 규칙이 **런타임 환경변수를 쓰지 않는 것**이다(site/.env.example) —
|
|
* env 를 채워 다시 굽는 건 같은 함정을 다음 사람에게 물려주는 것이다.
|
|
* ★ 상대 주소면 nginx 가 앱·발행본·API 를 한 오리진으로 주므로(CLAUDE.md) 그대로 닿고,
|
|
* CORS 도 없다. 오리진이 다른 곳(블롭 단독)에 올라가면 404 로 실패하는데,
|
|
* 그때는 아래 catch 가 구운 스냅샷을 유지한다 — 날씨가 사이트를 깨지 않는다.
|
|
*/
|
|
const query = new URLSearchParams({
|
|
region_code: regionCode!,
|
|
latitude: String(latitude),
|
|
longitude: String(longitude),
|
|
});
|
|
const response = await fetch(`/v1/local/weather?${query}`, {
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) return;
|
|
const body = await response.json();
|
|
if (!body?.result?.success || !body.weather) return;
|
|
setWeather((previous) => ({
|
|
temperature: Number(body.weather.temperature),
|
|
condition: condition(Number(body.weather.weather_code)),
|
|
note: previous?.note,
|
|
observedAt: body.weather.observed_at,
|
|
stale: Boolean(body.stale),
|
|
}));
|
|
} catch {
|
|
// 정적 payload의 날씨를 유지한다. 날씨 장애가 사이트 렌더를 깨면 안 된다.
|
|
}
|
|
}
|
|
|
|
void refresh();
|
|
const timer = window.setInterval(() => void refresh(), 10 * 60 * 1000);
|
|
return () => {
|
|
controller.abort();
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [latitude, longitude, regionCode]);
|
|
|
|
return weather;
|
|
}
|