Fix model summary live local time

This commit is contained in:
2569718930@qq.com
2026-06-29 20:08:41 +08:00
parent 709a567525
commit 1df13bb9e6
3 changed files with 65 additions and 14 deletions
@@ -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 (
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
@@ -109,7 +112,7 @@ function ModelSummaryRowView({
<span className="block truncate">{row.regionLabel}</span>
</td>
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
{row.localTime || "—"}
{formatModelSummaryLocalTime(row, nowMs)}
</td>
<td className="min-w-[90px] px-3 py-2 text-right">
<TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" />
@@ -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<number | null>(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({
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{visibleRows.map((row) => (
<ModelSummaryRowView key={row.cityKey} row={row} />
<ModelSummaryRowView key={row.cityKey} row={row} nowMs={nowMs} />
))}
</tbody>
</table>
@@ -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");
+15
View File
@@ -28,6 +28,7 @@ export type ModelSummaryRow = {
regionSort: number;
tempSymbol: string;
localTime: string;
timezoneOffsetSeconds: number | null;
debPrediction: number | null;
models: Record<ModelSummaryColumnKey, number | null>;
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<ModelSummaryRow, "localTime" | "timezoneOffsetSeconds">,
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),