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,