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 clsx from "clsx";
import { Search, Table2 } from "lucide-react"; 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 type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { import {
MODEL_SUMMARY_MODEL_COLUMNS, MODEL_SUMMARY_MODEL_COLUMNS,
buildModelSummaryRows, buildModelSummaryRows,
filterModelSummaryRows, filterModelSummaryRows,
formatModelSummaryLocalTime,
formatModelSummaryTemp, formatModelSummaryTemp,
type ModelSummaryRow, type ModelSummaryRow,
} from "@/lib/model-summary"; } from "@/lib/model-summary";
@@ -94,8 +95,10 @@ function FilterToggle({
function ModelSummaryRowView({ function ModelSummaryRowView({
row, row,
nowMs,
}: { }: {
row: ModelSummaryRow; row: ModelSummaryRow;
nowMs: number | null;
}) { }) {
return ( return (
<tr className="group border-b border-slate-100 hover:bg-blue-50/40"> <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> <span className="block truncate">{row.regionLabel}</span>
</td> </td>
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700"> <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>
<td className="min-w-[90px] px-3 py-2 text-right"> <td className="min-w-[90px] px-3 py-2 text-right">
<TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" /> <TemperatureCell value={row.debPrediction} symbol={row.tempSymbol} emphasis="deb" />
@@ -137,6 +140,14 @@ export function ModelSummaryDashboard({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debOnly, setDebOnly] = useState(false); const [debOnly, setDebOnly] = useState(false);
const [wideSpreadOnly, setWideSpreadOnly] = 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 summaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]);
const visibleRows = useMemo( const visibleRows = useMemo(
@@ -216,7 +227,7 @@ export function ModelSummaryDashboard({
</thead> </thead>
<tbody className="divide-y divide-slate-100 bg-white"> <tbody className="divide-y divide-slate-100 bg-white">
{visibleRows.map((row) => ( {visibleRows.map((row) => (
<ModelSummaryRowView key={row.cityKey} row={row} /> <ModelSummaryRowView key={row.cityKey} row={row} nowMs={nowMs} />
))} ))}
</tbody> </tbody>
</table> </table>
@@ -4,6 +4,7 @@ import {
MODEL_SUMMARY_MODEL_COLUMNS, MODEL_SUMMARY_MODEL_COLUMNS,
buildModelSummaryRows, buildModelSummaryRows,
filterModelSummaryRows, filterModelSummaryRows,
formatModelSummaryLocalTime,
formatModelSummaryTemp, formatModelSummaryTemp,
} from "@/lib/model-summary"; } from "@/lib/model-summary";
@@ -13,6 +14,22 @@ function assert(condition: unknown, message: string) {
export function runTests() { export function runTests() {
const rows = [ 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: "paris",
city_display_name: "Paris", city_display_name: "Paris",
@@ -68,6 +85,10 @@ export function runTests() {
] as any; ] as any;
const originalFirstModelSources = rows[0].model_cluster_sources; const originalFirstModelSources = rows[0].model_cluster_sources;
const summaryRows = buildModelSummaryRows(rows, false); 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( assert(
MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") && 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_MODEL_COLUMNS.map((column) => column.key).includes("NAM"),
"model summary must expose the fixed model columns including optional short-range models", "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.length === 4, "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[0].cityName === "Beijing", "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(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
assert(summaryRows[2].cityName === "Paris", "model summary should sort by resolved region then city name"); if (!madridRow || !parisRow) throw new Error("model summary should keep European rows");
assert(summaryRows[0].regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities"); assert(beijingRow?.localTime === "18:46", "model summary should keep stale source local_time only as a fallback");
assert(summaryRows[0].localTime === "12:11", "model summary should expose local time in place of current high"); assert(
assert(summaryRows[2].debPrediction === 31.6, "model summary should preserve DEB prediction"); beijingRow &&
assert(summaryRows[2].models.GFS === 33.4, "model summary should preserve model high temperature"); formatModelSummaryLocalTime(beijingRow, Date.parse("2026-06-29T12:05:00Z")) === "20:05",
assert(summaryRows[2].models.HRRR === null, "missing models should be normalized to null"); "model summary should display live local time from timezone offset instead of stale cached local_time",
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(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(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"); 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; regionSort: number;
tempSymbol: string; tempSymbol: string;
localTime: string; localTime: string;
timezoneOffsetSeconds: number | null;
debPrediction: number | null; debPrediction: number | null;
models: Record<ModelSummaryColumnKey, number | null>; models: Record<ModelSummaryColumnKey, number | null>;
modelMedian: number | null; modelMedian: number | null;
@@ -110,6 +111,19 @@ export function formatModelSummaryTemp(value: number | null | undefined, symbol
return `${numericValue.toFixed(1)}${symbol || "°C"}`; 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( export function buildModelSummaryRows(
rows: ScanOpportunityRow[], rows: ScanOpportunityRow[],
isEn: boolean, isEn: boolean,
@@ -147,6 +161,7 @@ export function buildModelSummaryRows(
regionSort: region.sort, regionSort: region.sort,
tempSymbol: row.temp_symbol || "°C", tempSymbol: row.temp_symbol || "°C",
localTime: normalizeLocalTime(row.local_time), localTime: normalizeLocalTime(row.local_time),
timezoneOffsetSeconds: finiteNumber(row.tz_offset_seconds),
debPrediction: finiteNumber(row.deb_prediction), debPrediction: finiteNumber(row.deb_prediction),
models, models,
modelMedian: median(modelValues), modelMedian: median(modelValues),