From 9dc0d598a0466a692029e34939f8c49cfb89da9a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 05:18:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E8=A1=A8=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=E8=AE=A1=E7=AE=97=E5=92=8C=20stale=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatCityLocalDate 改为纯 UTC+cityOffset+getUTC*,不再受浏览器时区影响 - 新增 formatCityLocalDateTime 统一更新时间的时区处理 - 缓存过期时静默后台刷新,不转 loading spinner - showingStaleDetail 现在不依赖 detailError 就显示橙色标签 --- .../LiveTemperatureThresholdChart.tsx | 45 ++++++++++++------- .../scan-terminal/TemperatureChartCanvas.tsx | 3 +- ...temperatureDefaultVisibilityPolicy.test.ts | 35 +++++++++++++++ 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index e8687119..09ab2a9a 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -103,13 +103,27 @@ function peakGlowTitle( function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) { const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000; - const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000); - const y = cityNow.getFullYear(); - const m = String(cityNow.getMonth() + 1).padStart(2, "0"); - const d = String(cityNow.getDate()).padStart(2, "0"); + const cityNow = new Date(Date.now() + cityOffsetMs); + const y = cityNow.getUTCFullYear(); + const m = String(cityNow.getUTCMonth() + 1).padStart(2, "0"); + const d = String(cityNow.getUTCDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } +function formatCityLocalDateTime(tzOffsetSeconds: number | null | undefined) { + const nowUtc = Date.now(); + const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000; + const cityNow = new Date(nowUtc + cityOffsetMs); + const pad = (n: number) => String(n).padStart(2, "0"); + const y = cityNow.getUTCFullYear(); + const mo = pad(cityNow.getUTCMonth() + 1); + const d = pad(cityNow.getUTCDate()); + const hh = pad(cityNow.getUTCHours()); + const mm = pad(cityNow.getUTCMinutes()); + const ss = pad(cityNow.getUTCSeconds()); + return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`; +} + function getLiveTempFromHourly(data: HourlyForecast) { return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null; } @@ -878,6 +892,9 @@ export function LiveTemperatureThresholdChart({ return; } + // ── Stale-while-revalidate: show cached data, refresh in background ── + const hasStaleCache = cached && !hasFreshCache; + if ( !shouldFetchCityDetailForChart({ city, @@ -899,7 +916,11 @@ export function LiveTemperatureThresholdChart({ commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot())); setShowingStaleDetail(false); } - setIsHourlyLoading(true); + + // Stale cache: don't show loading spinner, refresh silently + if (!hasStaleCache) { + setIsHourlyLoading(true); + } let cancelled = false; let retryScheduled = false; let retryTimer: ReturnType | null = null; @@ -1292,17 +1313,7 @@ export function LiveTemperatureThresholdChart({ ); const formattedUpdateTime = useMemo(() => { - const nowUtc = Date.now(); - const cityOffsetMs = (row?.tz_offset_seconds ?? 0) * 1000; - const cityNow = new Date(nowUtc + cityOffsetMs + new Date().getTimezoneOffset() * 60_000); - const pad = (n: number) => String(n).padStart(2, "0"); - const y = cityNow.getFullYear(); - const mo = pad(cityNow.getMonth() + 1); - const d = pad(cityNow.getDate()); - const hh = pad(cityNow.getHours()); - const mm = pad(cityNow.getMinutes()); - const ss = pad(cityNow.getSeconds()); - return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`; + return formatCityLocalDateTime(row?.tz_offset_seconds); }, [row]); const cityThresholds = useMemo(() => { @@ -1692,6 +1703,8 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels; export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; export const __getPeakGlowStateForTest = getPeakGlowState; export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh; +export const __formatCityLocalDateForTest = formatCityLocalDate; +export const __formatCityLocalDateTimeForTest = formatCityLocalDateTime; export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs; export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart; export const __shouldPollLiveChartForTest = shouldPollLiveChart; diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx index 10e89d76..4646ea4f 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -228,7 +228,8 @@ function TemperatureChartCanvasComponent({ const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent; const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent; const shouldShowBackgroundError = - showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent; + showDetailErrorBadge && Boolean(row?.city) && !isHourlyLoading && hasDrawableChartContent && + (Boolean(detailError) || showingStaleDetail || detailStatus === "stale_cache"); const backgroundErrorLabel = showingStaleDetail || detailStatus === "stale_cache" ? (isEn ? "Detail cache" : "详情缓存") diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 7d27327c..b2c24f3c 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -7,6 +7,8 @@ import { __getPeakGlowStateForTest, __getWundergroundDailyHighForTest, __getVisibleTemperatureSeriesForTest, + __formatCityLocalDateForTest, + __formatCityLocalDateTimeForTest, __isTemperatureSeriesVisibleByDefaultForTest, __mergePatchIntoHourlyForTest, __selectCompactSecondaryTempForTest, @@ -27,6 +29,39 @@ function runwayKey(rwy: string) { } export function runTests() { + { + const originalDateNow = Date.now; + const originalGetTimezoneOffsetForCityDate = Date.prototype.getTimezoneOffset; + try { + Date.now = () => Date.UTC(2026, 5, 15, 14, 0, 0); + Date.prototype.getTimezoneOffset = function () { + return 0; + }; + assert( + __formatCityLocalDateForTest(9 * 60 * 60) === "2026-06-15", + "Tokyo local date should be formatted from UTC plus city offset, independent of browser timezone", + ); + assert( + __formatCityLocalDateTimeForTest(9 * 60 * 60) === "2026-06-15 23:00:00", + "Tokyo update time should be formatted from UTC plus city offset, independent of browser timezone", + ); + Date.prototype.getTimezoneOffset = function () { + return -8 * 60; + }; + assert( + __formatCityLocalDateForTest(9 * 60 * 60) === "2026-06-15", + "Tokyo local date should not change when the browser timezone changes", + ); + assert( + __formatCityLocalDateTimeForTest(9 * 60 * 60) === "2026-06-15 23:00:00", + "Tokyo update time should not change when the browser timezone changes", + ); + } finally { + Date.now = originalDateNow; + Date.prototype.getTimezoneOffset = originalGetTimezoneOffsetForCityDate; + } + } + const peakGlowSeries = [ { key: "madis",