diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 09cb5c68..c0dfc825 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1854,6 +1854,7 @@ def test_stale_ankara_chart_data_overlays_latest_mgm_canonical(monkeypatch): "source_label": "METAR", "obs_time": "2026-06-14T09:50:00+00:00", }, + "airport_primary_today_obs": [{"time": "12:50", "temp": 16.0}], "hourly": {"times": ["2026-06-14T09:00:00Z"], "temps": [16.0]}, "deb": {"prediction": 23.0}, }, @@ -1897,6 +1898,9 @@ def test_stale_ankara_chart_data_overlays_latest_mgm_canonical(monkeypatch): assert payload["airport_primary"]["temp"] == 19.0 assert payload["airport_primary"]["source_code"] == "mgm" assert payload["airport_primary"]["station_code"] == "17128" + assert payload["local_date"] == "2026-06-15" + assert payload["local_time"] == "17:20" + assert payload["airport_primary_today_obs"] == [{"time": "17:20", "temp": 19.0}] assert payload["deb"]["prediction"] == 23.0 diff --git a/web/services/city_api.py b/web/services/city_api.py index a104ce34..53ef4a92 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -7,6 +7,7 @@ import asyncio import threading import time from copy import deepcopy +from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, List, Optional, Tuple from fastapi import HTTPException, Request @@ -340,7 +341,62 @@ def _payload_observation_epoch(payload: Dict[str, Any]) -> Optional[int]: return max(epochs) if epochs else None +def _float_or_none(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _latest_observation_local_context( + city: str, + latest_payload: Dict[str, Any], +) -> Dict[str, str]: + epoch = _payload_observation_epoch(latest_payload) + if epoch is None: + return {} + try: + offset_sec = int((legacy_routes.CITIES.get(city) or {}).get("tz") or 0) + except Exception: + offset_sec = 0 + local_dt = datetime.fromtimestamp(epoch, timezone.utc) + timedelta(seconds=offset_sec) + return { + "local_date": local_dt.strftime("%Y-%m-%d"), + "local_time": local_dt.strftime("%H:%M"), + } + + +def _latest_airport_primary_point( + latest_payload: Dict[str, Any], + *, + local_time: str, +) -> List[Dict[str, Any]]: + if not local_time: + return [] + airport = latest_payload.get("airport_primary") if isinstance(latest_payload.get("airport_primary"), dict) else {} + current = latest_payload.get("current") if isinstance(latest_payload.get("current"), dict) else {} + temp = _float_or_none(airport.get("temp") if airport else None) + if temp is None: + temp = _float_or_none(current.get("temp") if current else None) + if temp is None: + return [] + return [{"time": local_time, "temp": round(float(temp), 1)}] + + +def _replace_airport_primary_today_obs( + payload: Dict[str, Any], + points: List[Dict[str, Any]], +) -> None: + payload["airport_primary_today_obs"] = points + official = payload.get("official") + if isinstance(official, dict): + official["airport_primary_today_obs"] = points + + def _merge_latest_observation_payload( + city: str, payload: Dict[str, Any], latest_payload: Dict[str, Any], ) -> Dict[str, Any]: @@ -366,6 +422,19 @@ def _merge_latest_observation_payload( next_payload["updated_at"] = latest_payload.get("updated_at") if latest_payload.get("temp_symbol"): next_payload["temp_symbol"] = latest_payload.get("temp_symbol") + + local_context = _latest_observation_local_context(city, latest_payload) + if local_context: + previous_local_date = str(next_payload.get("local_date") or "") + next_payload.update(local_context) + if previous_local_date and previous_local_date != local_context["local_date"]: + _replace_airport_primary_today_obs( + next_payload, + _latest_airport_primary_point( + latest_payload, + local_time=local_context["local_time"], + ), + ) return next_payload @@ -375,7 +444,7 @@ async def _overlay_cached_canonical_observation(city: str, payload: Dict[str, An canonical_payload = await _get_canonical_city_payload(city, detail_depth=str(payload.get("detail_depth") or "full")) if not canonical_payload: return payload - return _merge_latest_observation_payload(payload, canonical_payload) + return _merge_latest_observation_payload(city, payload, canonical_payload) def _overlay_cached_runway_history_from_db(city: str, payload: Dict[str, Any]) -> Dict[str, Any]: