From 168b813754c1a7a25b81c20c8eda9642dbd2fc84 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 29 Jun 2026 21:17:04 +0800 Subject: [PATCH] Keep model summary data during refresh gaps --- .../scan-terminal/ModelSummaryDashboard.tsx | 58 ++++++++++++++++++- .../__tests__/modelSummaryDashboard.test.ts | 21 +++++++ frontend/lib/model-summary.ts | 7 +++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx index 724d9ba9..aecf4884 100644 --- a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { Search, Table2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { MODEL_SUMMARY_MODEL_COLUMNS, @@ -10,6 +10,7 @@ import { filterModelSummaryRows, formatModelSummaryLocalTime, formatModelSummaryTemp, + hasModelSummaryForecastData, type ModelSummaryRow, } from "@/lib/model-summary"; @@ -132,6 +133,43 @@ function ModelSummaryRowView({ ); } +function mergeWithLastGoodSummaryRows( + incomingRows: ModelSummaryRow[], + lastGoodRows: ModelSummaryRow[], +) { + if (!lastGoodRows.length) return incomingRows; + if (!incomingRows.length) return lastGoodRows; + + const incomingCityKeys = new Set(); + const lastGoodByCity = new Map(lastGoodRows.map((row) => [row.cityKey, row])); + const mergedRows = incomingRows.map((incomingRow) => { + incomingCityKeys.add(incomingRow.cityKey); + const lastGoodRow = lastGoodByCity.get(incomingRow.cityKey); + if (!lastGoodRow) return incomingRow; + + return { + ...lastGoodRow, + cityName: incomingRow.cityName, + regionLabel: incomingRow.regionLabel, + regionLabelZh: incomingRow.regionLabelZh, + regionSort: incomingRow.regionSort, + tempSymbol: incomingRow.tempSymbol || lastGoodRow.tempSymbol, + localTime: incomingRow.localTime || lastGoodRow.localTime, + timezoneOffsetSeconds: + incomingRow.timezoneOffsetSeconds ?? lastGoodRow.timezoneOffsetSeconds, + searchText: incomingRow.searchText || lastGoodRow.searchText, + }; + }); + + return [ + ...mergedRows, + ...lastGoodRows.filter((row) => !incomingCityKeys.has(row.cityKey)), + ].sort((a, b) => { + if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort; + return a.cityName.localeCompare(b.cityName, "en", { sensitivity: "base" }); + }); +} + export function ModelSummaryDashboard({ rows, isEn, @@ -141,6 +179,7 @@ export function ModelSummaryDashboard({ const [debOnly, setDebOnly] = useState(false); const [wideSpreadOnly, setWideSpreadOnly] = useState(false); const [nowMs, setNowMs] = useState(null); + const lastGoodSummaryRowsRef = useRef([]); useEffect(() => { const syncClock = () => setNowMs(Date.now()); @@ -149,7 +188,22 @@ export function ModelSummaryDashboard({ return () => window.clearInterval(timer); }, []); - const summaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]); + const incomingSummaryRows = useMemo(() => buildModelSummaryRows(rows, isEn), [rows, isEn]); + const incomingHasForecastData = hasModelSummaryForecastData(incomingSummaryRows); + const summaryRows = useMemo( + () => + incomingHasForecastData + ? incomingSummaryRows + : mergeWithLastGoodSummaryRows(incomingSummaryRows, lastGoodSummaryRowsRef.current), + [incomingSummaryRows, incomingHasForecastData], + ); + + useEffect(() => { + if (incomingHasForecastData) { + lastGoodSummaryRowsRef.current = incomingSummaryRows; + } + }, [incomingSummaryRows, incomingHasForecastData]); + const visibleRows = useMemo( () => filterModelSummaryRows(summaryRows, { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts index b75d3414..720e398a 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts @@ -6,6 +6,7 @@ import { filterModelSummaryRows, formatModelSummaryLocalTime, formatModelSummaryTemp, + hasModelSummaryForecastData, } from "@/lib/model-summary"; function assert(condition: unknown, message: string) { @@ -113,6 +114,24 @@ export function runTests() { 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"); + assert(hasModelSummaryForecastData(summaryRows), "model summary should recognize populated forecast rows"); + assert( + !hasModelSummaryForecastData( + buildModelSummaryRows([ + { + id: "fallback:beijing", + city: "beijing", + city_display_name: "Beijing", + trading_region_label: "East Asia", + trading_region_label_zh: "东亚", + trading_region_sort: 1, + local_time: "21:11", + tz_offset_seconds: 28800, + }, + ] as any, false), + ), + "model summary should recognize fallback-only rows without DEB or model forecasts", + ); const searched = filterModelSummaryRows(summaryRows, { debOnly: true, @@ -155,6 +174,8 @@ export function runTests() { ); assert( modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") && + modelSummarySource.includes("lastGoodSummaryRowsRef") && + modelSummarySource.includes("hasModelSummaryForecastData") && modelSummarySource.includes("Local Time") && modelSummarySource.includes("当地时间") && !modelSummarySource.includes("Current High") && diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts index a55a14a4..330df1aa 100644 --- a/frontend/lib/model-summary.ts +++ b/frontend/lib/model-summary.ts @@ -196,3 +196,10 @@ export function filterModelSummaryRows( return true; }); } + +export function hasModelSummaryForecastData(rows: ModelSummaryRow[]) { + return rows.some((row) => { + if (row.debPrediction != null) return true; + return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null); + }); +}