Slim terminal chart detail batches
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user