diff --git a/frontend/app/api/cities/detail-batch/route.ts b/frontend/app/api/cities/detail-batch/route.ts index e795ee68..8ad216f5 100644 --- a/frontend/app/api/cities/detail-batch/route.ts +++ b/frontend/app/api/cities/detail-batch/route.ts @@ -31,7 +31,7 @@ export async function GET(req: NextRequest) { force_refresh: forceRefresh, limit: req.nextUrl.searchParams.get("limit") || "12", }); - for (const key of ["market_slug", "target_date", "resolution"]) { + for (const key of ["market_slug", "target_date", "resolution", "scope"]) { const value = req.nextUrl.searchParams.get(key); if (value) searchParams.set(key, value); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 2db94e71..1d9efe30 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -119,6 +119,11 @@ export async function runTests() { chartLogicSource.includes("primeCityDetailCache"), "visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache", ); + assert( + chartLogicSource.includes('scope: "chart"') && + chartLogicSource.includes("params.toString()"), + "terminal chart detail batches should request the slim chart scope instead of the full city detail payload", + ); assert( chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"), "visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index eda3dbd4..b237583f 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -1179,6 +1179,7 @@ function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) { force_refresh: "false", limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)), resolution, + scope: "chart", }); return fetch(`/api/cities/detail-batch?${params.toString()}`, { headers: { Accept: "application/json" }, diff --git a/tests/test_api_performance_timing.py b/tests/test_api_performance_timing.py index d5ef660f..cd5d7214 100644 --- a/tests/test_api_performance_timing.py +++ b/tests/test_api_performance_timing.py @@ -23,6 +23,10 @@ def test_backend_shared_timing_helper_avoids_sensitive_identity_fields(): def test_city_detail_batch_response_includes_backend_server_timing(monkeypatch): + city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE.clear() + city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.clear() + city_api._CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.clear() + class FakeCache: def get_city_cache(self, kind, city): assert kind == "full" diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 61beb5fb..91c6c8f3 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -678,6 +678,78 @@ def test_city_detail_batch_endpoint_builds_multiple_cached_details(monkeypatch): assert sorted(calls) == [("paris", "10m"), ("shanghai", "10m")] +def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch): + monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None) + monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower()) + monkeypatch.setattr( + city_api.legacy_routes, + "_city_cache_is_fresh", + lambda entry, ttl: True, + ) + monkeypatch.setattr( + city_api.legacy_routes, + "_overlay_latest_wunderground_current", + lambda city, payload: payload, + ) + + class FakeCache: + def get_city_cache(self, kind, city): + assert kind == "full" + return { + "payload": { + "city": city, + "hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]}, + } + } + + def build_detail(data, market_slug, target_date, resolution): + return { + "city": data["city"], + "overview": { + "local_date": "2026-05-30", + "local_time": "15:20", + "deb_prediction": 21.5, + "airport_primary_today_obs": [["15:20", 20.0]], + }, + "timeseries": { + "hourly": data["hourly"], + "metar_today_obs": [{"time": "15:20", "temp": 20.0}], + "settlement_today_obs": [], + "forecast_daily": [{"date": "2026-05-30", "max_temp": 22.0}], + }, + "models_hourly": {"times": ["15:00"], "curves": {"ECMWF": [21.0]}}, + "deb": {"prediction": 21.5, "hourly_path": {"times": ["15:00"], "temps": [21.5]}}, + "probabilities": {"mu": 21.4, "distribution": [{"value": 21, "probability": 0.4}]}, + "runway_plate_history": {"01/19": [{"timestamp": "15:20", "temp_c": 20.1}]}, + "runway_band_history": [{"time": "2026-05-30T15:20:00Z", "high_temp": 20.1}], + "airport_current": {"temp": 20.0}, + "airport_primary": {"temp": 20.0}, + "wunderground_current": {"max_so_far": 20.5}, + "settlement_station": {"settlement_station_label": "Station"}, + "amos": {"runway_obs": {"point_temperatures": []}}, + "dynamic_commentary": {"summary": "large text"}, + "official_nearby": [{"name": "unused"}], + "taf": {"raw": "unused"}, + "ai_analysis": "unused", + } + + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache()) + monkeypatch.setattr(city_api.legacy_routes, "_build_city_detail_payload", build_detail) + + response = client.get("/api/cities/detail-batch?cities=Paris&resolution=10m&scope=chart") + + assert response.status_code == 200 + detail = response.json()["details"]["paris"] + assert detail["timeseries"]["hourly"]["temps"] == [20.0] + assert detail["models_hourly"]["curves"]["ECMWF"] == [21.0] + assert detail["deb"]["hourly_path"]["temps"] == [21.5] + assert detail["airport_primary_today_obs"] == [["15:20", 20.0]] + assert "dynamic_commentary" not in detail + assert "official_nearby" not in detail + assert "taf" not in detail + assert "ai_analysis" not in detail + + def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch): import asyncio diff --git a/web/routers/city.py b/web/routers/city.py index c3b11693..f5b8a9f7 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -115,6 +115,7 @@ async def city_detail_batch( market_slug: Optional[str] = None, target_date: Optional[str] = None, resolution: Optional[str] = "10m", + scope: Optional[str] = "full", limit: int = 12, ): payload = await get_city_detail_batch_payload( @@ -124,6 +125,7 @@ async def city_detail_batch( market_slug=market_slug, target_date=target_date, resolution=resolution, + scope=scope, limit=limit, ) attach_server_timing_header(response, request, "city_detail_batch_server_timing") diff --git a/web/services/city_api.py b/web/services/city_api.py index 57324faa..98203d62 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -27,7 +27,7 @@ _CITY_FULL_REFRESH_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_FULL_REFRESH_LOCK = asyncio.Lock() CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int] -CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str] +CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str] _CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {} _CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {} _CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {} @@ -481,6 +481,7 @@ def _city_detail_batch_response_cache_key( market_slug: Optional[str], target_date: Optional[str], resolution: Optional[str], + scope: str, ) -> CityDetailBatchResponseCacheKey: return ( tuple(city_names), @@ -488,9 +489,84 @@ def _city_detail_batch_response_cache_key( str(market_slug or ""), str(target_date or ""), str(resolution or "10m"), + str(scope or "full"), ) +def _normalize_city_detail_scope(scope: Optional[str]) -> str: + raw = str(scope or "full").strip().lower() + if raw in {"chart", "charts", "terminal", "terminal_chart"}: + return "chart" + return "full" + + +def _chart_scoped_city_detail(detail: Dict[str, Any]) -> Dict[str, Any]: + overview = detail.get("overview") if isinstance(detail.get("overview"), dict) else {} + timeseries = detail.get("timeseries") if isinstance(detail.get("timeseries"), dict) else {} + forecast = detail.get("forecast") if isinstance(detail.get("forecast"), dict) else {} + airport_primary_today_obs = ( + detail.get("airport_primary_today_obs") + or overview.get("airport_primary_today_obs") + or [] + ) + forecast_daily = ( + (forecast.get("daily") if isinstance(forecast, dict) else None) + or timeseries.get("forecast_daily") + or [] + ) + local_date = detail.get("local_date") or overview.get("local_date") + local_time = detail.get("local_time") or overview.get("local_time") + + scoped = { + "city": detail.get("city") or overview.get("name"), + "fetched_at": detail.get("fetched_at"), + "local_date": local_date, + "local_time": local_time, + "overview": { + "name": overview.get("name"), + "display_name": overview.get("display_name"), + "local_date": local_date, + "local_time": local_time, + "temp_symbol": overview.get("temp_symbol"), + "current_temp": overview.get("current_temp"), + "deb_prediction": overview.get("deb_prediction"), + "settlement_source": overview.get("settlement_source"), + "settlement_source_label": overview.get("settlement_source_label"), + }, + "timeseries": { + "hourly": timeseries.get("hourly") or detail.get("hourly") or {}, + "metar_today_obs": timeseries.get("metar_today_obs") or [], + "settlement_today_obs": timeseries.get("settlement_today_obs") or [], + "forecast_daily": forecast_daily, + }, + "hourly": timeseries.get("hourly") or detail.get("hourly") or {}, + "models_hourly": detail.get("models_hourly") or {}, + "deb": detail.get("deb") or {}, + "forecast": { + "today_high": forecast.get("today_high") if isinstance(forecast, dict) else None, + "daily": forecast_daily, + }, + "multi_model_daily": detail.get("multi_model_daily") or {}, + "probabilities": detail.get("probabilities") or {"mu": None, "distribution": []}, + "runway_plate_history": detail.get("runway_plate_history") or {}, + "runway_band_history": detail.get("runway_band_history") or [], + "amos": detail.get("amos") or {}, + "airport_current": detail.get("airport_current") or {}, + "airport_primary": detail.get("airport_primary") or overview.get("airport_primary") or {}, + "airport_primary_today_obs": airport_primary_today_obs, + "official": {"airport_primary_today_obs": airport_primary_today_obs}, + "wunderground_current": detail.get("wunderground_current") or {}, + "settlement_station": detail.get("settlement_station") or overview.get("settlement_station") or {}, + } + return scoped + + +def _apply_city_detail_scope(detail: Dict[str, Any], scope: str) -> Dict[str, Any]: + if scope == "chart": + return _chart_scoped_city_detail(detail) + return detail + + async def _build_city_detail_batch_item_async( city: str, *, @@ -498,6 +574,7 @@ async def _build_city_detail_batch_item_async( market_slug: Optional[str], target_date: Optional[str], resolution: Optional[str], + detail_scope: str = "full", timing_recorder: Optional[ServerTimingRecorder] = None, ) -> Tuple[str, Dict[str, Any]]: if timing_recorder is not None: @@ -522,7 +599,7 @@ async def _build_city_detail_batch_item_async( target_date, resolution, ) - return city, detail + return city, _apply_city_detail_scope(detail, detail_scope) def _city_detail_batch_concurrency() -> int: @@ -554,6 +631,7 @@ async def get_city_detail_batch_payload( market_slug: Optional[str] = None, target_date: Optional[str] = None, resolution: Optional[str] = "10m", + scope: Optional[str] = "full", limit: int = 12, ) -> Dict[str, Any]: timer = ServerTimingRecorder( @@ -581,6 +659,7 @@ async def get_city_detail_batch_payload( "missing": [], "partial": False, } + detail_scope = _normalize_city_detail_scope(scope) async def _build_uncached_payload() -> Dict[str, Any]: semaphore = asyncio.Semaphore(_city_detail_batch_concurrency()) @@ -593,6 +672,7 @@ async def get_city_detail_batch_payload( market_slug=market_slug, target_date=target_date, resolution=resolution, + detail_scope=detail_scope, timing_recorder=timer, ) @@ -642,6 +722,7 @@ async def get_city_detail_batch_payload( market_slug=market_slug, target_date=target_date, resolution=resolution, + scope=detail_scope, ) if cache_ttl > 0 and not force_refresh: now_ts = time.time()