[fix] site,solution/backend: 날씨 상태 라벨을 코드별 고유값으로 — 구간 뭉치기 제거

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: 새 라벨 반영
This commit is contained in:
김성경 2026-09-22 08:36:09 +09:00
parent 47da2f29b3
commit cd771719df
5 changed files with 285 additions and 104 deletions

View File

@ -13,60 +13,67 @@
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가 원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로 `{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로 정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 구간이어야 한다** 저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다): (하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
- 서버: `site_payload._weather_condition()` — 프리렌더 스냅샷에 쓰인다. - 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
- 브라우저: `use-live-weather.ts:condition()` — 10분마다 재조회할 때 쓰인다. - 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
| 코드 | 날씨 상태(한국어) | 날씨 상태(영어) | 내부 분류 | 둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
|---|---|---|---| 분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
| 0 | 맑음 | Clear sky | 맑음 |
| 1 | 대체로 맑음 | Mainly clear | 구름많음 |
| 2 | 구름 조금 | Partly cloudy | 구름많음 |
| 3 | 흐림 | Overcast | 구름많음 |
| 45 | 안개 | Fog | 안개 |
| 48 | 착빙성 안개 (서리 안개) | Depositing rime fog | 안개 |
| 51 | 가벼운 이슬비 | Drizzle: Light intensity | 이슬비 |
| 53 | 보통 이슬비 | Drizzle: Moderate intensity | 이슬비 |
| 55 | 강한 이슬비 | Drizzle: Dense intensity | 이슬비 |
| 56 | 가벼운 착빙성 이슬비 | Freezing Drizzle: Light intensity | 어는비 |
| 57 | 강한 착빙성 이슬비 | Freezing Drizzle: Dense intensity | 어는비 |
| 61 | 약한 비 | Rain: Slight intensity | 비 |
| 63 | 보통 비 | Rain: Moderate intensity | 비 |
| 65 | 강한 비 | Rain: Heavy intensity | 강한비 |
| 66 | 약한 착빙성 비 (얼어붙는 비) | Freezing Rain: Light intensity | 어는비 |
| 67 | 강한 착빙성 비 | Freezing Rain: Heavy intensity | 어는비 |
| 71 | 약한 눈 | Snow fall: Slight intensity | 눈 |
| 73 | 보통 눈 | Snow fall: Moderate intensity | 눈 |
| 75 | 강한 눈 | Snow fall: Heavy intensity | 눈 |
| 77 | 싸라기눈 (진눈깨비) | Snow grains | 눈 |
| 80 | 약한 소나기 | Rain showers: Slight | 소나기 |
| 81 | 보통 소나기 | Rain showers: Moderate | 소나기 |
| 82 | 강한 소나기 | Rain showers: Violent | 소나기 |
| 85 | 약한 소나기성 눈 | Snow showers: Slight | 소나기 |
| 86 | 강한 소나기성 눈 | Snow showers: Heavy | 소나기 |
| 95 | 뇌우 (천둥번개) | Thunderstorm: Slight or moderate | 뇌우 |
| 96 | 우박을 동반한 약한 뇌우 | Thunderstorm with slight hail | 뇌우 |
| 99 | 우박을 동반한 강한 뇌우 | Thunderstorm with heavy hail | 뇌우 |
이 27개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만 | 코드 | WMO 의미(영어) | 분류(=조건 라벨) |
|---|---|---|
| 0 | Clear sky | 맑음 |
| 1 | Mainly clear | 대체로 맑음 |
| 2 | Partly cloudy | 구름 조금 |
| 3 | Overcast | 흐림 |
| 45 | Fog | 안개 |
| 48 | Depositing rime fog | 착빙성 안개 |
| 51 | Drizzle: Light intensity | 가벼운 이슬비 |
| 53 | Drizzle: Moderate intensity | 보통 이슬비 |
| 55 | Drizzle: Dense intensity | 강한 이슬비 |
| 56 | Freezing Drizzle: Light intensity | 가벼운 착빙성 이슬비 |
| 57 | Freezing Drizzle: Dense intensity | 강한 착빙성 이슬비 |
| 61 | Rain: Slight intensity | 약한 비 |
| 63 | Rain: Moderate intensity | 보통 비 |
| 65 | Rain: Heavy intensity | 강한 비 |
| 66 | Freezing Rain: Light intensity | 약한 착빙성 비 |
| 67 | Freezing Rain: Heavy intensity | 강한 착빙성 비 |
| 71 | Snow fall: Slight intensity | 약한 눈 |
| 73 | Snow fall: Moderate intensity | 보통 눈 |
| 75 | Snow fall: Heavy intensity | 강한 눈 |
| 77 | Snow grains | 싸라기눈 |
| 80 | Rain showers: Slight | 약한 소나기 |
| 81 | Rain showers: Moderate | 보통 소나기 |
| 82 | Rain showers: Violent | 강한 소나기 |
| 85 | Snow showers: Slight | 약한 소나기눈 |
| 86 | Snow showers: Heavy | 강한 소나기눈 |
| 95 | Thunderstorm: Slight or moderate | 뇌우 |
| 96 | Thunderstorm with slight hail | 약한 우박 뇌우 |
| 99 | Thunderstorm with heavy hail | 강한 우박 뇌우 |
이 28개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만
안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기). 안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기).
`weatherMood()`(`derive.ts`)는 위 11종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로 `weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "이슬비"·"강한비"·"어는비"는 뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
모두 `/비|우/` 패턴에 "비"가 들어 있어 `비`로 걸린다). `WeatherSection`의 `skyKey`는 노트가 `/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
실제로 있는 세부 조건(구름많음/안개/소나기/뇌우/이슬비/강한비/어는비)이면 그 조건 그대로 쓰고, `눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection`의 `skyKey`는 노트에 그 조건 키가
그 외(맑음/흐림/비/눈)엔 `mood`로 내려간다 — 어차피 세부 조건과 mood 값이 같기 때문이다. 실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets` `weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
하늘 11종(맑음·구름많음·안개·비·이슬비·강한비·어는비·눈·소나기·뇌우·흐림)·기온 5구간에 각 하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, 브라우저에서는 무작위 시작 고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
후 20초마다 한 바퀴 안에서 중복 없이 순환한다. 기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
구름많음·안개·소나기·뇌우·이슬비·강한비·어는비는 그림(mood)상 흐림 또는 비와 같이 묶이지만 중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
문구는 조건별로 따로 쓴다. **비 중에서도 어는비(착빙성)만 안전 안내가 다르다** — 도로·보행
결빙 위험을 짚는 문구가 필요해서 이슬비·강한비와 갈랐다(2026-09-17). **세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지 목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.** 않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**

View File

@ -7,12 +7,19 @@
"다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.",
"좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?" "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"
], ],
"구름많음": [ "대체로 맑음": [
"구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "구름이 살짝 있지만 대체로 맑은 하루입니다. 나들이하기 좋은 날씨입니다.",
"여행 사진을 정리하며 잠깐 쉬어 가세요.", "옅은 구름 사이로 볕이 듭니다. 가볍게 산책을 나서 보세요.",
"함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "하늘이 대체로 맑아 야외 일정을 편하게 잡을 수 있습니다.",
"바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "구름이 잠깐씩 지나가지만 큰 영향은 없습니다.",
"오늘의 기분을 짧은 글로 남겨도 좋겠습니다." "맑은 편이니 오늘 담고 싶은 풍경을 찾아 나서 보세요."
],
"구름 조금": [
"구름이 조금 낀 날입니다. 볕이 강하지 않아 걷기에 오히려 좋습니다.",
"하늘에 구름이 떠 있지만 나들이엔 무리가 없습니다.",
"구름 사이로 비치는 햇살을 즐기며 산책해 보세요.",
"그늘이 반가운 날, 야외 일정을 여유롭게 잡아 보세요.",
"가벼운 겉옷 하나면 하루 종일 다니기 좋겠습니다."
], ],
"흐림": [ "흐림": [
"흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.",
@ -21,20 +28,6 @@
"천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.",
"분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요." "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."
], ],
"비": [
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
],
"눈": [
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
"다음 이동을 서두르지 말고 쉬어 가세요.",
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
"실내에서 다음 일정과 이동편을 확인해 보세요.",
"눈길을 나서기 전에는 여유 있게 준비하세요."
],
"안개": [ "안개": [
"안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.", "안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.",
"시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.", "시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.",
@ -42,26 +35,166 @@
"운전하신다면 평소보다 여유 있게 출발하시길 권합니다.", "운전하신다면 평소보다 여유 있게 출발하시길 권합니다.",
"뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다." "뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다."
], ],
"이슬비": [ "착빙성 안개": [
"서리 안개가 낀 아침입니다. 노면이 미끄러울 수 있으니 조심하세요.",
"안개와 함께 기온이 낮아 도로가 얼어 있을 수 있습니다.",
"이른 시간 이동은 특히 주의하고 서두르지 마세요.",
"안개가 걷히고 기온이 오른 뒤 나서는 편이 안전합니다.",
"차량·보행 모두 시야와 노면을 함께 살펴보세요."
],
"가벼운 이슬비": [
"가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.", "가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.",
"빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.", "빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.",
"얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.", "얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.",
"젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.", "젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.",
"빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다." "빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다."
], ],
"소나기": [ "보통 이슬비": [
"보통 세기의 이슬비가 내립니다. 우산 하나로 충분합니다.",
"옷이 젖을 수 있으니 우산이나 방수 겉옷을 챙기세요.",
"이슬비가 이어지는 동안은 실내 일정을 먼저 잡아 보세요.",
"그치는 틈을 봐서 짧게 이동하는 정도는 괜찮습니다.",
"빗소리를 들으며 잠시 여유를 가져도 좋겠습니다."
],
"강한 이슬비": [
"이슬비치고는 제법 굵게 내립니다. 우산을 단단히 챙기세요.",
"옷과 신발이 젖기 쉬우니 방수 채비를 해 두세요.",
"바깥 일정은 짧게 잡고 실내에서 기다리는 편이 낫겠습니다.",
"빗줄기가 가늘어도 오래 맞으면 금방 젖으니 주의하세요.",
"그칠 때까지는 무리해서 이동하지 않는 게 좋겠습니다."
],
"가벼운 착빙성 이슬비": [
"가벼운 착빙성 이슬비입니다. 노면이 얼 수 있어 걸음을 조심하세요.",
"얼어붙은 길이 있을 수 있으니 평소보다 천천히 움직이세요.",
"차량 이동도 서행하고 급제동은 피하세요.",
"실내에서 잠시 기다렸다 나서는 편을 권합니다.",
"그늘지거나 다리 위 같은 곳은 특히 미끄러우니 유의하세요."
],
"강한 착빙성 이슬비": [
"강한 착빙성 이슬비가 내립니다. 바깥 이동은 최대한 미루세요.",
"노면 결빙 위험이 크니 실내에 머무르는 편이 안전합니다.",
"부득이 이동한다면 미끄럼 방지 신발을 챙기세요.",
"차량도 도로 상태를 확인한 뒤 서행으로 움직이세요.",
"기상특보를 확인하고 해빙될 때까지 기다려 보세요."
],
"약한 비": [
"약한 비가 내립니다. 우산 하나면 짧은 이동은 무리 없습니다.",
"가볍게 챙기고 나서도 크게 불편하지 않겠습니다.",
"비가 잦아드는 시간을 골라 움직여 보세요.",
"젖어도 금방 마를 정도니 편하게 다녀오세요.",
"우산을 챙기고 평소처럼 일정을 이어가도 좋겠습니다."
],
"보통 비": [
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
],
"강한 비": [
"빗줄기가 굵습니다. 이동을 미루고 실내에서 기다리세요.",
"강한 비가 그칠 때까지 바깥 일정은 잠시 접어 두세요.",
"물웅덩이·배수 상황을 살피고 이동 경로를 다시 확인하세요.",
"우산만으로는 부족할 수 있으니 방수 옷차림을 챙기세요.",
"굵은 빗속 이동은 삼가고 안전한 곳에서 쉬어 가세요."
],
"약한 착빙성 비": [
"약한 착빙성 비입니다. 노면이 얼기 시작할 수 있어 조심하세요.",
"걸음을 천천히 옮기고 급한 이동은 피하세요.",
"차량은 서행하고 앞뒤 거리를 넉넉히 두세요.",
"가능하면 이동을 미루고 실내에서 기다려 보세요.",
"다리·그늘진 도로는 특히 미끄러우니 유의하세요."
],
"강한 착빙성 비": [
"강한 착빙성 비가 내립니다. 바깥 이동을 삼가고 실내에 머무르세요.",
"도로가 얼어붙을 수 있으니 되도록 나서지 마세요.",
"부득이 이동해야 한다면 걸음을 평소보다 훨씬 천천히 옮기세요.",
"차량 이동도 미끄러질 수 있으니 서두르지 마세요.",
"기상특보를 확인하고 해빙될 때까지 일정을 미뤄 두세요."
],
"약한 눈": [
"약한 눈이 흩날립니다. 짧은 나들이 정도는 다녀올 만합니다.",
"쌓이기 전이라면 평소처럼 움직여도 괜찮겠습니다.",
"따뜻하게 챙기고 잠깐의 눈길 산책을 즐겨 보세요.",
"노면이 젖어 있을 수 있으니 걸음만 조심하세요.",
"눈이 그치는 틈을 봐서 이동해도 좋겠습니다."
],
"보통 눈": [
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
"다음 이동을 서두르지 말고 쉬어 가세요.",
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
"실내에서 다음 일정과 이동편을 확인해 보세요.",
"눈길을 나서기 전에는 여유 있게 준비하세요."
],
"강한 눈": [
"눈이 강하게 쌓입니다. 바깥 이동은 미루는 편이 좋습니다.",
"제설이 될 때까지 실내에서 기다려 보세요.",
"이동해야 한다면 미끄럼 방지 신발과 여유 시간을 챙기세요.",
"차량 이동은 도로 상황을 확인한 뒤 움직이세요.",
"따뜻하게 머물며 눈이 그치기를 기다려 보세요."
],
"싸라기눈": [
"싸라기눈이 흩날립니다. 노면이 미끄러울 수 있어요.",
"짧은 이동이라도 걸음을 조심스럽게 옮기세요.",
"우산보다는 모자나 방한용품이 도움이 되겠습니다.",
"그치는 틈을 봐서 다음 일정을 잡아 보세요.",
"쌓이지 않아도 걸을 때는 발밑을 살펴보세요."
],
"약한 소나기": [
"짧게 스치는 소나기입니다. 잠깐 처마 밑에서 기다려도 됩니다.",
"갑자기 내릴 수 있으니 우산을 챙겨 두면 든든합니다.",
"소나기가 그치면 이어서 일정을 다니셔도 좋겠습니다.",
"젖은 길만 조심하면 크게 무리 없는 날씨입니다.",
"짧은 비를 피할 실내 공간을 미리 봐 두세요."
],
"보통 소나기": [
"소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.", "소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.",
"갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.", "갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.",
"비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.", "비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.",
"지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.", "지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.",
"짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다." "짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다."
], ],
"강한 소나기": [
"갑자기 쏟아지는 강한 소나기입니다. 이동을 잠시 멈추세요.",
"그칠 때까지 안전한 실내에서 기다리는 편이 좋습니다.",
"젖은 도로가 미끄러울 수 있으니 이동 시 주의하세요.",
"우산만으로는 부족할 수 있어 방수 옷차림을 챙기세요.",
"소나기가 지나간 뒤 도로 상황을 확인하고 움직이세요."
],
"약한 소나기눈": [
"짧게 흩날리는 눈입니다. 잠깐 피했다 이동해도 됩니다.",
"노면이 젖거나 살짝 얼 수 있어 걸음을 조심하세요.",
"따뜻하게 챙기고 짧은 외출 정도는 다녀올 만합니다.",
"그치는 틈을 봐서 다음 장소로 이동해 보세요.",
"쌓이기 전이라면 크게 무리는 없겠습니다."
],
"강한 소나기눈": [
"갑자기 쏟아지는 눈입니다. 이동을 잠시 멈추고 기다리세요.",
"노면이 급격히 미끄러워질 수 있어 주의가 필요합니다.",
"실내에서 그치기를 기다렸다 움직이는 편이 안전합니다.",
"차량 이동은 서행하고 앞차와 거리를 넉넉히 두세요.",
"쌓인 눈이 있다면 이동 전 노면 상태부터 확인하세요."
],
"뇌우": [ "뇌우": [
"천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.", "천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.",
"비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.", "비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.",
"번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.", "번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.",
"날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.", "날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.",
"궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요." "궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요."
],
"약한 우박 뇌우": [
"천둥·번개와 함께 약한 우박이 내릴 수 있습니다. 실내에 머무르세요.",
"우박이 그칠 때까지 차양 아래보다는 건물 안이 안전합니다.",
"야외 물건이 있다면 잠시 안으로 들여 두세요.",
"번개가 잦아든 뒤 이동 여부를 다시 확인하세요.",
"차량도 우박 피해가 없는 곳에 세워 두는 편이 좋습니다."
],
"강한 우박 뇌우": [
"굵은 우박을 동반한 뇌우입니다. 반드시 실내에 머무르세요.",
"창가나 개방된 곳을 피하고 안전한 공간에서 기다리세요.",
"차량은 우박을 피할 수 있는 곳에 세워 두세요.",
"기상특보를 확인하고 뇌우가 완전히 지나간 뒤 나서세요.",
"야외 활동은 오늘 일정에서 빼는 편이 안전하겠습니다."
] ]
}, },
"temperature": { "temperature": {

View File

@ -1,28 +1,44 @@
import {expect, it} from 'vitest'; import {expect, it} from 'vitest';
import {condition} from './use-live-weather'; import {condition} from './use-live-weather';
it('recognizes fog, showers and thunderstorms as distinct conditions', () => { const WEATHER_CODE_LABELS: Record<number, string> = {
expect(condition(45)).toBe('안개'); 0: '맑음',
expect(condition(48)).toBe('안개'); 1: '대체로 맑음',
expect(condition(80)).toBe('소나기'); 2: '구름 조금',
expect(condition(81)).toBe('소나기'); 3: '흐림',
expect(condition(82)).toBe('소나기'); 45: '안개',
expect(condition(85)).toBe('소나기'); 48: '착빙성 안개',
expect(condition(86)).toBe('소나기'); 51: '가벼운 이슬비',
expect(condition(95)).toBe('뇌우'); 53: '보통 이슬비',
expect(condition(96)).toBe('뇌우'); 55: '강한 이슬비',
expect(condition(99)).toBe('뇌우'); 56: '가벼운 착빙성 이슬비',
57: '강한 착빙성 이슬비',
61: '약한 비',
63: '보통 비',
65: '강한 비',
66: '약한 착빙성 비',
67: '강한 착빙성 비',
71: '약한 눈',
73: '보통 눈',
75: '강한 눈',
77: '싸라기눈',
80: '약한 소나기',
81: '보통 소나기',
82: '강한 소나기',
85: '약한 소나기눈',
86: '강한 소나기눈',
95: '뇌우',
96: '약한 우박 뇌우',
99: '강한 우박 뇌우',
};
it('gives every WMO weather code its own label', () => {
for (const [code, label] of Object.entries(WEATHER_CODE_LABELS)) {
expect(condition(Number(code))).toBe(label);
}
}); });
it('splits rain into drizzle, normal, heavy and freezing', () => { it('falls back to overcast for unknown codes', () => {
expect(condition(51)).toBe('이슬비'); expect(condition(4)).toBe('흐림');
expect(condition(53)).toBe('이슬비'); expect(condition(-1)).toBe('흐림');
expect(condition(55)).toBe('이슬비');
expect(condition(61)).toBe('비');
expect(condition(63)).toBe('비');
expect(condition(65)).toBe('강한비');
expect(condition(56)).toBe('어는비');
expect(condition(57)).toBe('어는비');
expect(condition(66)).toBe('어는비');
expect(condition(67)).toBe('어는비');
}); });

View File

@ -5,18 +5,43 @@ export interface LiveWeather extends WeatherSnapshot {
stale?: boolean; stale?: boolean;
} }
/** WMO weather code → 한 줄. ★ solution/backend/services/site_payload.py 의 _weather_condition() 과 같은 구간이어야 한다. */ /**
* 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 { export function condition(code: number): string {
if (code === 0) return '맑음'; return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
if (code >= 1 && code <= 2) return '구름많음';
if (code === 3) return '흐림';
if (code === 45 || code === 48) return '안개';
if (code >= 51 && code <= 57) return '이슬비';
if (code >= 61 && code <= 67) return '비';
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return '눈';
if (code >= 80 && code <= 82) return '소나기';
if (code >= 95 && code <= 99) return '뇌우';
return '흐림';
} }
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */ /** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */

View File

@ -12,20 +12,20 @@ function render(weather?: WeatherSnapshot) {
} }
it('uses the first sky and temperature lines for deterministic HTML', () => { it('uses the first sky and temperature lines for deterministic HTML', () => {
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('구름 첫 줄'); expect(render(weather)).toContain('맑음 첫 줄');
expect(render(weather)).toContain('더위 첫 줄'); expect(render(weather)).toContain('더위 첫 줄');
expect(render(weather)).not.toContain('다음 줄'); expect(render(weather)).not.toContain('다음 줄');
expect(render(weather)).toBe(render(weather)); 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(render(weather)).toContain('소나기 첫 줄');
}); });
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(render(weather)).toContain('어는비 첫 줄');
expect(render(weather)).not.toContain('일반 비 첫 줄'); expect(render(weather)).not.toContain('일반 비 첫 줄');
}); });