修复图表时区计算和 stale 缓存展示
- formatCityLocalDate 改为纯 UTC+cityOffset+getUTC*,不再受浏览器时区影响 - 新增 formatCityLocalDateTime 统一更新时间的时区处理 - 缓存过期时静默后台刷新,不转 loading spinner - showingStaleDetail 现在不依赖 detailError 就显示橙色标签
This commit is contained in:
@@ -103,13 +103,27 @@ function peakGlowTitle(
|
|||||||
|
|
||||||
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) {
|
||||||
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000;
|
||||||
const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000);
|
const cityNow = new Date(Date.now() + cityOffsetMs);
|
||||||
const y = cityNow.getFullYear();
|
const y = cityNow.getUTCFullYear();
|
||||||
const m = String(cityNow.getMonth() + 1).padStart(2, "0");
|
const m = String(cityNow.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const d = String(cityNow.getDate()).padStart(2, "0");
|
const d = String(cityNow.getUTCDate()).padStart(2, "0");
|
||||||
return `${y}-${m}-${d}`;
|
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) {
|
function getLiveTempFromHourly(data: HourlyForecast) {
|
||||||
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
|
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
|
||||||
}
|
}
|
||||||
@@ -878,6 +892,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stale-while-revalidate: show cached data, refresh in background ──
|
||||||
|
const hasStaleCache = cached && !hasFreshCache;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!shouldFetchCityDetailForChart({
|
!shouldFetchCityDetailForChart({
|
||||||
city,
|
city,
|
||||||
@@ -899,7 +916,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot()));
|
commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot()));
|
||||||
setShowingStaleDetail(false);
|
setShowingStaleDetail(false);
|
||||||
}
|
}
|
||||||
setIsHourlyLoading(true);
|
|
||||||
|
// Stale cache: don't show loading spinner, refresh silently
|
||||||
|
if (!hasStaleCache) {
|
||||||
|
setIsHourlyLoading(true);
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let retryScheduled = false;
|
let retryScheduled = false;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -1292,17 +1313,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formattedUpdateTime = useMemo(() => {
|
const formattedUpdateTime = useMemo(() => {
|
||||||
const nowUtc = Date.now();
|
return formatCityLocalDateTime(row?.tz_offset_seconds);
|
||||||
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}`;
|
|
||||||
}, [row]);
|
}, [row]);
|
||||||
|
|
||||||
const cityThresholds = useMemo(() => {
|
const cityThresholds = useMemo(() => {
|
||||||
@@ -1692,6 +1703,8 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
|||||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||||
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
||||||
|
export const __formatCityLocalDateForTest = formatCityLocalDate;
|
||||||
|
export const __formatCityLocalDateTimeForTest = formatCityLocalDateTime;
|
||||||
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
||||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||||
|
|||||||
@@ -228,7 +228,8 @@ function TemperatureChartCanvasComponent({
|
|||||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
||||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||||
const shouldShowBackgroundError =
|
const shouldShowBackgroundError =
|
||||||
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
showDetailErrorBadge && Boolean(row?.city) && !isHourlyLoading && hasDrawableChartContent &&
|
||||||
|
(Boolean(detailError) || showingStaleDetail || detailStatus === "stale_cache");
|
||||||
const backgroundErrorLabel =
|
const backgroundErrorLabel =
|
||||||
showingStaleDetail || detailStatus === "stale_cache"
|
showingStaleDetail || detailStatus === "stale_cache"
|
||||||
? (isEn ? "Detail cache" : "详情缓存")
|
? (isEn ? "Detail cache" : "详情缓存")
|
||||||
|
|||||||
+35
@@ -7,6 +7,8 @@ import {
|
|||||||
__getPeakGlowStateForTest,
|
__getPeakGlowStateForTest,
|
||||||
__getWundergroundDailyHighForTest,
|
__getWundergroundDailyHighForTest,
|
||||||
__getVisibleTemperatureSeriesForTest,
|
__getVisibleTemperatureSeriesForTest,
|
||||||
|
__formatCityLocalDateForTest,
|
||||||
|
__formatCityLocalDateTimeForTest,
|
||||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||||
__mergePatchIntoHourlyForTest,
|
__mergePatchIntoHourlyForTest,
|
||||||
__selectCompactSecondaryTempForTest,
|
__selectCompactSecondaryTempForTest,
|
||||||
@@ -27,6 +29,39 @@ function runwayKey(rwy: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runTests() {
|
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 = [
|
const peakGlowSeries = [
|
||||||
{
|
{
|
||||||
key: "madis",
|
key: "madis",
|
||||||
|
|||||||
Reference in New Issue
Block a user