diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index d989632f..bf1a286e 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -38,7 +38,6 @@ import { readCityDetailBatchDiagnostics, readHourlyDetailSnapshot, readHourlyDetailSnapshotAgeMs, - rememberHourlyDetailSnapshot, selectCompactSecondaryTemp, selectDisplayRunwayTemp, selectInitialHourlyForRowChange, @@ -65,7 +64,6 @@ const PEAK_GLOW_BADGE_CLASS = { cooling: "border-slate-200 bg-slate-100 text-slate-500", } as const; -const PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000; const LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback; const DETAIL_LOAD_BATCH_DELAY_MS = 0; @@ -684,7 +682,6 @@ export function LiveTemperatureThresholdChart({ const chartVisibilityRef = useRef(null); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); - const lastProbabilityRefreshAtRef = useRef(0); const lastForegroundRefreshAtRef = useRef(0); const lastRowObservationSignatureRef = useRef(""); const localDayRolloverFetchDateRef = useRef(""); @@ -763,7 +760,6 @@ export function LiveTemperatureThresholdChart({ hasLoadedHourlyDetailRef.current = false; lastPatchAtRef.current = now; lastAppliedPatchRevisionRef.current = 0; - lastProbabilityRefreshAtRef.current = 0; lastForegroundRefreshAtRef.current = 0; lastRowObservationSignatureRef.current = ""; localDayRolloverFetchDateRef.current = ""; @@ -811,12 +807,8 @@ export function LiveTemperatureThresholdChart({ }, [row?.tz_offset_seconds]); const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => { - setHourly((prev) => { - const next = buildNext(prev); - rememberHourlyDetailSnapshot(city, targetResolution, next); - return next; - }); - }, [city, targetResolution]); + setHourly((prev) => buildNext(prev)); + }, []); const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { if (!data) return; @@ -1024,46 +1016,19 @@ export function LiveTemperatureThresholdChart({ sseSourceToCollectorLatencySec: latestPatch.delivery?.source_to_collector_latency_sec ?? null, })); - const hasObservationChange = - tempValue !== null || - Array.isArray(latestPatch.changes.runway_points) || - Boolean(latestPatch.changes.amos); - if (!hasObservationChange || !shouldPollLiveChart({ city, compact, isActive, isMaximized })) return; - - const now = Date.now(); - if (now - lastProbabilityRefreshAtRef.current < PROBABILITY_REFRESH_AFTER_PATCH_MS) return; - lastProbabilityRefreshAtRef.current = now; - - let cancelled = false; - const refreshProbabilityOverlayAfterPatch = () => { - void runHourlyDetailFetch({ - source: "force_refresh", - fetchOptions: { ignoreCache: true }, - isCancelled: () => cancelled, - }); - }; - - refreshProbabilityOverlayAfterPatch(); - return () => { - cancelled = true; - }; - }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, commitHourlySnapshot, runHourlyDetailFetch]); + }, [latestPatch, getLatestRowSnapshot, commitHourlySnapshot]); useEffect(() => { if (!resyncVersion || !city) return; let cancelled = false; - void runHourlyDetailFetch({ - source: "force_refresh", - fetchOptions: { ignoreCache: true }, - isCancelled: () => cancelled, - onSettled: () => { - setIsHourlyLoading(false); - }, + void fetchLiveObservationForCity(city).then((payload) => { + if (cancelled || !payload) return; + applyLiveObservationPayload(payload); }); return () => { cancelled = true; }; - }, [resyncVersion, city, runHourlyDetailFetch]); + }, [resyncVersion, city, applyLiveObservationPayload]); // ── SSE fallback: visible charts merge no-store observations if patches stop. ── useEffect(() => { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index bd33c297..1dabff97 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -82,9 +82,9 @@ export async function runTests() { assert( chartSource.includes("useLatestPatch") && chartSource.includes("latestPatch") && - chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && + !chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && !chartSource.includes("2 * 60_000"), - "selected city chart should consume SSE patches and keep METAR cadence for heavy probability refreshes instead of a 2-minute forced refresh", + "selected city chart should consume SSE patches without coupling live observations to the model/detail refresh cadence", ); assert( chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") && @@ -136,15 +136,15 @@ export async function runTests() { "switching back to the terminal tab should refresh visible chart detail through cached backend data without forcing external sources", ); assert( - chartSource.includes("fetchOptions: { ignoreCache: true }") && - 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", + chartSource.includes("fetchLiveObservationForCity") && + chartSource.includes("mergeObservationPayloadIntoHourly") && + !chartSource.includes("rememberHourlyDetailSnapshot"), + "visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache", ); assert( - chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") && - !chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000"), - "live observation patches should update the plotted line immediately and throttle heavy probability/detail refreshes to METAR cadence", + !chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") && + !chartSource.includes("refreshProbabilityOverlayAfterPatch"), + "live observation patches must not force-refresh model, DEB, probability, or detail-batch data", ); assert( chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") && @@ -370,8 +370,8 @@ export async function runTests() { ); const rememberSnapshotCalls = chartSource.match(/rememberHourlyDetailSnapshot\(/g) || []; assert( - rememberSnapshotCalls.length === 1, - "temperature chart should persist hourly snapshots through one local commit helper instead of writing cache snapshots from multiple effects", + rememberSnapshotCalls.length === 0, + "temperature chart component must not persist live-merged snapshots into the full-detail cache", ); const markDetailDegradedBlock = /const markDetailDegraded = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; @@ -388,13 +388,13 @@ export async function runTests() { successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && successfulHourlyDetailBlock.includes("commitHourlySnapshot") && !/\[row\]/.test(successfulHourlyDetailBlock), - "successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object", + "successful city detail refreshes must read the latest row, merge into chart state, and avoid depending on the row object", ); assert( chartSource.includes("commitHourlySnapshot") && chartSource.includes("mergeRowObservationIntoHourly") && chartSource.includes("mergePatchIntoHourly"), - "live row and SSE patch merges must persist their hourly snapshots through the shared commit helper so returning to terminal restores runway history immediately", + "live row and SSE patch merges must flow through the shared state helper without touching the full-detail cache", ); const coldDetailFetchBlock = /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index ff4c816b..07a03ffd 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -189,12 +189,12 @@ export function runTests() { assert(!chart.includes("function mergePatchIntoHourly"), "LiveTemperatureThresholdChart.tsx must not define SSE patch merge logic inline"); assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)"); assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches"); - assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete"); + assert(chart.includes("useSseResyncVersion"), "temperature chart must resync live observations when SSE replay is incomplete"); assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history"); assert( - chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && + !chart.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && !chart.includes("2 * 60_000"), - "temperature chart must keep METAR cadence for heavy patch-triggered probability refreshes", + "temperature chart must not attach model/detail refresh cadence to live observation patches", ); assert( chart.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback"), @@ -239,12 +239,18 @@ export function runTests() { !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), "no-patch fallback refresh should merge no-store observation data without refreshing cached detail-batch or showing the loading overlay", ); - const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, runHourlyDetailFetch\]\);/)?.[0] || ""; assert( - resyncBlock.includes("runHourlyDetailFetch") && - resyncBlock.includes("fetchOptions: { ignoreCache: true }") && + !chart.includes("rememberHourlyDetailSnapshot"), + "temperature chart must not write row/SSE/observation overlays back into the full-detail cache", + ); + const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationPayload\]\);/)?.[0] || ""; + assert( + resyncBlock.includes("fetchLiveObservationForCity") && + resyncBlock.includes("applyLiveObservationPayload") && + !resyncBlock.includes("runHourlyDetailFetch") && + !resyncBlock.includes("ignoreCache: true") && !resyncBlock.includes("setIsHourlyLoading(true)"), - "SSE replay resync should refresh full detail in the background without showing the loading overlay", + "SSE replay resync should refresh no-store observations without touching cached detail-batch or showing the loading overlay", ); assert( chart.includes("visibilitychange") && @@ -290,19 +296,10 @@ export function runTests() { "runway charts must request 1-minute detail resolution so historical runway lines match live SSE patch cadence", ); assert( - chart.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") && - chart.includes("lastProbabilityRefreshAtRef") && - chart.includes("refreshProbabilityOverlayAfterPatch"), - "temperature chart must trigger a throttled background probability refresh after live observation patches", - ); - const patchEffectBlock = chart.match( - /useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?refreshProbabilityOverlayAfterPatch\(\);[\s\S]*?\}, \[[^\]]*latestPatch[^\]]*runHourlyDetailFetch[^\]]*\]\);/, - )?.[0] || ""; - assert( - patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") && - patchEffectBlock.includes("fetchOptions: { ignoreCache: true }") && - !patchEffectBlock.includes("setIsHourlyLoading(true)"), - "live patch probability refresh must recompute legacy Gaussian in the background without showing a loading overlay", + !chart.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") && + !chart.includes("lastProbabilityRefreshAtRef") && + !chart.includes("refreshProbabilityOverlayAfterPatch"), + "live observation patches must not trigger model/probability/detail refreshes; cached detail-batch stays on its own cadence", ); assert(!chartCanvas.includes("ResponsiveContainer"), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0"); assert(chartCanvas.includes("ResizeObserver"), "temperature chart canvas must measure its host with ResizeObserver");