diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 51fb0c61..43c31af4 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -519,6 +519,18 @@ export function LiveTemperatureThresholdChart({ return () => clearInterval(id); }, [row?.tz_offset_seconds]); + const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { + if (!data) return; + hasLoadedHourlyDetailRef.current = true; + if (options?.updateLiveTemp) { + const temp = getLiveTempFromHourly(data); + if (temp !== null) setLiveTemp(temp); + } + setHourly(data); + setDetailError(null); + setShowingStaleDetail(false); + }, []); + useEffect(() => { if (!city) { setIsHourlyLoading(false); @@ -581,10 +593,7 @@ export function LiveTemperatureThresholdChart({ setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用"); return; } - hasLoadedHourlyDetailRef.current = true; - setHourly(data); - setDetailError(null); - setShowingStaleDetail(false); + applySuccessfulHourlyDetail(data); }) .catch(() => { if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用"); @@ -610,6 +619,7 @@ export function LiveTemperatureThresholdChart({ detailLoadReady, detailRetryNonce, isEn, + applySuccessfulHourlyDetail, ]); useEffect(() => { @@ -635,8 +645,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; - hasLoadedHourlyDetailRef.current = true; - setHourly(data); + applySuccessfulHourlyDetail(data); }) .catch(() => {}); }; @@ -645,7 +654,7 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; - }, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized]); + }, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized, applySuccessfulHourlyDetail]); useEffect(() => { if (!resyncVersion || !city) return; @@ -653,8 +662,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; - hasLoadedHourlyDetailRef.current = true; - setHourly(data); + applySuccessfulHourlyDetail(data); }) .catch(() => {}) .finally(() => { @@ -663,7 +671,7 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; - }, [resyncVersion, city, targetResolution]); + }, [resyncVersion, city, targetResolution, applySuccessfulHourlyDetail]); // ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ── useEffect(() => { @@ -676,10 +684,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; - hasLoadedHourlyDetailRef.current = true; - const temp = getLiveTempFromHourly(data); - if (temp !== null) setLiveTemp(temp); - setHourly(data); + applySuccessfulHourlyDetail(data, { updateLiveTemp: true }); }) .catch(() => {}) .finally(() => { @@ -699,7 +704,7 @@ export function LiveTemperatureThresholdChart({ cancelled = true; clearInterval(id); }; - }, [city, compact, isActive, isMaximized, targetResolution]); + }, [city, compact, isActive, isMaximized, targetResolution, applySuccessfulHourlyDetail]); useEffect(() => { if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return; @@ -723,10 +728,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; - hasLoadedHourlyDetailRef.current = true; - const temp = getLiveTempFromHourly(data); - if (temp !== null) setLiveTemp(temp); - setHourly(data); + applySuccessfulHourlyDetail(data, { updateLiveTemp: true }); }) .catch(() => {}); }; @@ -743,7 +745,7 @@ export function LiveTemperatureThresholdChart({ document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", refreshForegroundFullDetail); }; - }, [city, compact, isActive, isMaximized, targetResolution]); + }, [city, compact, isActive, isMaximized, targetResolution, applySuccessfulHourlyDetail]); useEffect(() => { if (!city || !currentCityLocalDate) return; @@ -756,8 +758,7 @@ export function LiveTemperatureThresholdChart({ fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) .then((data) => { if (cancelled || !data) return; - hasLoadedHourlyDetailRef.current = true; - setHourly(data); + applySuccessfulHourlyDetail(data); }) .catch(() => { if (!cancelled) localDayRolloverFetchDateRef.current = ""; @@ -766,7 +767,7 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; - }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution]); + }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, applySuccessfulHourlyDetail]); const chartHourly = useMemo(() => { if (!hourly) return hourly; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index b685e6db..311b8122 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -191,6 +191,22 @@ export async function runTests() { chartCanvasSourceIncludes(chartSource, "handleRetryDetail"), "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] || ""; + assert( + successfulHourlyDetailBlock.includes("setDetailError(null)") && + successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && + successfulHourlyDetailBlock.includes("setHourly(data)"), + "successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", + ); + const rawSuccessfulSetHourlyCalls = chartSource + .replace(successfulHourlyDetailBlock, "") + .match(/setHourly\(data\);/g) || []; + assert( + rawSuccessfulSetHourlyCalls.length === 0 && + (chartSource.match(/applySuccessfulHourlyDetail\(data/g) || []).length >= 5, + "all successful city detail fetch branches should use the shared success handler", + ); assert( chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") && chartSource.includes("showDetailErrorBadge={showDetailErrorBadge}") && diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 416fde32..e93f1af6 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -187,7 +187,7 @@ export function runTests() { !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), "no-patch fallback refresh should update the chart in the background without showing the loading overlay", ); - const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution\]\);/)?.[0] || ""; + const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution, applySuccessfulHourlyDetail\]\);/)?.[0] || ""; assert( !resyncBlock.includes("setIsHourlyLoading(true)"), "SSE replay resync should refresh full detail in the background without showing the loading overlay", @@ -240,7 +240,7 @@ export function runTests() { 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]*?\}, \[latestPatch, row, city, targetResolution, compact, isActive, isMaximized\]\);/)?.[0] || ""; + const patchEffectBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?\}, \[latestPatch, row, city, targetResolution, compact, isActive, isMaximized, applySuccessfulHourlyDetail\]\);/)?.[0] || ""; assert( patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") && patchEffectBlock.includes("ignoreCache: true") &&