From 709a567525995704dfbe60ab08c1c579ab822a36 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 29 Jun 2026 19:30:35 +0800 Subject: [PATCH] Fix model summary local time and regions --- .../scan-terminal/ModelSummaryDashboard.tsx | 15 ++---- .../UpdateAnnouncementButton.tsx | 8 +-- .../__tests__/modelSummaryDashboard.test.ts | 38 ++++++++++--- .../__tests__/updateAnnouncement.test.ts | 2 + frontend/lib/model-summary.ts | 54 +++++++++++++++---- 5 files changed, 84 insertions(+), 33 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx index bc210246..780dfb1c 100644 --- a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx @@ -27,10 +27,9 @@ const SUMMARY_TEXT = { search: { en: "Search city or model", zh: "搜索城市或模型" }, city: { en: "City", zh: "城市" }, region: { en: "Region", zh: "区域" }, - currentHigh: { en: "Current High", zh: "当前最高" }, + localTime: { en: "Local Time", zh: "当地时间" }, median: { en: "Median", zh: "模型中位数" }, spread: { en: "Spread", zh: "分歧范围" }, - updated: { en: "Updated", zh: "更新时间" }, empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" }, generated: { en: "Generated", zh: "生成" }, total: { en: "rows", zh: "行" }, @@ -109,8 +108,8 @@ function ModelSummaryRowView({ {row.regionLabel} - - + + {row.localTime || "—"} @@ -126,9 +125,6 @@ function ModelSummaryRowView({ - - {row.updatedAt || "—"} - ); } @@ -198,14 +194,14 @@ export function ModelSummaryDashboard({
- +
- + {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( - diff --git a/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx index 8c604768..0da05863 100644 --- a/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx +++ b/frontend/components/dashboard/scan-terminal/UpdateAnnouncementButton.tsx @@ -24,20 +24,20 @@ const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1"; const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [ { - id: "model-summary-table-2026-06-29", - publishedAt: "2026-06-29T19:20:00+08:00", + id: "model-summary-table-local-time-2026-06-29", + publishedAt: "2026-06-29T19:35:00+08:00", expiresAt: "2026-08-15T00:00:00+08:00", zh: { title: "更新公告:模型汇总表上线", body: - "终端左侧新增「模型汇总」菜单,按今日最高温口径汇总全部城市:当前最高、DEB 预测,以及 ECMWF、ECMWF AIFS、GFS、ICON、ICON-EU、GEM、GDPS、JMA、AROME HD、HRRR、NAM 等模型高温预测集中到一张表里。\n\n" + + "终端左侧新增「模型汇总」菜单,按今日最高温口径汇总全部城市:当地时间、DEB 预测,以及 ECMWF、ECMWF AIFS、GFS、ICON、ICON-EU、GEM、GDPS、JMA、AROME HD、HRRR、NAM 等模型高温预测集中到一张表里。\n\n" + "缺失模型会显示 —;模型中位数和分歧范围只基于已有模型值计算,方便快速发现 DEB 与多模型之间的偏离。\n\n" + "页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。", }, en: { title: "Update: Model Summary table is live", body: - "The left terminal navigation now includes Model Summary, a city-by-city table for today's high-temperature view: current high, DEB forecast, plus ECMWF, ECMWF AIFS, GFS, ICON, ICON-EU, GEM, GDPS, JMA, AROME HD, HRRR, and NAM model highs in one workspace.\n\n" + + "The left terminal navigation now includes Model Summary, a city-by-city table for today's high-temperature view: local time, DEB forecast, plus ECMWF, ECMWF AIFS, GFS, ICON, ICON-EU, GEM, GDPS, JMA, AROME HD, HRRR, and NAM model highs in one workspace.\n\n" + "Missing models render as —. Model median and spread are calculated only from available model values, making it easier to spot where DEB diverges from the model cluster.\n\n" + "The page supports city or model search, plus Only DEB and Large spread filters. Mobile keeps the city column sticky with horizontal table scrolling. Refresh the terminal to use it.", }, diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts index e130143b..809af4bd 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts @@ -50,6 +50,21 @@ export function runTests() { GFS: 38.2, }, }, + { + city: "amsterdam", + city_display_name: "Amsterdam", + trading_region_label: "West Asia / Middle East", + trading_region_label_zh: "西亚 / 中东", + trading_region_sort: 4, + temp_symbol: "°C", + current_max_so_far: 16, + deb_prediction: 17.1, + local_time: "12:11", + model_cluster_sources: { + ECMWF: 15.6, + GFS: 17.2, + }, + }, ] as any; const originalFirstModelSources = rows[0].model_cluster_sources; const summaryRows = buildModelSummaryRows(rows, false); @@ -60,14 +75,17 @@ 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 === 2, "model summary should keep one row per city"); - assert(summaryRows[0].cityName === "Madrid", "model summary should sort by region then city name"); - assert(summaryRows[1].cityName === "Paris", "model summary should sort by region then city name"); - assert(summaryRows[1].debPrediction === 31.6, "model summary should preserve DEB prediction"); - assert(summaryRows[1].models.GFS === 33.4, "model summary should preserve model high temperature"); - assert(summaryRows[1].models.HRRR === null, "missing models should be normalized to null"); - assert(summaryRows[1].modelMedian === 32.1, "model median should use available model values only"); - assert(summaryRows[1].modelSpread === 2.5, "model spread should use available model min/max only"); + 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(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"); @@ -112,6 +130,10 @@ export function runTests() { ); assert( modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") && + modelSummarySource.includes("Local Time") && + modelSummarySource.includes("当地时间") && + !modelSummarySource.includes("Current High") && + !modelSummarySource.includes("当前最高") && modelSummarySource.includes("Only DEB") && modelSummarySource.includes("仅 DEB") && modelSummarySource.includes("Large spread") && diff --git a/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts index 2dc8968b..96597667 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/updateAnnouncement.test.ts @@ -72,6 +72,7 @@ export function runTests() { assert( componentSource.includes("模型汇总表上线") && componentSource.includes("模型汇总") && + componentSource.includes("当地时间") && componentSource.includes("DEB") && componentSource.includes("ECMWF") && componentSource.includes("ECMWF AIFS") && @@ -90,6 +91,7 @@ export function runTests() { assert( componentSource.includes("Model Summary table is live") && componentSource.includes("city-by-city table") && + componentSource.includes("local time") && componentSource.includes("DEB") && componentSource.includes("ECMWF") && componentSource.includes("ECMWF AIFS") && diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts index 0ed995a9..06854dd3 100644 --- a/frontend/lib/model-summary.ts +++ b/frontend/lib/model-summary.ts @@ -1,4 +1,8 @@ import type { ScanOpportunityRow } from "@/lib/dashboard-types"; +import { + REGIONS, + getCityRegion, +} from "@/components/dashboard/scan-terminal/continent-grouping"; export const MODEL_SUMMARY_MODEL_COLUMNS = [ { key: "ECMWF", label: "ECMWF" }, @@ -23,12 +27,11 @@ export type ModelSummaryRow = { regionLabelZh: string; regionSort: number; tempSymbol: string; - currentHigh: number | null; + localTime: string; debPrediction: number | null; models: Record; modelMedian: number | null; modelSpread: number | null; - updatedAt: string; searchText: string; }; @@ -69,6 +72,38 @@ function normalizeCityKey(row: ScanOpportunityRow, index: number) { return String(rawKey).trim().toLowerCase(); } +function normalizeLocalTime(value: unknown) { + const text = String(value || "").trim(); + if (!text) return ""; + const match = text.match(/(\d{1,2}):(\d{2})/); + if (!match) return text; + return `${match[1].padStart(2, "0")}:${match[2]}`; +} + +function resolveRegion(row: ScanOpportunityRow, isEn: boolean) { + const configuredRegionKey = getCityRegion(row); + const configuredRegion = configuredRegionKey + ? REGIONS.find((region) => region.key === configuredRegionKey) + : null; + if (configuredRegion) { + return { + label: isEn ? configuredRegion.labelEn : configuredRegion.labelZh, + labelEn: configuredRegion.labelEn, + labelZh: configuredRegion.labelZh, + sort: configuredRegion.sort, + }; + } + + const labelEn = row.trading_region_label || row.trading_region_label_zh || "—"; + const labelZh = row.trading_region_label_zh || row.trading_region_label || "—"; + return { + label: isEn ? labelEn : labelZh, + labelEn, + labelZh, + sort: finiteNumber(row.trading_region_sort) ?? 999, + }; +} + export function formatModelSummaryTemp(value: number | null | undefined, symbol = "°C") { const numericValue = finiteNumber(value); if (numericValue == null) return "—"; @@ -86,9 +121,7 @@ export function buildModelSummaryRows( if (byCity.has(cityKey)) return; const cityName = row.city_display_name || row.display_name || row.city || "—"; - const regionLabel = row.trading_region_label || row.trading_region_label_zh || "—"; - const regionLabelZh = row.trading_region_label_zh || row.trading_region_label || "—"; - const displayRegionLabel = isEn ? regionLabel : regionLabelZh; + const region = resolveRegion(row, isEn); const rawModelSources = row.model_cluster_sources || {}; const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce( (acc, column) => { @@ -109,17 +142,16 @@ export function buildModelSummaryRows( byCity.set(cityKey, { cityKey, cityName, - regionLabel: displayRegionLabel, - regionLabelZh, - regionSort: finiteNumber(row.trading_region_sort) ?? 999, + regionLabel: region.label, + regionLabelZh: region.labelZh, + regionSort: region.sort, tempSymbol: row.temp_symbol || "°C", - currentHigh: finiteNumber(row.current_max_so_far), + localTime: normalizeLocalTime(row.local_time), debPrediction: finiteNumber(row.deb_prediction), models, modelMedian: median(modelValues), modelSpread: spread(modelValues), - updatedAt: row.local_time || row.local_date || row.selected_date || "", - searchText: `${cityName} ${row.city || ""} ${regionLabel} ${regionLabelZh} ${modelSearchText}`.toLowerCase(), + searchText: `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText}`.toLowerCase(), }); });
{copy("city", isEn)} {copy("region", isEn)}{copy("currentHigh", isEn)}{copy("localTime", isEn)} DEB @@ -216,7 +212,6 @@ export function ModelSummaryDashboard({ {copy("median", isEn)} {copy("spread", isEn)}{copy("updated", isEn)}