Fix model summary local time and regions

This commit is contained in:
2569718930@qq.com
2026-06-29 19:30:35 +08:00
parent b8d4970f9a
commit 709a567525
5 changed files with 84 additions and 33 deletions
@@ -27,10 +27,9 @@ const SUMMARY_TEXT = {
search: { en: "Search city or model", zh: "搜索城市或模型" }, search: { en: "Search city or model", zh: "搜索城市或模型" },
city: { en: "City", zh: "城市" }, city: { en: "City", zh: "城市" },
region: { en: "Region", zh: "区域" }, region: { en: "Region", zh: "区域" },
currentHigh: { en: "Current High", zh: "当前最高" }, localTime: { en: "Local Time", zh: "当地时间" },
median: { en: "Median", zh: "模型中位数" }, median: { en: "Median", zh: "模型中位数" },
spread: { en: "Spread", zh: "分歧范围" }, spread: { en: "Spread", zh: "分歧范围" },
updated: { en: "Updated", zh: "更新时间" },
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" }, empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
generated: { en: "Generated", zh: "生成" }, generated: { en: "Generated", zh: "生成" },
total: { en: "rows", zh: "行" }, total: { en: "rows", zh: "行" },
@@ -109,8 +108,8 @@ function ModelSummaryRowView({
<td className="min-w-[150px] px-3 py-2 text-xs font-semibold text-slate-600"> <td className="min-w-[150px] px-3 py-2 text-xs font-semibold text-slate-600">
<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"> <td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
<TemperatureCell value={row.currentHigh} symbol={row.tempSymbol} /> {row.localTime || "—"}
</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" />
@@ -126,9 +125,6 @@ function ModelSummaryRowView({
<td className="min-w-[100px] px-3 py-2 text-right"> <td className="min-w-[100px] px-3 py-2 text-right">
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} /> <TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
</td> </td>
<td className="min-w-[130px] px-3 py-2 text-right font-mono text-[11px] font-semibold text-slate-500">
{row.updatedAt || "—"}
</td>
</tr> </tr>
); );
} }
@@ -198,14 +194,14 @@ export function ModelSummaryDashboard({
</header> </header>
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
<table className="w-full min-w-[1580px] border-collapse text-xs"> <table className="w-full min-w-[1460px] border-collapse text-xs">
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]"> <thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
<tr> <tr>
<th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left"> <th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left">
{copy("city", isEn)} {copy("city", isEn)}
</th> </th>
<th className="min-w-[150px] px-3 py-2 text-left">{copy("region", isEn)}</th> <th className="min-w-[150px] px-3 py-2 text-left">{copy("region", isEn)}</th>
<th className="min-w-[96px] px-3 py-2 text-right">{copy("currentHigh", isEn)}</th> <th className="min-w-[96px] px-3 py-2 text-right">{copy("localTime", isEn)}</th>
<th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th> <th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th>
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
<th key={column.key} className="min-w-[92px] px-3 py-2 text-right"> <th key={column.key} className="min-w-[92px] px-3 py-2 text-right">
@@ -216,7 +212,6 @@ export function ModelSummaryDashboard({
{copy("median", isEn)} {copy("median", isEn)}
</th> </th>
<th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th> <th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th>
<th className="min-w-[130px] px-3 py-2 text-right">{copy("updated", isEn)}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 bg-white"> <tbody className="divide-y divide-slate-100 bg-white">
@@ -24,20 +24,20 @@ const UPDATE_ANNOUNCEMENT_SEEN_KEY = "polyweather_update_announcement_seen_v1";
const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [ const STATIC_UPDATE_ANNOUNCEMENTS: StaticUpdateAnnouncement[] = [
{ {
id: "model-summary-table-2026-06-29", id: "model-summary-table-local-time-2026-06-29",
publishedAt: "2026-06-29T19:20:00+08:00", publishedAt: "2026-06-29T19:35:00+08:00",
expiresAt: "2026-08-15T00:00:00+08:00", expiresAt: "2026-08-15T00:00:00+08:00",
zh: { zh: {
title: "更新公告:模型汇总表上线", title: "更新公告:模型汇总表上线",
body: 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 与多模型之间的偏离。\n\n" +
"页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。", "页面支持搜索城市或模型,并提供「仅 DEB」和「分歧较大」筛选。移动端可横向滚动,首列城市固定。刷新终端后即可使用。",
}, },
en: { en: {
title: "Update: Model Summary table is live", title: "Update: Model Summary table is live",
body: 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" + "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.", "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.",
}, },
@@ -50,6 +50,21 @@ export function runTests() {
GFS: 38.2, 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; ] 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);
@@ -60,14 +75,17 @@ 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 === 2, "model summary should keep one row per city"); assert(summaryRows.length === 3, "model summary should keep one row per city");
assert(summaryRows[0].cityName === "Madrid", "model summary should sort by region then city name"); assert(summaryRows[0].cityName === "Amsterdam", "model summary should sort by resolved region then city name");
assert(summaryRows[1].cityName === "Paris", "model summary should sort by region then city name"); assert(summaryRows[1].cityName === "Madrid", "model summary should sort by resolved region then city name");
assert(summaryRows[1].debPrediction === 31.6, "model summary should preserve DEB prediction"); assert(summaryRows[2].cityName === "Paris", "model summary should sort by resolved region then city name");
assert(summaryRows[1].models.GFS === 33.4, "model summary should preserve model high temperature"); assert(summaryRows[0].regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities");
assert(summaryRows[1].models.HRRR === null, "missing models should be normalized to null"); assert(summaryRows[0].localTime === "12:11", "model summary should expose local time in place of current high");
assert(summaryRows[1].modelMedian === 32.1, "model median should use available model values only"); assert(summaryRows[2].debPrediction === 31.6, "model summary should preserve DEB prediction");
assert(summaryRows[1].modelSpread === 2.5, "model spread should use available model min/max only"); 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(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");
@@ -112,6 +130,10 @@ export function runTests() {
); );
assert( assert(
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") && modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
modelSummarySource.includes("Local Time") &&
modelSummarySource.includes("当地时间") &&
!modelSummarySource.includes("Current High") &&
!modelSummarySource.includes("当前最高") &&
modelSummarySource.includes("Only DEB") && modelSummarySource.includes("Only DEB") &&
modelSummarySource.includes("仅 DEB") && modelSummarySource.includes("仅 DEB") &&
modelSummarySource.includes("Large spread") && modelSummarySource.includes("Large spread") &&
@@ -72,6 +72,7 @@ export function runTests() {
assert( assert(
componentSource.includes("模型汇总表上线") && componentSource.includes("模型汇总表上线") &&
componentSource.includes("模型汇总") && componentSource.includes("模型汇总") &&
componentSource.includes("当地时间") &&
componentSource.includes("DEB") && componentSource.includes("DEB") &&
componentSource.includes("ECMWF") && componentSource.includes("ECMWF") &&
componentSource.includes("ECMWF AIFS") && componentSource.includes("ECMWF AIFS") &&
@@ -90,6 +91,7 @@ export function runTests() {
assert( assert(
componentSource.includes("Model Summary table is live") && componentSource.includes("Model Summary table is live") &&
componentSource.includes("city-by-city table") && componentSource.includes("city-by-city table") &&
componentSource.includes("local time") &&
componentSource.includes("DEB") && componentSource.includes("DEB") &&
componentSource.includes("ECMWF") && componentSource.includes("ECMWF") &&
componentSource.includes("ECMWF AIFS") && componentSource.includes("ECMWF AIFS") &&
+43 -11
View File
@@ -1,4 +1,8 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
REGIONS,
getCityRegion,
} from "@/components/dashboard/scan-terminal/continent-grouping";
export const MODEL_SUMMARY_MODEL_COLUMNS = [ export const MODEL_SUMMARY_MODEL_COLUMNS = [
{ key: "ECMWF", label: "ECMWF" }, { key: "ECMWF", label: "ECMWF" },
@@ -23,12 +27,11 @@ export type ModelSummaryRow = {
regionLabelZh: string; regionLabelZh: string;
regionSort: number; regionSort: number;
tempSymbol: string; tempSymbol: string;
currentHigh: number | null; localTime: string;
debPrediction: number | null; debPrediction: number | null;
models: Record<ModelSummaryColumnKey, number | null>; models: Record<ModelSummaryColumnKey, number | null>;
modelMedian: number | null; modelMedian: number | null;
modelSpread: number | null; modelSpread: number | null;
updatedAt: string;
searchText: string; searchText: string;
}; };
@@ -69,6 +72,38 @@ function normalizeCityKey(row: ScanOpportunityRow, index: number) {
return String(rawKey).trim().toLowerCase(); 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") { export function formatModelSummaryTemp(value: number | null | undefined, symbol = "°C") {
const numericValue = finiteNumber(value); const numericValue = finiteNumber(value);
if (numericValue == null) return "—"; if (numericValue == null) return "—";
@@ -86,9 +121,7 @@ export function buildModelSummaryRows(
if (byCity.has(cityKey)) return; if (byCity.has(cityKey)) return;
const cityName = row.city_display_name || row.display_name || row.city || "—"; const cityName = row.city_display_name || row.display_name || row.city || "—";
const regionLabel = row.trading_region_label || row.trading_region_label_zh || "—"; const region = resolveRegion(row, isEn);
const regionLabelZh = row.trading_region_label_zh || row.trading_region_label || "—";
const displayRegionLabel = isEn ? regionLabel : regionLabelZh;
const rawModelSources = row.model_cluster_sources || {}; const rawModelSources = row.model_cluster_sources || {};
const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce( const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce(
(acc, column) => { (acc, column) => {
@@ -109,17 +142,16 @@ export function buildModelSummaryRows(
byCity.set(cityKey, { byCity.set(cityKey, {
cityKey, cityKey,
cityName, cityName,
regionLabel: displayRegionLabel, regionLabel: region.label,
regionLabelZh, regionLabelZh: region.labelZh,
regionSort: finiteNumber(row.trading_region_sort) ?? 999, regionSort: region.sort,
tempSymbol: row.temp_symbol || "°C", tempSymbol: row.temp_symbol || "°C",
currentHigh: finiteNumber(row.current_max_so_far), localTime: normalizeLocalTime(row.local_time),
debPrediction: finiteNumber(row.deb_prediction), debPrediction: finiteNumber(row.deb_prediction),
models, models,
modelMedian: median(modelValues), modelMedian: median(modelValues),
modelSpread: spread(modelValues), modelSpread: spread(modelValues),
updatedAt: row.local_time || row.local_date || row.selected_date || "", searchText: `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText}`.toLowerCase(),
searchText: `${cityName} ${row.city || ""} ${regionLabel} ${regionLabelZh} ${modelSearchText}`.toLowerCase(),
}); });
}); });