Preserve runway history during chart refresh

This commit is contained in:
2569718930@qq.com
2026-06-15 18:47:25 +08:00
parent 3f8ae44149
commit 05a36f857b
6 changed files with 267 additions and 9 deletions
@@ -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,
@@ -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;
@@ -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",
);
}
@@ -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<string, Array<Record<string, unknown>>> | undefined);
const incomingHistory =
data.runwayPlateHistory ||
((data.amos as any)?.runway_plate_history as Record<string, Array<Record<string, unknown>>> | 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,
+55
View File
@@ -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
+22 -1
View File
@@ -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: