Preserve runway history during chart refresh
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user