From 5e8548999ae984da7d3b436e9f9ae8562eacd6e6 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 15 May 2026 15:02:15 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard UI and state management with supporting tests --- .../dashboard/ScanTerminalDashboard.tsx | 13 ---- .../cityDetailMergeChartCoverage.test.ts | 20 ++++++ .../__tests__/marketOverviewBadge.test.ts | 11 ++++ frontend/hooks/useDashboardStore.tsx | 66 ++++++++++++++++++- 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 16310103..77de813d 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -46,13 +46,6 @@ import { useUserLocalClock, } from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; import { useRelativeTime } from "@/hooks/useRelativeTime"; -const MarketOverviewBanner = dynamic( - () => - import( - "@/components/dashboard/scan-terminal/MarketOverviewBanner" - ).then((m) => m.MarketOverviewBanner), - { ssr: false }, -); const MonitorPanel = dynamic( () => import("@/components/dashboard/monitoring/MonitorPanel"), @@ -453,12 +446,6 @@ function ScanTerminalScreen() { /> ) : null} - -
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts index e4160313..34cfca86 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts @@ -21,4 +21,24 @@ export function runTests() { ), "deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data", ); + assert( + source.includes("function pickRicherObservationSeries"), + "city detail merge should preserve chart observation series when refresh payload is sparse", + ); + assert( + /metar_today_obs:\s*pickRicherObservationSeries\(\s*current\.metar_today_obs,\s*incoming\.metar_today_obs,?\s*\)/.test( + source, + ), + "deep-analysis refresh must not wipe METAR observation points used by the intraday path chart", + ); + assert( + /settlement_today_obs:\s*pickRicherObservationSeries\(\s*current\.settlement_today_obs,\s*incoming\.settlement_today_obs,?\s*\)/.test( + source, + ), + "deep-analysis refresh must not wipe settlement observation points used by the intraday path chart", + ); + assert( + /trend:\s*mergeTrendInfo\(\s*current\.trend,\s*incoming\.trend\s*\)/.test(source), + "deep-analysis refresh must merge trend.recent instead of replacing it with sparse trend payloads", + ); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts index 0888618a..37ebe8fb 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts @@ -7,6 +7,12 @@ function assert(condition: unknown, message: string) { export function runTests() { const projectRoot = process.cwd(); + const dashboardPath = path.join( + projectRoot, + "components", + "dashboard", + "ScanTerminalDashboard.tsx", + ); const bannerPath = path.join( projectRoot, "components", @@ -14,6 +20,7 @@ export function runTests() { "scan-terminal", "MarketOverviewBanner.tsx", ); + const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); const source = fs.readFileSync(bannerPath, "utf8"); assert( @@ -22,4 +29,8 @@ export function runTests() { !source.includes("styles.badge"), "market overview banner should not render the AI overview badge", ); + assert( + !dashboardSource.includes("MarketOverviewBanner"), + "scan terminal dashboard should not mount the AI market overview banner", + ); } diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 5a78bf4f..a454fc2b 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -509,6 +509,61 @@ function pickRicherHourly( return incomingCount >= currentCount ? incomingValue : currentValue; } +function countObservationSeriesPoints( + value: T[] | null | undefined, +) { + return (Array.isArray(value) ? value : []).filter((row) => { + const time = String(row?.time || "").trim(); + const temp = Number(row?.temp); + return Boolean(time) && Number.isFinite(temp); + }).length; +} + +function pickRicherObservationSeries< + T extends { time?: string | null; temp?: number | null }, +>( + currentValue: T[] | null | undefined, + incomingValue: T[] | null | undefined, +): T[] | undefined { + const incomingCount = countObservationSeriesPoints(incomingValue); + const currentCount = countObservationSeriesPoints(currentValue); + if (incomingCount <= 0) return currentValue || undefined; + if (currentCount <= 0) return incomingValue || undefined; + return (incomingCount >= currentCount ? incomingValue : currentValue) || undefined; +} + +function mergeTrendInfo( + currentValue: CityDetail["trend"] | undefined, + incomingValue: CityDetail["trend"] | undefined, +) { + if (!incomingValue) return currentValue; + if (!currentValue) return incomingValue; + return { + ...currentValue, + ...incomingValue, + recent: pickRicherObservationSeries( + currentValue.recent, + incomingValue.recent, + ), + }; +} + +function mergeMgmData( + currentValue: CityDetail["mgm"] | undefined, + incomingValue: CityDetail["mgm"] | undefined, +) { + if (!incomingValue) return currentValue; + if (!currentValue) return incomingValue; + return { + ...currentValue, + ...incomingValue, + hourly: pickRicherObservationSeries( + currentValue.hourly, + incomingValue.hourly, + ), + }; +} + function pickPreferredNearbyStations( currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"], incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"], @@ -546,7 +601,16 @@ function mergeCityDetail( airport_current: incoming.airport_current || current.airport_current, deb: incoming.deb || current.deb, probabilities: incoming.probabilities || current.probabilities, - trend: incoming.trend || current.trend, + trend: mergeTrendInfo(current.trend, incoming.trend), + metar_today_obs: pickRicherObservationSeries( + current.metar_today_obs, + incoming.metar_today_obs, + ), + settlement_today_obs: pickRicherObservationSeries( + current.settlement_today_obs, + incoming.settlement_today_obs, + ), + mgm: mergeMgmData(current.mgm, incoming.mgm), multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model), multi_model_daily: mergeDailyModelMap( current.multi_model_daily,