Fix model summary local time and regions
This commit is contained in:
@@ -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.",
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-8
@@ -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") &&
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user