From 1df13bb9e638748efdc16af76b5f27803d6c706a Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Mon, 29 Jun 2026 20:08:41 +0800
Subject: [PATCH] Fix model summary live local time
---
.../scan-terminal/ModelSummaryDashboard.tsx | 17 +++++--
.../__tests__/modelSummaryDashboard.test.ts | 47 ++++++++++++++-----
frontend/lib/model-summary.ts | 15 ++++++
3 files changed, 65 insertions(+), 14 deletions(-)
diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
index 780dfb1c..724d9ba9 100644
--- a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
+++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
@@ -2,12 +2,13 @@
import clsx from "clsx";
import { Search, Table2 } from "lucide-react";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
MODEL_SUMMARY_MODEL_COLUMNS,
buildModelSummaryRows,
filterModelSummaryRows,
+ formatModelSummaryLocalTime,
formatModelSummaryTemp,
type ModelSummaryRow,
} from "@/lib/model-summary";
@@ -94,8 +95,10 @@ function FilterToggle({
function ModelSummaryRowView({
row,
+ nowMs,
}: {
row: ModelSummaryRow;
+ nowMs: number | null;
}) {
return (
@@ -109,7 +112,7 @@ function ModelSummaryRowView({
{row.regionLabel}
|
- {row.localTime || "—"}
+ {formatModelSummaryLocalTime(row, nowMs)}
|
@@ -137,6 +140,14 @@ export function ModelSummaryDashboard({
const [query, setQuery] = useState("");
const [debOnly, setDebOnly] = useState(false);
const [wideSpreadOnly, setWideSpreadOnly] = useState(false);
+ const [nowMs, setNowMs] = useState(null);
+
+ useEffect(() => {
+ const syncClock = () => setNowMs(Date.now());
+ syncClock();
+ const timer = window.setInterval(syncClock, 30_000);
+ return () => window.clearInterval(timer);
+ }, []);
const summaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]);
const visibleRows = useMemo(
@@ -216,7 +227,7 @@ export function ModelSummaryDashboard({
{visibleRows.map((row) => (
-
+
))}
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
index 809af4bd..b75d3414 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
@@ -4,6 +4,7 @@ import {
MODEL_SUMMARY_MODEL_COLUMNS,
buildModelSummaryRows,
filterModelSummaryRows,
+ formatModelSummaryLocalTime,
formatModelSummaryTemp,
} from "@/lib/model-summary";
@@ -13,6 +14,22 @@ function assert(condition: unknown, message: string) {
export function runTests() {
const rows = [
+ {
+ city: "beijing",
+ city_display_name: "Beijing",
+ trading_region_label: "East Asia",
+ trading_region_label_zh: "东亚",
+ trading_region_sort: 1,
+ temp_symbol: "°C",
+ current_max_so_far: 28,
+ deb_prediction: 29.1,
+ local_time: "18:46",
+ tz_offset_seconds: 28800,
+ model_cluster_sources: {
+ ECMWF: 28.8,
+ GFS: 29.4,
+ },
+ },
{
city: "paris",
city_display_name: "Paris",
@@ -68,6 +85,10 @@ export function runTests() {
] as any;
const originalFirstModelSources = rows[0].model_cluster_sources;
const summaryRows = buildModelSummaryRows(rows, false);
+ const amsterdamRow = summaryRows.find((row) => row.cityName === "Amsterdam");
+ const beijingRow = summaryRows.find((row) => row.cityName === "Beijing");
+ const madridRow = summaryRows.find((row) => row.cityName === "Madrid");
+ const parisRow = summaryRows.find((row) => row.cityName === "Paris");
assert(
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") &&
@@ -75,17 +96,21 @@ export function runTests() {
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
"model summary must expose the fixed model columns including optional short-range models",
);
- assert(summaryRows.length === 3, "model summary should keep one row per city");
- assert(summaryRows[0].cityName === "Amsterdam", "model summary should sort by resolved region then city name");
- assert(summaryRows[1].cityName === "Madrid", "model summary should sort by resolved region then city name");
- assert(summaryRows[2].cityName === "Paris", "model summary should sort by resolved region then city name");
- assert(summaryRows[0].regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
- assert(summaryRows[0].localTime === "12:11", "model summary should expose local time in place of current high");
- assert(summaryRows[2].debPrediction === 31.6, "model summary should preserve DEB prediction");
- assert(summaryRows[2].models.GFS === 33.4, "model summary should preserve model high temperature");
- assert(summaryRows[2].models.HRRR === null, "missing models should be normalized to null");
- assert(summaryRows[2].modelMedian === 32.1, "model median should use available model values only");
- assert(summaryRows[2].modelSpread === 2.5, "model spread should use available model min/max only");
+ assert(summaryRows.length === 4, "model summary should keep one row per city");
+ assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name");
+ assert(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
+ if (!madridRow || !parisRow) throw new Error("model summary should keep European rows");
+ assert(beijingRow?.localTime === "18:46", "model summary should keep stale source local_time only as a fallback");
+ assert(
+ beijingRow &&
+ formatModelSummaryLocalTime(beijingRow, Date.parse("2026-06-29T12:05:00Z")) === "20:05",
+ "model summary should display live local time from timezone offset instead of stale cached local_time",
+ );
+ assert(parisRow.debPrediction === 31.6, "model summary should preserve DEB prediction");
+ assert(parisRow.models.GFS === 33.4, "model summary should preserve model high temperature");
+ assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
+ assert(parisRow.modelMedian === 32.1, "model median should use available model values only");
+ assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only");
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
assert(formatModelSummaryTemp(32.16, "°C") === "32.2°C", "model temperatures should render to one decimal");
diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts
index 06854dd3..a55a14a4 100644
--- a/frontend/lib/model-summary.ts
+++ b/frontend/lib/model-summary.ts
@@ -28,6 +28,7 @@ export type ModelSummaryRow = {
regionSort: number;
tempSymbol: string;
localTime: string;
+ timezoneOffsetSeconds: number | null;
debPrediction: number | null;
models: Record;
modelMedian: number | null;
@@ -110,6 +111,19 @@ export function formatModelSummaryTemp(value: number | null | undefined, symbol
return `${numericValue.toFixed(1)}${symbol || "°C"}`;
}
+export function formatModelSummaryLocalTime(
+ row: Pick,
+ nowMs: number | null | undefined = Date.now(),
+) {
+ const offsetSeconds = finiteNumber(row.timezoneOffsetSeconds);
+ const timestampMs = finiteNumber(nowMs);
+ if (offsetSeconds == null || timestampMs == null) return row.localTime || "—";
+ const localDate = new Date(timestampMs + offsetSeconds * 1000);
+ const hours = String(localDate.getUTCHours()).padStart(2, "0");
+ const minutes = String(localDate.getUTCMinutes()).padStart(2, "0");
+ return `${hours}:${minutes}`;
+}
+
export function buildModelSummaryRows(
rows: ScanOpportunityRow[],
isEn: boolean,
@@ -147,6 +161,7 @@ export function buildModelSummaryRows(
regionSort: region.sort,
tempSymbol: row.temp_symbol || "°C",
localTime: normalizeLocalTime(row.local_time),
+ timezoneOffsetSeconds: finiteNumber(row.tz_offset_seconds),
debPrediction: finiteNumber(row.deb_prediction),
models,
modelMedian: median(modelValues),
|