From c0a5912246577741183ce62abf5304a7b520066c Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 14 Jun 2026 20:11:08 +0800 Subject: [PATCH] Preserve terminal chart detail during refresh --- .../LiveTemperatureThresholdChart.tsx | 41 ++++++- .../__tests__/temperatureChartData.test.ts | 104 ++++++++++++++++++ .../scan-terminal/temperature-chart-logic.ts | 27 +++++ 3 files changed, 170 insertions(+), 2 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 0a05ff51..3a841757 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -37,6 +37,7 @@ import { readSessionCache, selectCompactSecondaryTemp, selectDisplayRunwayTemp, + selectInitialHourlyForRowChange, seedHourlyForecastFromRow, shouldPollLiveChart, validNumber, @@ -528,6 +529,30 @@ function getInitialDetailLoadDelayMs({ return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS; } +function readCachedHourlyForInitialRow( + city: string, + preferredResolution: string, +): HourlyForecast { + const cityKey = String(city || "").toLowerCase().trim(); + if (!cityKey) return null; + const resolutions = [ + preferredResolution, + preferredResolution === "1m" ? "10m" : "1m", + ].filter((value, index, list) => value && list.indexOf(value) === index); + + for (const resolution of resolutions) { + const cacheKey = `${cityKey}:${resolution}`; + const memoryEntry = _hourlyCache.get(cacheKey); + if (memoryEntry?.data) return memoryEntry.data; + const sessionEntry = readSessionCache(cacheKey, { allowStale: true }); + if (sessionEntry?.data) { + _hourlyCache.set(cacheKey, sessionEntry); + return sessionEntry.data; + } + } + return null; +} + // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -575,6 +600,7 @@ export function LiveTemperatureThresholdChart({ createChartFreshnessState(), ); const hasLoadedHourlyDetailRef = useRef(false); + const hourlyCityRef = useRef(""); const chartVisibilityRef = useRef(null); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); @@ -624,12 +650,23 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { const now = Date.now(); + const previousCity = hourlyCityRef.current; + hourlyCityRef.current = city; + const nextTargetResolution = prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m"; + const cachedHourly = readCachedHourlyForInitialRow(city, nextTargetResolution); setUserToggledKeys({}); setZoomRange(null); setViewMode("full"); setShowRunwayDetails(true); - setTargetResolution(prefersHighFrequencyRunwayResolution(row, null) ? "1m" : "10m"); - setHourly(seedHourlyForecastFromRow(row)); + setTargetResolution(nextTargetResolution); + setHourly((prev) => + selectInitialHourlyForRowChange({ + cachedHourly, + previousCity, + previousHourly: prev, + row, + }), + ); setLiveTemp(null); setIsHourlyLoading(Boolean(city) && detailLoadDelayMs === 0); setDetailError(null); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 8d3ac857..dcb24583 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -6,6 +6,7 @@ import { mergeHourlyWithLiveObservations, mergePatchIntoHourly, mergeRowObservationIntoHourly, + selectInitialHourlyForRowChange, seedHourlyForecastFromRow, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -474,4 +475,107 @@ export function runTests() { chengduMerged?.airportCurrent?.temp === 28, "stale previous-day scan rows must not replace current-date detail airport conditions", ); + + const shenzhenRow = { + city: "shenzhen", + local_date: "2026-06-10", + local_time: "12:03", + current_temp: 31.2, + current_max_so_far: 31.2, + temp_symbol: "°C", + tz_offset_seconds: 8 * 3600, + } as any; + const shenzhenFullDetail = { + ...seedHourlyForecastFromRow(shenzhenRow), + localDate: "2026-06-10", + localTime: "12:00", + times: ["10:00", "11:00", "12:00"], + temps: [28, 30, 31], + modelTimes: ["10:00", "11:00", "12:00"], + modelCurves: { + ECMWF: [28.2, 30.1, 31.1], + GFS: [27.9, 29.8, 30.9], + }, + debPrediction: 33, + debHourlyPath: { + source: "deb_hourly_consensus", + times: ["10:00", "11:00", "12:00"], + temps: [29, 31, 33], + }, + multiModelDaily: { + "2026-06-10": { + deb: { prediction: 33 }, + models: { ECMWF: 33.2, GFS: 32.7 }, + }, + }, + } as any; + const shenzhenUpdatedRow = { + ...shenzhenRow, + local_time: "12:07", + current_temp: 31.6, + current_max_so_far: 31.6, + } as any; + const shenzhenInitialAfterReturn = selectInitialHourlyForRowChange({ + previousCity: "shenzhen", + previousHourly: shenzhenFullDetail, + row: shenzhenUpdatedRow, + }); + assert( + shenzhenInitialAfterReturn?.modelCurves?.ECMWF?.length === 3, + "returning to terminal must keep existing multi-model curves while the detail refresh is pending", + ); + assert( + shenzhenInitialAfterReturn?.debHourlyPath?.temps?.includes(33), + "returning to terminal must keep the existing DEB hourly path while the detail refresh is pending", + ); + assert( + shenzhenInitialAfterReturn?.airportCurrent?.temp === 31.6, + "returning to terminal should still merge the newest scan-row observation into the preserved detail", + ); + + const hongKongCachedDetail = { + ...seedHourlyForecastFromRow({ ...shenzhenRow, city: "hongkong" } as any), + localDate: "2026-06-10", + times: ["10:00", "11:00"], + temps: [29.5, 30.2], + modelCurves: { ECMWF: [29.8, 30.5] }, + debPrediction: 31, + debHourlyPath: { + source: "deb_hourly_consensus", + times: ["10:00", "11:00"], + temps: [30, 31], + }, + } as any; + const hongKongRow = { + ...shenzhenRow, + city: "hongkong", + local_time: "12:10", + current_temp: 30.7, + } as any; + const hongKongInitialAfterSelect = selectInitialHourlyForRowChange({ + cachedHourly: hongKongCachedDetail, + previousCity: "shenzhen", + previousHourly: shenzhenFullDetail, + row: hongKongRow, + }); + assert( + hongKongInitialAfterSelect?.modelCurves?.ECMWF?.length === 2 && + hongKongInitialAfterSelect?.debHourlyPath?.temps?.includes(31), + "selecting a city with cached detail must render cached multi-model and DEB immediately instead of falling back to an empty row seed", + ); + assert( + !hongKongInitialAfterSelect?.modelCurves?.GFS, + "selecting a different city must not leak the previous city's model curves", + ); + + const uncachedNewCityInitial = selectInitialHourlyForRowChange({ + previousCity: "shenzhen", + previousHourly: shenzhenFullDetail, + row: { ...shenzhenRow, city: "tokyo" } as any, + }); + assert( + !uncachedNewCityInitial?.modelCurves?.ECMWF && + !uncachedNewCityInitial?.debHourlyPath, + "selecting an uncached different city must not show another city's model and DEB curves", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index b64940fd..d3ea866d 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -1406,6 +1406,32 @@ function mergeRowObservationIntoHourly( return mergeHourlyWithLiveObservations(prev, seeded, row); } +function selectInitialHourlyForRowChange({ + cachedHourly, + previousCity, + previousHourly, + row, +}: { + cachedHourly?: HourlyForecast; + previousCity?: string | null; + previousHourly?: HourlyForecast; + row: ScanOpportunityRow | null; +}): HourlyForecast { + const seeded = seedHourlyForecastFromRow(row); + const nextCity = normalizeCityKey(row?.city); + if (!nextCity) return seeded; + + if (cachedHourly) { + return mergeHourlyWithLiveObservations(cachedHourly, seeded, row); + } + + if (previousHourly && normalizeCityKey(previousCity) === nextCity) { + return mergeHourlyWithLiveObservations(previousHourly, seeded, row); + } + + return seeded; +} + type HourlyForecastFetchOptions = { bypassLocalCache?: boolean; ignoreCache?: boolean; @@ -2980,6 +3006,7 @@ export { readSessionCache, selectCompactSecondaryTemp, selectDisplayRunwayTemp, + selectInitialHourlyForRowChange, seedHourlyForecastFromRow, seriesStats, shouldPollLiveChart,