From 05a36f857bb47757bd22fba9ec81068281f0b948 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 15 Jun 2026 18:47:25 +0800 Subject: [PATCH] Preserve runway history during chart refresh --- .../LiveTemperatureThresholdChart.tsx | 23 ++++-- .../__tests__/refreshCadencePolicy.test.ts | 80 ++++++++++++++++++- .../__tests__/temperatureChartData.test.ts | 57 +++++++++++++ .../scan-terminal/temperature-chart-logic.ts | 38 ++++++++- tests/test_web_observability.py | 55 +++++++++++++ web/services/city_api.py | 23 +++++- 6 files changed, 267 insertions(+), 9 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index a2698d1e..a45af3e1 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -35,6 +35,7 @@ import { prefersHighFrequencyRunwayResolution, readCityDetailBatchDiagnostics, readSessionCache, + rememberHourlyDetailSnapshot, selectCompactSecondaryTemp, selectDisplayRunwayTemp, selectInitialHourlyForRowChange, @@ -743,7 +744,11 @@ export function LiveTemperatureThresholdChart({ const temp = getLiveTempFromHourly(dataWithCurrentRow); if (temp !== null) setLiveTemp(temp); } - setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow)); + setHourly((prev) => { + const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow); + rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); + return mergedHourly; + }); setDetailError(null); setShowingStaleDetail(false); setChartFreshness((prev) => ({ @@ -756,7 +761,7 @@ export function LiveTemperatureThresholdChart({ ? "network" : prev.detailSource, })); - }, [getLatestRowSnapshot]); + }, [city, targetResolution, getLatestRowSnapshot]); useEffect(() => { if (!city || !currentRowObservationSignature) return; @@ -766,13 +771,17 @@ export function LiveTemperatureThresholdChart({ const rowSeed = seedHourlyForecastFromRow(row); const temp = getLiveTempFromHourly(rowSeed); if (temp !== null) setLiveTemp(temp); - setHourly((prev) => mergeRowObservationIntoHourly(prev ?? rowSeed, row)); + setHourly((prev) => { + const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row); + rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); + return mergedHourly; + }); setChartFreshness((prev) => ({ ...prev, rowAppliedAtMs: now, rowObservationTime: rowObservationTimeForFreshness(row), })); - }, [city, currentRowObservationSignature, row]); + }, [city, currentRowObservationSignature, row, targetResolution]); useEffect(() => { if (!city) { @@ -913,7 +922,11 @@ export function LiveTemperatureThresholdChart({ lastPatchAtRef.current = patchAppliedAtMs; const tempValue = validNumber(latestPatch.changes.temp); if (tempValue !== null) setLiveTemp(tempValue); - setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch)); + setHourly((prev) => { + const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch); + rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); + return mergedHourly; + }); setChartFreshness((prev) => ({ ...prev, ssePatchAppliedAtMs: patchAppliedAtMs, diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 462bf9a8..2783d212 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -125,7 +125,8 @@ export async function runTests() { ); assert( chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && - chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow))"), + chartSource.includes("const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow)") && + chartSource.includes("return mergedHourly;"), "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations", ); assert( @@ -339,8 +340,15 @@ export async function runTests() { successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && + successfulHourlyDetailBlock.includes("rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly)") && !/\[row\]/.test(successfulHourlyDetailBlock), - "successful city detail refreshes must read the latest row without depending on the row object", + "successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object", + ); + assert( + chartSource.includes("rememberHourlyDetailSnapshot") && + chartSource.includes("const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row);") && + chartSource.includes("const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);"), + "live row and SSE patch merges must persist their hourly snapshots so returning to terminal restores runway history immediately", ); const coldDetailFetchBlock = /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?fetchHourlyForecastForCity\(city, \{ resolution: targetResolution \}\)[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; @@ -583,6 +591,74 @@ export async function runTests() { __readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21, "stale city detail cache should still be available for immediate chart rendering", ); + + clearCityDetailCache(); + const runwayCacheKey = "chengdu:1m"; + store.set( + `polyweather_city_detail_v1:${runwayCacheKey}`, + JSON.stringify({ + ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000, + data: { + forecastDaily: [], + localDate: "2026-06-15", + localTime: "2026-06-15T09:00:00Z", + multiModelDaily: {}, + probabilities: null, + temps: [27.1], + times: ["09:00"], + runwayPlateHistory: { + "02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }], + }, + amos: { + runway_plate_history: { + "02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }], + }, + }, + }, + }), + ); + (globalThis as any).fetch = async () => ({ + ok: true, + json: async () => ({ + cities: ["chengdu"], + details: { + chengdu: { + city: "chengdu", + local_date: "2026-06-15", + local_time: "2026-06-15T09:04:00Z", + hourly: { + times: ["09:00", "09:04"], + temps: [27.1, 27.8], + }, + models_hourly: { + times: ["09:00", "09:04"], + curves: { ECMWF: [27.0, 27.6] }, + }, + deb: { + hourly_path: { + source: "deb_hourly_consensus", + times: ["09:00", "09:04"], + temps: [30.1, 30.4], + }, + }, + runway_plate_history: {}, + amos: {}, + }, + }, + errors: {}, + missing: [], + partial: false, + }), + }); + const forceRefreshedRunwayDetail = await fetchHourlyForecastForCity("chengdu", { + ignoreCache: true, + resolution: "1m", + }); + assert( + forceRefreshedRunwayDetail?.runwayPlateHistory?.["02L/20R"]?.length === 1 && + __readHourlyCacheEntryForTest(runwayCacheKey, { allowStale: true })?.data?.runwayPlateHistory?.["02L/20R"]?.length === 1, + "force-refresh chart detail must not overwrite cached runway history when the response omits it", + ); } finally { clearCityDetailCache(); (globalThis as any).window = originalWindow; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 2c0e16ff..04cf1dde 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -6,8 +6,10 @@ import { mergeHourlyWithLiveObservations, mergePatchIntoHourly, mergeRowObservationIntoHourly, + rememberHourlyDetailSnapshot, selectInitialHourlyForRowChange, seedHourlyForecastFromRow, + _hourlyCache, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; function assert(condition: unknown, message: string) { @@ -749,4 +751,59 @@ export function runTests() { !uncachedNewCityInitial?.debHourlyPath, "selecting an uncached different city must not show another city's model and DEB curves", ); + + const chengduCacheKey = "chengdu:1m"; + _hourlyCache.delete(chengduCacheKey); + const cachedChengduRow = { + city: "chengdu", + local_date: "2026-06-15", + local_time: "2026-06-15T09:00:00Z", + current_temp: 26.4, + current_max_so_far: 26.4, + temp_symbol: "°C", + tz_offset_seconds: 8 * 3600, + metar_context: { source: "amsc_awos" }, + } as any; + rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow)); + assert( + !_hourlyCache.has(chengduCacheKey), + "instant-restore cache must not persist a row-only seed that would block the full detail fetch", + ); + + const cachedChengduDetail = { + ...seedHourlyForecastFromRow(cachedChengduRow), + localDate: "2026-06-15", + times: ["08:00", "09:00"], + temps: [25.8, 26.4], + modelTimes: ["08:00", "09:00"], + modelCurves: { ECMWF: [26.1, 26.5] }, + debHourlyPath: { + source: "deb_hourly_consensus", + times: ["08:00", "09:00"], + temps: [30.1, 30.9], + }, + runwayPlateHistory: { + "02L/20R": [{ timestamp: "2026-06-15T08:55:00Z", temp_c: 26.2 }], + }, + } as any; + const cachedChengduLivePatch = mergePatchIntoHourly(cachedChengduDetail, { + city: "chengdu", + revision: 7, + changes: { + temp: 26.8, + observed_at_utc: "2026-06-15T09:03:00Z", + runway_points: [{ runway: "02L/20R", temp: 26.8 }], + }, + } as any); + rememberHourlyDetailSnapshot("chengdu", "1m", cachedChengduLivePatch); + const restoredChengdu = _hourlyCache.get(chengduCacheKey)?.data; + assert( + restoredChengdu?.modelCurves?.ECMWF?.length === 2 && + restoredChengdu?.debHourlyPath?.temps?.includes(30.9), + "instant-restore cache must keep the full DEB and multi-model detail after live merges", + ); + assert( + (restoredChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2, + "instant-restore cache must include live-merged runway history so returning to terminal shows it immediately", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 4dd20e14..2087ce3d 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -466,6 +466,18 @@ function writeSessionCache(city: string, data: HourlyForecast) { } catch {} } +function rememberHourlyDetailSnapshot( + city: string, + resolution: string, + data: HourlyForecast, +) { + const cityKey = normalizeCityKey(city); + if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return; + const cacheKey = `${cityKey}:${resolution || "10m"}`; + _hourlyCache.set(cacheKey, { ts: Date.now(), data }); + writeSessionCache(cacheKey, data); +} + function drainHourlyDetailRequestQueue() { while ( _hourlyActiveDetailRequests < MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS && @@ -1578,14 +1590,37 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec }; } +function preserveCachedRunwayHistory(cacheKey: string, data: HourlyForecast) { + if (!data) return data; + const cached = readHourlyCacheEntry(cacheKey, { allowStale: true })?.data; + if (!cached || hourlyLocalDatesConflict(cached, data, null)) return data; + const cachedHistory = + cached.runwayPlateHistory || + ((cached.amos as any)?.runway_plate_history as Record>> | undefined); + const incomingHistory = + data.runwayPlateHistory || + ((data.amos as any)?.runway_plate_history as Record>> | undefined); + const runwayPlateHistory = mergeRunwayPlateHistory(cachedHistory, incomingHistory); + if (!runwayPlateHistory) return data; + return { + ...data, + runwayPlateHistory, + amos: { + ...(data.amos || {}), + runway_plate_history: runwayPlateHistory, + } as AmosData, + }; +} + function primeCityDetailCache( city: string, resolution: string, detail: CityDetail | null | undefined, ): HourlyForecast { - const data = parseHourlyForecastFromCityDetail(detail || null); + let data = parseHourlyForecastFromCityDetail(detail || null); if (!data) return null; const cacheKey = `${city}:${resolution}`; + data = preserveCachedRunwayHistory(cacheKey, data); _hourlyCache.set(cacheKey, { ts: Date.now(), data }); writeSessionCache(cacheKey, data); return data; @@ -3086,6 +3121,7 @@ export { prefersHighFrequencyRunwayResolution, readCityDetailBatchDiagnostics, readSessionCache, + rememberHourlyDetailSnapshot, selectCompactSecondaryTemp, selectDisplayRunwayTemp, selectInitialHourlyForRowChange, diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 936bea6d..61547b71 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1157,6 +1157,61 @@ def test_chart_scope_overlays_collector_runway_history_from_db(monkeypatch): assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8} +def test_chart_data_force_refresh_overlays_collector_runway_history(monkeypatch): + import asyncio + + class FakeCache: + def get_runway_obs_recent(self, icao, minutes=60): + assert icao == "ZUUU" + assert minutes == 36 * 60 + return [ + { + "runway": "02L/20R", + "tdz_temp": 27.2, + "mid_temp": None, + "end_temp": 27.0, + "target_runway_max": 27.2, + "otime_utc": "2026-06-15T09:01:00+00:00", + }, + { + "runway": "02L/20R", + "tdz_temp": 27.8, + "mid_temp": None, + "end_temp": 27.5, + "target_runway_max": 27.8, + "otime_utc": "2026-06-15T09:04:00+00:00", + }, + ] + + async def refreshed_full_payload(city, *, force_refresh): + assert force_refresh is True + return { + "name": city, + "display_name": "Chengdu", + "local_date": "2026-06-15", + "local_time": "17:04", + "temp_symbol": "°C", + "risk": {"icao": "ZUUU"}, + "current": {"temp": 27.8}, + "hourly": {"times": ["16:00"], "temps": [27.0]}, + } + + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache()) + monkeypatch.setattr(city_api, "_get_city_full_data", refreshed_full_payload) + monkeypatch.setattr( + city_api.legacy_routes, + "_overlay_latest_wunderground_current", + lambda city, payload: payload, + ) + + payload = asyncio.run(city_api._get_city_chart_data("chengdu", force_refresh=True)) + + assert payload["runway_plate_history"]["02L/20R"][-1] == { + "time": "2026-06-15T09:04:00+00:00", + "temp": 27.8, + } + + def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch): import asyncio diff --git a/web/services/city_api.py b/web/services/city_api.py index e305becc..6303f90b 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -404,7 +404,28 @@ async def _get_city_full_data(city: str, *, force_refresh: bool) -> Dict[str, An async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, Any]: if force_refresh: - return await _get_city_full_data(city, force_refresh=True) + payload = await _get_city_full_data(city, force_refresh=True) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="runway_history", + payload=payload, + fn=_overlay_cached_runway_history_from_db, + args=(city, payload), + ) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="amsc_latest_raw", + payload=payload, + fn=overlay_latest_amsc_observation, + args=(legacy_routes._CACHE_DB, city, payload), + ) + return await _run_optional_city_chart_overlay( + city=city, + overlay_name="wunderground_current", + payload=payload, + fn=legacy_routes._overlay_latest_wunderground_current, + args=(city, payload), + ) cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "full", city) if cached_entry: