diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index fc0c6da5..084f9dd9 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -28,7 +28,9 @@ import { getObservationDisplayMetrics, getVisibleTemperatureSeries, isTemperatureSeriesVisibleByDefault, + mergeHourlyWithLiveObservations, mergePatchIntoHourly, + mergeRowObservationIntoHourly, normObs, prefersHighFrequencyRunwayResolution, readSessionCache, @@ -107,6 +109,31 @@ function getLiveTempFromHourly(data: HourlyForecast) { return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null; } +function rowObservationSignature(row: ScanOpportunityRow | null) { + if (!row) return ""; + const metarContext = row.metar_context || null; + const pieces = [ + row.city, + row.current_temp, + row.current_max_so_far, + row.local_time, + (row as any).sse_revision, + metarContext?.airport_current_temp, + metarContext?.airport_max_so_far, + metarContext?.airport_obs_time, + metarContext?.last_temp, + metarContext?.last_time, + metarContext?.last_observation_time, + metarContext?.source, + metarContext?.station_label, + ]; + const hasObservation = + validNumber(row.current_temp) !== null || + validNumber(metarContext?.airport_current_temp) !== null || + validNumber(metarContext?.last_temp) !== null; + return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : ""; +} + function getWundergroundDailyHigh(hourly: HourlyForecast) { return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; } @@ -437,6 +464,7 @@ export function LiveTemperatureThresholdChart({ const lastAppliedPatchRevisionRef = useRef(0); const lastProbabilityRefreshAtRef = useRef(0); const lastForegroundRefreshAtRef = useRef(0); + const lastRowObservationSignatureRef = useRef(""); const localDayRolloverFetchDateRef = useRef(""); const [isChartVisible, setIsChartVisible] = useState( () => typeof IntersectionObserver === "undefined", @@ -457,6 +485,7 @@ export function LiveTemperatureThresholdChart({ const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() => formatCityLocalDate(row?.tz_offset_seconds), ); + const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]); useEffect(() => { setUserToggledKeys({}); @@ -475,6 +504,7 @@ export function LiveTemperatureThresholdChart({ lastAppliedPatchRevisionRef.current = 0; lastProbabilityRefreshAtRef.current = 0; lastForegroundRefreshAtRef.current = 0; + lastRowObservationSignatureRef.current = ""; localDayRolloverFetchDateRef.current = ""; setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); }, [city, detailLoadDelayMs]); @@ -521,15 +551,27 @@ export function LiveTemperatureThresholdChart({ const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { if (!data) return; + const rowSeed = seedHourlyForecastFromRow(row); + const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row); hasLoadedHourlyDetailRef.current = true; if (options?.updateLiveTemp) { - const temp = getLiveTempFromHourly(data); + const temp = getLiveTempFromHourly(dataWithCurrentRow); if (temp !== null) setLiveTemp(temp); } - setHourly(data); + setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)); setDetailError(null); setShowingStaleDetail(false); - }, []); + }, [row]); + + useEffect(() => { + if (!city || !currentRowObservationSignature) return; + if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return; + lastRowObservationSignatureRef.current = currentRowObservationSignature; + const rowSeed = seedHourlyForecastFromRow(row); + const temp = getLiveTempFromHourly(rowSeed); + if (temp !== null) setLiveTemp(temp); + setHourly((prev) => mergeRowObservationIntoHourly(prev ?? rowSeed, row)); + }, [city, currentRowObservationSignature, row]); useEffect(() => { if (!city) { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index c98117a8..5b5b0b9d 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -91,8 +91,8 @@ export async function runTests() { ); assert( chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && - chartSource.includes("setHourly(data)"), - "visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop", + chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row))"), + "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations", ); assert( chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") && @@ -197,11 +197,11 @@ export async function runTests() { "city detail charts should show stale cache first and expose a retryable unavailable state", ); const successfulHourlyDetailBlock = - /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[\]\);/.exec(chartSource)?.[0] || ""; + /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || ""; assert( successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && - successfulHourlyDetailBlock.includes("setHourly(data)"), + successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"), "successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", ); const rawSuccessfulSetHourlyCalls = chartSource diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 5f327067..bfe65aa8 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -3,7 +3,9 @@ import type { CityDetail } from "@/lib/dashboard-types"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { buildFullDayChartData, + mergeHourlyWithLiveObservations, mergePatchIntoHourly, + mergeRowObservationIntoHourly, seedHourlyForecastFromRow, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -293,4 +295,43 @@ export function runTests() { patchedRunway?.values.some((value) => value === 28.8), "SSE runway patches must append directly to the chart series without waiting for a force-refreshed detail payload", ); + + const guangzhouLaterRow = { + ...guangzhouRow, + current_temp: 29.1, + local_time: "12:51", + sse_revision: 43, + metar_context: { + ...guangzhouRow.metar_context, + airport_current_temp: 29.1, + airport_obs_time: "12:51", + airport_max_so_far: 29.1, + }, + } as any; + const guangzhouRowMerged = mergeRowObservationIntoHourly(seededGuangzhou, guangzhouLaterRow); + const guangzhouRowMergedChart = buildFullDayChartData(guangzhouLaterRow, guangzhouRowMerged, false); + const rowMergedRunway = guangzhouRowMergedChart.series.find((item) => item.key === "runway_02L_20R"); + assert( + rowMergedRunway?.values.some((value) => value === 29.1), + "same-city scan row observation changes must merge into chart state without requiring an active-slot click", + ); + + const staleDetail = { + ...seededGuangzhou, + forecastDaily: [{ date: "2026-06-10", max_temp: 31, min_temp: 24 }] as any, + probabilities: { engine: "legacy", distribution: [{ value: 30, probability: 0.4 }] }, + runwayPlateHistory: { + "02L/20R": [{ timestamp: "12:45", temp_c: 28.4, value: 28.4 }], + }, + airportPrimaryTodayObs: [["12:45", 28.4]], + airportCurrent: { temp: 28.4, obs_time: "12:45", max_so_far: 29 }, + airportPrimary: { temp: 28.4, obs_time: "12:45", max_so_far: 29 }, + } as any; + const mergedAfterStaleDetail = mergeHourlyWithLiveObservations(staleDetail, guangzhouPatched, guangzhouRow); + const mergedAfterStaleDetailChart = buildFullDayChartData(guangzhouRow, mergedAfterStaleDetail, false); + const preservedPatchRunway = mergedAfterStaleDetailChart.series.find((item) => item.key === "runway_02L_20R"); + assert( + preservedPatchRunway?.values.some((value) => value === 28.8), + "stale full-detail responses must not overwrite a newer live SSE observation point", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 64b7e117..75b848d4 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -981,6 +981,113 @@ function appendRawObservationPoint( return merged.slice(-MAX_OBS_POINTS); } +function rawObservationKey(point: RawObsPoint) { + const normalized = normalizeRawObsPoint(point); + const time = String(normalized?.time || "").trim(); + const temp = validNumber(normalized?.temp); + if (!time || temp === null) return ""; + return `${time}:${temp}`; +} + +function mergeRawObservationPoints( + base: RawObsPoint[] | null | undefined, + live: RawObsPoint[] | null | undefined, +) { + const merged: RawObsPoint[] = []; + const seen = new Set(); + [...(base || []), ...(live || [])].forEach((point) => { + const key = rawObservationKey(point); + if (!key || seen.has(key)) return; + seen.add(key); + merged.push(point); + }); + return merged.length ? merged.slice(-MAX_OBS_POINTS) : undefined; +} + +function observationTimeRank( + value: string | number | null | undefined, + row: ScanOpportunityRow | null, + localDateStr: string | null | undefined, +) { + const localRank = getCityLocalUtcTimestamp( + value, + row?.tz_offset_seconds ?? 0, + localDateStr || row?.local_date || null, + ); + if (localRank !== null) return localRank; + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = Date.parse(String(value || "")); + return Number.isFinite(parsed) ? parsed : null; +} + +function conditionObservationTime(source: AirportCurrentConditions | null | undefined) { + return ( + (source as any)?.obs_time ?? + (source as any)?.observation_time ?? + (source as any)?.timestamp ?? + (source as any)?.time ?? + null + ); +} + +function mergeAirportCondition( + base: AirportCurrentConditions | null | undefined, + live: AirportCurrentConditions | null | undefined, + row: ScanOpportunityRow | null, + localDateStr: string | null | undefined, +) { + if (!base) return live || null; + if (!live) return base || null; + const baseTime = observationTimeRank(conditionObservationTime(base), row, localDateStr); + const liveTime = observationTimeRank(conditionObservationTime(live), row, localDateStr); + const liveIsAtLeastAsFresh = liveTime === null || baseTime === null || liveTime >= baseTime; + const maxSoFar = Math.max( + validNumber(base.max_so_far) ?? validNumber(base.temp) ?? Number.NEGATIVE_INFINITY, + validNumber(live.max_so_far) ?? validNumber(live.temp) ?? Number.NEGATIVE_INFINITY, + ); + const merged = liveIsAtLeastAsFresh ? { ...base, ...live } : { ...live, ...base }; + if (Number.isFinite(maxSoFar)) { + merged.max_so_far = maxSoFar; + } + return merged; +} + +function runwayHistoryPointKey(point: Record) { + const time = String( + point.timestamp ?? + point.time ?? + point.observed_at ?? + "", + ).trim(); + const value = parseRunwayHistoryValue(point); + if (!time || value === null) return ""; + return `${time}:${value}`; +} + +function mergeRunwayPlateHistory( + base: Record>> | undefined, + live: Record>> | undefined, +) { + if (!base && !live) return undefined; + const result: Record>> = {}; + Object.entries(base || {}).forEach(([rwy, points]) => { + if (Array.isArray(points)) result[rwy] = [...points]; + }); + Object.entries(live || {}).forEach(([rwy, points]) => { + if (!Array.isArray(points)) return; + const merged = result[rwy] ? [...result[rwy]] : []; + const seen = new Set(merged.map(runwayHistoryPointKey).filter(Boolean)); + points.forEach((point) => { + const key = runwayHistoryPointKey(point); + if (key && seen.has(key)) return; + if (key) seen.add(key); + merged.push(point); + }); + result[rwy] = merged.slice(-MAX_OBS_POINTS); + }); + return Object.keys(result).length ? result : undefined; +} + function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) { const cityKey = normalizeCityKey(row?.city); const sourceText = [ @@ -1121,6 +1228,47 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca }; } +function mergeHourlyWithLiveObservations( + base: HourlyForecast, + live: HourlyForecast, + row: ScanOpportunityRow | null, +): HourlyForecast { + if (!base) return live; + if (!live) return base; + const localDate = base.localDate || live.localDate || row?.local_date || null; + const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory); + const amos = runwayPlateHistory + ? { + ...(base.amos || {}), + runway_plate_history: runwayPlateHistory, + } as AmosData + : base.amos; + return { + ...base, + localDate, + localTime: live.localTime || base.localTime, + runwayPlateHistory, + amos, + airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate), + airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate), + settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined, + metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined, + airportPrimaryTodayObs: mergeRawObservationPoints( + base.airportPrimaryTodayObs, + live.airportPrimaryTodayObs, + ), + }; +} + +function mergeRowObservationIntoHourly( + prev: HourlyForecast, + row: ScanOpportunityRow | null, +): HourlyForecast { + const seeded = seedHourlyForecastFromRow(row); + if (!prev) return seeded; + return mergeHourlyWithLiveObservations(prev, seeded, row); +} + type HourlyForecastFetchOptions = { ignoreCache?: boolean; resolution?: string; @@ -2652,7 +2800,9 @@ export { getObservationDisplayMetrics, getVisibleTemperatureSeries, isTemperatureSeriesVisibleByDefault, + mergeHourlyWithLiveObservations, mergePatchIntoHourly, + mergeRowObservationIntoHourly, normObs, normalizeCityKey, prefersHighFrequencyRunwayResolution,