From a098a5ab2057cc192bbdaaf33df63e2ebabe0686 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 14 Jun 2026 08:39:08 +0800 Subject: [PATCH] Prevent stale chart auto refresh rollback --- .../__tests__/temperatureChartData.test.ts | 42 +++++++++ .../scan-terminal/temperature-chart-logic.ts | 94 ++++++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index fd99d2f6..8d3ac857 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -364,6 +364,48 @@ export function runTests() { preservedPatchRunway?.values.some((value) => value === 28.8), "stale full-detail responses must not overwrite a newer live SSE observation point", ); + const olderAutoRefreshDetail = { + ...seededGuangzhou, + localDate: "2026-06-10", + localTime: "12:45", + times: ["12:00", "12:45"], + temps: [27.8, 28.4], + probabilities: { engine: "stale", distribution: [{ value: 28, probability: 0.2 }] }, + runwayPlateHistory: { + "02L/20R": [{ timestamp: "12:45", temp_c: 28.4, value: 28.4 }], + }, + airportCurrent: { temp: 28.4, obs_time: "12:45", max_so_far: 28.4 }, + airportPrimary: { temp: 28.4, obs_time: "12:45", max_so_far: 28.4 }, + airportPrimaryTodayObs: [["12:45", 28.4]], + } as any; + const newerVisibleDetail = { + ...seededGuangzhou, + localDate: "2026-06-10", + localTime: "12:55", + times: ["12:00", "12:55"], + temps: [27.8, 29.2], + probabilities: { engine: "fresh", distribution: [{ value: 29, probability: 0.7 }] }, + runwayPlateHistory: { + "02L/20R": [{ timestamp: "12:55", temp_c: 29.2, value: 29.2 }], + }, + airportCurrent: { temp: 29.2, obs_time: "12:55", max_so_far: 29.2 }, + airportPrimary: { temp: 29.2, obs_time: "12:55", max_so_far: 29.2 }, + airportPrimaryTodayObs: [["12:55", 29.2]], + } as any; + const mergedAfterOlderAutoRefresh = mergeHourlyWithLiveObservations( + olderAutoRefreshDetail, + newerVisibleDetail, + guangzhouRow, + ); + assert( + mergedAfterOlderAutoRefresh?.times.includes("12:55") && + !mergedAfterOlderAutoRefresh?.times.includes("12:45"), + "older automatic detail refreshes must not roll a newer visible chart curve back to stale timestamps", + ); + assert( + mergedAfterOlderAutoRefresh?.probabilities?.engine === "fresh", + "older automatic detail refreshes must preserve the newer visible chart probability payload", + ); const chengduDetail = { forecastTodayHigh: null, diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 6bb69713..b64940fd 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -1126,6 +1126,91 @@ function mergeRunwayPlateHistory( return Object.keys(result).length ? result : undefined; } +function hasFullHourlyDetailPayload(hourly: HourlyForecast) { + if (!hourly) return false; + const probabilityBuckets = + hourly.probabilities?.distribution_all || + hourly.probabilities?.distribution || + []; + return Boolean( + (hourly.times || []).length > 0 || + (hourly.temps || []).length > 0 || + (hourly.debHourlyPath?.times || []).length > 0 || + Object.keys(hourly.modelCurves || {}).length > 0 || + (hourly.forecastDaily || []).length > 0 || + Object.keys(hourly.multiModelDaily || {}).length > 0 || + probabilityBuckets.length > 0, + ); +} + +function latestRawObservationRank( + points: RawObsPoint[] | null | undefined, + row: ScanOpportunityRow | null, + localDateStr: string | null | undefined, +) { + let latest: number | null = null; + (points || []).forEach((point) => { + const normalized = normalizeRawObsPoint(point); + const rank = observationTimeRank(normalized?.time, row, localDateStr); + if (rank !== null) latest = latest === null ? rank : Math.max(latest, rank); + }); + return latest; +} + +function latestRunwayHistoryRank( + history: Record>> | undefined, + row: ScanOpportunityRow | null, + localDateStr: string | null | undefined, +) { + let latest: number | null = null; + Object.values(history || {}).forEach((points) => { + if (!Array.isArray(points)) return; + points.forEach((point) => { + const rawTime = point.timestamp ?? + point.time ?? + point.observed_at ?? + null; + const rank = observationTimeRank( + typeof rawTime === "string" || typeof rawTime === "number" ? rawTime : null, + row, + localDateStr, + ); + if (rank !== null) latest = latest === null ? rank : Math.max(latest, rank); + }); + }); + return latest; +} + +function latestHourlyObservationRank( + hourly: HourlyForecast, + row: ScanOpportunityRow | null, +) { + if (!hourly) return null; + const localDateStr = hourly.localDate || row?.local_date || null; + const ranks = [ + observationTimeRank(hourly.localTime, row, localDateStr), + observationTimeRank(conditionObservationTime(hourly.airportCurrent), row, localDateStr), + observationTimeRank(conditionObservationTime(hourly.airportPrimary), row, localDateStr), + latestRawObservationRank(hourly.airportPrimaryTodayObs, row, localDateStr), + latestRawObservationRank(hourly.metarTodayObs, row, localDateStr), + latestRawObservationRank(hourly.settlementTodayObs, row, localDateStr), + latestRunwayHistoryRank(hourly.runwayPlateHistory, row, localDateStr), + ].filter((rank): rank is number => rank !== null); + return ranks.length ? Math.max(...ranks) : null; +} + +function shouldKeepLiveHourlyDetailPayload( + base: HourlyForecast, + live: HourlyForecast, + row: ScanOpportunityRow | null, +) { + if (!base || !live) return false; + if (!hasFullHourlyDetailPayload(base) || !hasFullHourlyDetailPayload(live)) return false; + const baseRank = latestHourlyObservationRank(base, row); + const liveRank = latestHourlyObservationRank(live, row); + return baseRank !== null && liveRank !== null && liveRank > baseRank; +} + function hourlyLocalDatesConflict( base: HourlyForecast, live: HourlyForecast, @@ -1286,16 +1371,17 @@ function mergeHourlyWithLiveObservations( if (!base) return live; if (!live) return base; if (hourlyLocalDatesConflict(base, live, row)) return base; - const localDate = base.localDate || live.localDate || row?.local_date || null; + const detailSource = shouldKeepLiveHourlyDetailPayload(base, live, row) ? live : base; + const localDate = detailSource.localDate || base.localDate || live.localDate || row?.local_date || null; const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory); const amos = runwayPlateHistory ? { - ...(base.amos || {}), + ...(detailSource.amos || {}), runway_plate_history: runwayPlateHistory, } as AmosData - : base.amos; + : detailSource.amos; return { - ...base, + ...detailSource, localDate, localTime: live.localTime || base.localTime, runwayPlateHistory,