Keep model summary data during refresh gaps
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Search, Table2 } from "lucide-react";
|
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 type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import {
|
import {
|
||||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
formatModelSummaryLocalTime,
|
formatModelSummaryLocalTime,
|
||||||
formatModelSummaryTemp,
|
formatModelSummaryTemp,
|
||||||
|
hasModelSummaryForecastData,
|
||||||
type ModelSummaryRow,
|
type ModelSummaryRow,
|
||||||
} from "@/lib/model-summary";
|
} 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<string>();
|
||||||
|
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({
|
export function ModelSummaryDashboard({
|
||||||
rows,
|
rows,
|
||||||
isEn,
|
isEn,
|
||||||
@@ -141,6 +179,7 @@ export function ModelSummaryDashboard({
|
|||||||
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);
|
const [nowMs, setNowMs] = useState<number | null>(null);
|
||||||
|
const lastGoodSummaryRowsRef = useRef<ModelSummaryRow[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncClock = () => setNowMs(Date.now());
|
const syncClock = () => setNowMs(Date.now());
|
||||||
@@ -149,7 +188,22 @@ export function ModelSummaryDashboard({
|
|||||||
return () => window.clearInterval(timer);
|
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(
|
const visibleRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterModelSummaryRows(summaryRows, {
|
filterModelSummaryRows(summaryRows, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
formatModelSummaryLocalTime,
|
formatModelSummaryLocalTime,
|
||||||
formatModelSummaryTemp,
|
formatModelSummaryTemp,
|
||||||
|
hasModelSummaryForecastData,
|
||||||
} from "@/lib/model-summary";
|
} from "@/lib/model-summary";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
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(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");
|
||||||
|
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, {
|
const searched = filterModelSummaryRows(summaryRows, {
|
||||||
debOnly: true,
|
debOnly: true,
|
||||||
@@ -155,6 +174,8 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
||||||
|
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
||||||
|
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
||||||
modelSummarySource.includes("Local Time") &&
|
modelSummarySource.includes("Local Time") &&
|
||||||
modelSummarySource.includes("当地时间") &&
|
modelSummarySource.includes("当地时间") &&
|
||||||
!modelSummarySource.includes("Current High") &&
|
!modelSummarySource.includes("Current High") &&
|
||||||
|
|||||||
@@ -196,3 +196,10 @@ export function filterModelSummaryRows(
|
|||||||
return true;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user