移除未使用的 Groq 和 Meteoblue 服务代码及配置

This commit is contained in:
2569718930@qq.com
2026-05-19 00:05:07 +08:00
parent 19bd8f3636
commit b93a75516d
28 changed files with 215 additions and 1417 deletions
+42
View File
@@ -1 +1,43 @@
# PolyWeather 前端最小配置(本地 / Vercel)
# 只部署天气看板时,先填下面 4 项即可。
# 必填:后端 FastAPI 基础地址
# 默认供 Next.js API Route 在服务端代理后端使用。
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
# 可选:浏览器直连后端 FastAPI 基础地址。
# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
# 例如:https://api.example.com
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
# 必填:Supabase 前端公钥(鉴权开启时必须)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 常用:前端鉴权开关
# true: 启用 Supabase 登录
# false: 关闭登录能力,访客模式
POLYWEATHER_AUTH_ENABLED=false
# 常用:是否强制登录
# true: middleware 强制登录后才能访问主页面
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 可选:前端 API Route 转发到后端时附带的共享令牌
# 仅当后端启用了 entitlement / 订阅校验时需要
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# 可选:钱包支付 / Telegram 入口
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app
POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com
NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot
+2 -6
View File
@@ -21,8 +21,8 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF结算来源和历史对账
- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF结算来源
- 今日日内分析支持:
- `锚点状态`
- `当前节奏`
@@ -31,9 +31,6 @@ PolyWeather Pro 的生产前端工程。
- `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动
- 历史对账支持:
- `DEB / 最佳单模型 / 实测最高温` 对比
- 峰值前 12 小时 `DEB` 参考(近似)
- `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
@@ -117,7 +114,6 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
- `GET /api/city/[name]`
- `GET /api/city/[name]/summary`
- `GET /api/city/[name]/detail`
- `GET /api/history/[name]`
鉴权:
-27
View File
@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyBackendJsonGet } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
const response = NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
return response;
}
const { name } = await context.params;
const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`;
return proxyBackendJsonGet(req, {
cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300",
publicMessage: "Failed to fetch history",
revalidateSeconds: 60,
url,
});
}
@@ -1,163 +0,0 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getHistorySummary } from "@/lib/dashboard-utils";
export function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isNoaaSettlement
? locale === "en-US"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.1,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.1,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.1,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.1,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${store.selectedDetail?.temp_symbol || "°C"}`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
+1 -3
View File
@@ -31,9 +31,7 @@ export function MapCanvas({
},
selectedCity: store.selectedCity,
selectedDetail: store.selectedDetail,
suspendMotion:
Boolean(store.futureModalDate) ||
store.historyState.isOpen,
suspendMotion: Boolean(store.futureModalDate),
isLoadingDetail: store.loadingState.cityDetail,
});
@@ -343,7 +343,6 @@ function getRoundedModelVoteDistribution(
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
+1 -6
View File
@@ -12,12 +12,7 @@ export const DOCS_GROUPS: DocsNavGroup[] = [
{
id: "settlement",
title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" },
},
{
id: "history",
title: { "zh-CN": "历史对账", "en-US": "History & Reconciliation" },
},
];
},];
export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) {
return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId;
+6 -82
View File
@@ -22,7 +22,7 @@ export interface DocsPageContent {
export interface DocsPageMeta {
slug: string;
group: "getting-started" | "analysis" | "settlement" | "history";
group: "getting-started" | "analysis" | "settlement";
}
export interface DocsPage extends DocsPageMeta {
@@ -55,7 +55,7 @@ export const DOCS_PAGES: DocsPage[] = [
id: "core-modules",
title: "你会在页面上看到什么",
blocks: [
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。", "历史对账:查看已结算样本、DEB MAE、单模型表现和新增模型参考。"] },
{ type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] },
],
},
{
@@ -83,7 +83,7 @@ export const DOCS_PAGES: DocsPage[] = [
id: "core-modules",
title: "What you see on the site",
blocks: [
{ type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it.", "History reconciliation: settled-sample MAE, single-model performance, and the new model reference stack."] },
{ type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it."] },
],
},
{
@@ -465,82 +465,6 @@ export const DOCS_PAGES: DocsPage[] = [
},
},
},
{
slug: "history-reconciliation",
group: "history",
content: {
"zh-CN": {
title: "历史对账",
description: "历史对账用于看已结算样本,不用于把当天未结算的行情硬算进胜率。",
sections: [
{
id: "settled-only",
title: "为什么只看已结算样本",
blocks: [
{ type: "paragraph", text: "网页上的历史对账只统计已结算样本。当天还在交易中的市场,不会被提前算进 DEB 命中率或 MAE。这样做的目的,是避免用还没兑现的结果污染历史准确率。" },
],
},
{
id: "rolling-window",
title: "近 15 天滚动视图",
blocks: [
{ type: "paragraph", text: "网页默认展示近 15 天滚动视图,方便比较最近这轮模型状态,而不是用过长的旧样本稀释当前表现。" },
],
},
{
id: "peak-minus-12h",
title: "峰值前 12 小时 DEB 参考",
blocks: [
{ type: "paragraph", text: "这项指标用来回答一个更具体的问题:在真正出现高温之前 12 小时,DEB 当时大概有多准。它不是额外结算规则,而是一个用来观察模型是否过慢修正的参考视角。" },
{ type: "callout", tone: "info", title: "近似值说明", text: "当前峰值时间是根据历史快照链路反推的近似时间,不是逐分钟官方复盘。页面会明确标记为“参考 / 近似”。" },
],
},
{
id: "model-reference",
title: "新增模型参考",
blocks: [
{ type: "paragraph", text: "历史对账会保留 DEB、最佳单模型和实测最高温对比,同时加入当前模型栈的参考信息。新增模型用于解释当时模型家族分歧,不会 retroactively 改写已经结算的历史真值。" },
],
},
],
},
"en-US": {
title: "History Reconciliation",
description: "History reconciliation is for settled samples only. It is not meant to leak same-day unsettled outcomes into historical hit-rate or MAE.",
sections: [
{
id: "settled-only",
title: "Why only settled samples count",
blocks: [
{ type: "paragraph", text: "The history panel only counts settled samples. Markets still trading on the same day are excluded from DEB hit-rate and MAE so unfinished outcomes do not contaminate the historical record." },
],
},
{
id: "rolling-window",
title: "Rolling 15-day view",
blocks: [
{ type: "paragraph", text: "The web dashboard defaults to a rolling 15-day view so the panel reflects current model behavior rather than being overly diluted by older regimes." },
],
},
{
id: "peak-minus-12h",
title: "DEB at peak minus 12 hours",
blocks: [
{ type: "paragraph", text: "This field answers a more specific question: how good was DEB roughly 12 hours before the eventual high actually printed? It is not a settlement rule, but a way to judge whether the model corrected too slowly." },
{ type: "callout", tone: "info", title: "Approximation note", text: "The current peak time is inferred from the snapshot chain and should be treated as an approximate reference rather than a minute-perfect official replay." },
],
},
{
id: "model-reference",
title: "New model reference",
blocks: [
{ type: "paragraph", text: "History reconciliation keeps the DEB, best single model, and observed high comparison, while adding the current model-stack reference. New model lines explain family spread at the time; they do not rewrite already settled truth records." },
],
},
],
},
},
},
{
slug: "extension",
group: "getting-started",
@@ -557,7 +481,7 @@ export const DOCS_PAGES: DocsPage[] = [
type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "打开 Chrome Web Store",
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析与历史对账。",
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析。",
},
],
},
@@ -597,7 +521,7 @@ export const DOCS_PAGES: DocsPage[] = [
blocks: [
{
type: "paragraph",
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断、历史对账和完整交易语境仍以主站为准。",
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断和完整交易语境仍以主站为准。",
},
{
type: "callout",
@@ -631,7 +555,7 @@ export const DOCS_PAGES: DocsPage[] = [
type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "Open Chrome Web Store",
caption: "Once installed, the side panel can route users back into the main intraday analysis and history views.",
caption: "Once installed, the side panel can route users back into the main intraday analysis.",
},
],
},
+5 -203
View File
@@ -28,10 +28,6 @@ import {
CitySummary,
DashboardState,
ForecastModalMode,
HistoryPoint,
HistoryPayload,
HistoryPayloadMeta,
HistoryState,
LoadingState,
ProAccessState,
} from "@/lib/dashboard-types";
@@ -39,7 +35,6 @@ import {
interface DashboardStoreValue extends DashboardState {
clearCityFocus: () => void;
closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (
cityName: string,
@@ -61,7 +56,6 @@ interface DashboardStoreValue extends DashboardState {
loadCities: () => Promise<void>;
preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
@@ -84,10 +78,6 @@ type DashboardModalContextValue = Pick<
| "selectedForecastDate"
| "setForecastDate"
>;
type DashboardHistoryContextValue = Pick<
DashboardStoreValue,
"closeHistory" | "historyState" | "openHistory"
>;
type DashboardProAccessContextValue = Pick<
DashboardStoreValue,
"proAccess" | "refreshProAccess"
@@ -100,8 +90,6 @@ const DashboardActionsContext = createContext<Pick<
> | null>(null);
const DashboardModalContext =
createContext<DashboardModalContextValue | null>(null);
const DashboardHistoryContext =
createContext<DashboardHistoryContextValue | null>(null);
const DashboardProAccessContext =
createContext<DashboardProAccessContextValue | null>(null);
const DashboardSelectionContext = createContext<Pick<
@@ -126,24 +114,11 @@ function getInitialLoadingState(): LoadingState {
cities: false,
cityDetail: false,
futureDeep: false,
history: false,
historyRecords: false,
refresh: false,
marketScan: false,
};
}
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
metaByCity: {},
recordsLoading: false,
};
}
function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) {
return getLocalDevProAccessState();
@@ -666,20 +641,6 @@ function mergeMarketScan(
};
}
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
const history = Array.isArray(payload.history) ? payload.history : [];
const previewCount = Number(payload.preview_count || history.length || 0);
const fullCount = Number(payload.full_count || previewCount || 0);
return {
mode: payload.mode === "full" ? "full" : "preview",
hasMore: payload.has_more === true,
fullCount,
previewCount,
settlementSource: payload.settlement_source ?? null,
settlementSourceLabel: payload.settlement_source_label ?? null,
};
}
export function DashboardStoreProvider({
children,
}: {
@@ -710,9 +671,6 @@ export function DashboardStoreProvider({
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [proAccess, setProAccess] = useState<ProAccessState>(
getInitialProAccessState,
);
@@ -1427,127 +1385,12 @@ export function DashboardStoreProvider({
}
};
const openHistory = async () => {
if (!selectedCity) return;
if (!proAccess.subscriptionActive) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
recordsLoading: false,
}));
return;
}
const cityName = selectedCity;
const cachedHistory = historyState.dataByCity[cityName];
const cachedMeta = historyState.metaByCity[cityName];
if (cachedMeta && cachedHistory?.length) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
recordsLoading: cachedMeta.mode !== "full" && cachedMeta.hasMore,
}));
if (cachedMeta.mode !== "full" && cachedMeta.hasMore) {
void dashboardClient
.getHistory(cityName, { includeRecords: true })
.then((payload) => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: payload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(payload),
},
recordsLoading: false,
}));
})
.catch(() => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
recordsLoading: false,
}));
});
}
return;
}
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: true,
recordsLoading: false,
}));
try {
const payload = await dashboardClient.getHistory(cityName);
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: payload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(payload),
},
loading: false,
recordsLoading: payload.has_more === true,
}));
if (payload.has_more) {
void dashboardClient
.getHistory(cityName, { includeRecords: true })
.then((fullPayload) => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: fullPayload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(fullPayload),
},
recordsLoading: false,
}));
})
.catch(() => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
recordsLoading: false,
}));
});
}
} catch (error) {
setHistoryState((current) => ({
...current,
error: String(error),
loading: false,
recordsLoading: false,
}));
}
};
const closeFutureModal = () => {
modalOpenSeqRef.current += 1;
setFutureModalDate(null);
setForecastModalMode(null);
};
const closeHistory = () =>
setHistoryState((current) => ({ ...current, isOpen: false }));
const openFutureModal = async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
@@ -1679,7 +1522,6 @@ export function DashboardStoreProvider({
citySummariesByName,
clearCityFocus,
closeFutureModal,
closeHistory,
closePanel: () => {
setIsPanelOpen(false);
},
@@ -1688,14 +1530,12 @@ export function DashboardStoreProvider({
focusCity,
forecastModalMode,
futureModalDate,
historyState,
isPanelOpen,
loadCities,
preloadCityFromRow,
loadingState,
proAccess,
openFutureModal,
openHistory,
openTodayModal,
registerMapStopMotion: (stopMotion: () => void) => {
mapStopMotionRef.current = stopMotion;
@@ -1716,7 +1556,6 @@ export function DashboardStoreProvider({
citySummariesByName,
forecastModalMode,
futureModalDate,
historyState,
isPanelOpen,
loadingState,
proAccess,
@@ -1784,14 +1623,6 @@ export function DashboardStoreProvider({
setForecastDate,
],
);
const dashboardHistoryValue = useMemo<DashboardHistoryContextValue>(
() => ({
closeHistory,
historyState,
openHistory,
}),
[closeHistory, historyState, openHistory],
);
const dashboardProAccessValue = useMemo<DashboardProAccessContextValue>(
() => ({
proAccess,
@@ -1805,13 +1636,11 @@ export function DashboardStoreProvider({
<DashboardActionsContext.Provider value={dashboardActionsValue}>
<DashboardProAccessContext.Provider value={dashboardProAccessValue}>
<DashboardModalContext.Provider value={dashboardModalValue}>
<DashboardHistoryContext.Provider value={dashboardHistoryValue}>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardHistoryContext.Provider>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardModalContext.Provider>
</DashboardProAccessContext.Provider>
</DashboardActionsContext.Provider>
@@ -1859,16 +1688,6 @@ export function useDashboardModal() {
return context;
}
export function useDashboardHistory() {
const context = useContext(DashboardHistoryContext);
if (!context) {
throw new Error(
"useDashboardHistory must be used within DashboardStoreProvider",
);
}
return context;
}
export function useProAccess() {
const context = useContext(DashboardProAccessContext);
if (!context) {
@@ -1899,20 +1718,3 @@ export function useCityData(name?: string | null) {
selection.selectedCity === key,
};
}
export function useHistoryData(name?: string | null) {
const history = useDashboardHistory();
const selection = useDashboardSelection();
const key = name || selection.selectedCity;
return {
data: key
? history.historyState.dataByCity[key] || ([] as HistoryPoint[])
: [],
error: history.historyState.error,
isLoading: history.historyState.loading,
isOpen: history.historyState.isOpen,
isRecordsLoading: history.historyState.recordsLoading,
meta: key ? history.historyState.metaByCity[key] || null : null,
};
}
-39
View File
@@ -4,7 +4,6 @@ import {
CityDetail,
CityListItem,
CitySummary,
HistoryPayload,
MarketScan,
ScanTerminalFilters,
ScanTerminalResponse,
@@ -20,7 +19,6 @@ const CACHE_TTL_MS = 30 * 60 * 1000;
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const pendingCityMarketScanRequests = new Map<
string,
@@ -452,43 +450,6 @@ export const dashboardClient = {
return request;
},
async getHistory(cityName: string, options?: { includeRecords?: boolean }) {
const includeRecords = options?.includeRecords === true;
const requestKey = `${normalizeCityName(cityName)}::${
includeRecords ? "full" : "preview"
}`;
const existing = pendingHistoryRequests.get(requestKey);
if (existing) {
return existing;
}
const params = new URLSearchParams();
if (includeRecords) {
params.set("include_records", "true");
}
const request = fetchJson<HistoryPayload>(
`/api/history/${normalizeCityName(cityName)}${
params.size ? `?${params.toString()}` : ""
}`,
)
.then((data) => ({
...data,
full_count: Number(data.full_count || 0),
has_more: data.has_more === true,
history: Array.isArray(data.history) ? data.history : [],
mode: (data.mode === "full" ? "full" : "preview") as
| "full"
| "preview",
preview_count: Number(data.preview_count || 0),
}))
.finally(() => {
pendingHistoryRequests.delete(requestKey);
});
pendingHistoryRequests.set(requestKey, request);
return request;
},
isCityDetailFresh(meta?: CityCacheMeta | null) {
return isFresh(meta);
+2 -68
View File
@@ -976,78 +976,13 @@ export interface AmosData {
observation_time_local?: string | null;
}
export interface HistoryPoint {
date: string;
actual: number | null;
deb: number | null;
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
model_reference?: {
available?: boolean;
truth_layer?: string | null;
reference_layer?: string | null;
deb?: {
value?: number | null;
error?: number | null;
};
models?: Array<{
model?: string | null;
value?: number | null;
error?: number | null;
participates_in_deb?: boolean;
}>;
model_count?: number | null;
};
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
truth_version?: string | null;
updated_by?: string | null;
truth_updated_at?: number | null;
actual_peak_time?: string | null;
deb_at_peak_minus_12h?: number | null;
deb_at_peak_minus_12h_time?: string | null;
deb_at_peak_minus_12h_error?: number | null;
}
export interface HistoryPayloadMeta {
mode: "preview" | "full";
hasMore: boolean;
fullCount: number;
previewCount: number;
settlementSource?: string | null;
settlementSourceLabel?: string | null;
}
export interface HistoryPayload {
history: HistoryPoint[];
has_more?: boolean;
full_count?: number;
preview_count?: number;
mode?: "preview" | "full";
settlement_source?: string | null;
settlement_source_label?: string | null;
}
export interface LoadingState {
cities: boolean;
cityDetail: boolean;
refresh: boolean;
history: boolean;
marketScan?: boolean;
futureDeep?: boolean;
historyRecords?: boolean;
}
refresh: boolean; marketScan?: boolean;
futureDeep?: boolean;}
export interface HistoryState {
isOpen: boolean;
loading: boolean;
recordsLoading: boolean;
error: string | null;
dataByCity: Record<string, HistoryPoint[]>;
metaByCity: Record<string, HistoryPayloadMeta>;
}
export interface ProAccessState {
loading: boolean;
@@ -1073,6 +1008,5 @@ export interface DashboardState {
selectedForecastDate: string | null;
forecastModalMode: ForecastModalMode | null;
loadingState: LoadingState;
historyState: HistoryState;
proAccess: ProAccessState;
}
-137
View File
@@ -2,7 +2,6 @@ import { Locale } from "@/lib/i18n";
import {
AiAnalysisStructured,
CityDetail,
HistoryPoint,
NearbyStation,
} from "@/lib/dashboard-types";
import {
@@ -1715,142 +1714,6 @@ export function getShortTermNowcastLines(
return rows;
}
export function getHistorySummary(
history: HistoryPoint[],
cityLocalDate?: string | null,
) {
const toFinite = (value: unknown): number | null => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const isExcludedModel = (name: string) =>
String(name || "").toLowerCase().includes("meteoblue");
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() - 14);
const recentData = history.filter((row) => {
if (!row?.date) return false;
const rowDate = new Date(`${row.date}T00:00:00`);
return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
});
const settledData = recentData.filter((row) => {
if (!row?.date) return false;
return cityLocalDate
? row.date < cityLocalDate
: row.date < new Date().toISOString().slice(0, 10);
});
const comparableSettledData = settledData.filter((row) => {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
return actual != null && deb != null;
});
let hits = 0;
const debErrors: number[] = [];
const modelErrors: Record<string, number[]> = {};
comparableSettledData.forEach((row) => {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
if (actual == null || deb == null) return;
debErrors.push(Math.abs(actual - deb));
if (wuRound(actual) === wuRound(deb)) {
hits += 1;
}
const forecasts = row.forecasts || {};
Object.entries(forecasts).forEach(([modelName, modelValue]) => {
if (isExcludedModel(modelName)) return;
const mv = toFinite(modelValue);
if (actual == null || mv == null) return;
if (!modelErrors[modelName]) {
modelErrors[modelName] = [];
}
modelErrors[modelName].push(Math.abs(actual - mv));
});
});
const modelMaeList = Object.entries(modelErrors)
.map(([name, errors]) => ({
mae:
errors.length > 0
? errors.reduce((sum, value) => sum + value, 0) / errors.length
: Number.POSITIVE_INFINITY,
model: name,
sampleCount: errors.length,
}))
.filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0)
.sort((a, b) => a.mae - b.mae);
const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2);
const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null;
const bestModelName = bestModel?.model || null;
const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null;
const bestModelSeries = recentData.map((row) =>
bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null,
);
let debWinDaysVsBest = 0;
let debVsBestComparableDays = 0;
if (bestModelName) {
comparableSettledData.forEach((row) => {
const actual = toFinite(row.actual);
const deb = toFinite(row.deb);
const bestModelVal = toFinite(row.forecasts?.[bestModelName]);
if (actual == null || deb == null || bestModelVal == null) return;
debVsBestComparableDays += 1;
if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) {
debWinDaysVsBest += 1;
}
});
}
const mgmSettledCount = settledData.reduce((count, row) => {
return toFinite(row.mgm) != null ? count + 1 : count;
}, 0);
const mgmSeriesComplete =
settledData.length >= 2 && mgmSettledCount === settledData.length;
const mgmSeries = mgmSeriesComplete
? recentData.map((row) => row.mgm ?? null)
: recentData.map(() => null);
return {
dates: recentData.map((row) => row.date),
debMae: debErrors.length
? Number(
(
debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length
).toFixed(1),
)
: null,
debs: recentData.map((row) => row.deb),
bestModelName,
bestModelMae,
bestModelSeries,
modelMaeRanks: modelMaeList.map((row) => ({
model: row.model,
mae: Number(row.mae.toFixed(1)),
sampleCount: row.sampleCount,
})),
debWinDaysVsBest,
debVsBestComparableDays,
debWinRateVsBest:
debVsBestComparableDays > 0
? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0))
: null,
hitRate: debErrors.length
? Number(((hits / debErrors.length) * 100).toFixed(0))
: null,
mgmSeriesComplete,
mgms: mgmSeries,
recentData,
settledCount: comparableSettledData.length,
actuals: recentData.map((row) => row.actual),
};
}
function toFiniteNumber(value: unknown): number | null {
const numeric = Number(value);
-34
View File
@@ -44,7 +44,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市",
"detail.todayAnalysis": "今日日内分析",
"detail.history": "历史对账",
"detail.loading": "正在加载城市详情...",
"detail.emptyHint": "从左侧城市列表选择一个城市查看详情。",
"detail.sceneryAlt": "{city} 风景照",
@@ -72,22 +71,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.footer":
"数据源以 METAR、香港天文台(HKO)、NOAA 指定站点、Turkish MGM、Open-Meteo、weather.gov 为主。",
"history.title": "📊 历史准确率对账 - {city}",
"history.closeAria": "关闭历史对账",
"history.loading": "正在获取历史数据...",
"history.error": "获取历史信息失败",
"history.empty": "近 15 天暂无该城市历史数据",
"history.previewTitle": "历史准确率对账",
"history.previewDesc": "对比 DEB 预报与实际结算温度,查看命中率、MAE 和模型对比。升级 Pro 即可解锁。",
"history.hitRate": "DEB 结算胜率 (METAR)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB 结算胜率 (METAR)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "最佳单模型 MAE",
"history.debVsBest": "DEB 优于最佳模型",
"history.sample": "近 15 天已结算样本",
"history.sampleDays": "{count} 天",
"future.todayTitle": "{city} · 今日日内分析",
"future.dateTitle": "{city} · {date} 未来日期分析",
@@ -242,7 +225,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"detail.closeAria": "Close city detail panel",
"detail.waitSelect": "Waiting for city selection",
"detail.todayAnalysis": "Today's Intraday",
"detail.history": "History Reconciliation",
"detail.loading": "Loading city details...",
"detail.emptyHint": "Select a city from the left list to view details.",
"detail.sceneryAlt": "{city} scenery",
@@ -271,22 +253,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.footer":
"Primary data sources are METAR, Hong Kong Observatory (HKO), designated NOAA stations, Turkish MGM, Open-Meteo, and weather.gov.",
"history.title": "📊 Historical Reconciliation - {city}",
"history.closeAria": "Close history reconciliation",
"history.loading": "Loading historical data...",
"history.error": "Failed to load historical data",
"history.empty": "No historical records for this city in the last 15 days",
"history.previewTitle": "Historical Reconciliation",
"history.previewDesc": "Compare DEB forecasts to actual settlement temperatures with hit rate, MAE, and model comparison. Unlock Pro to access.",
"history.hitRate": "DEB Settlement Hit Rate (METAR)",
"history.mae": "DEB MAE",
"history.debHitRate": "DEB Settlement Hit Rate (METAR)",
"history.debMae": "DEB MAE",
"history.muMae": "μ MAE",
"history.bestModelMae": "Best Single-model MAE",
"history.debVsBest": "DEB vs Best Model",
"history.sample": "Settled Samples (Last 15 Days)",
"history.sampleDays": "{count} days",
"future.todayTitle": "{city} · Intraday Analysis",
"future.dateTitle": "{city} · {date} Future-date Analysis",
-1
View File
@@ -190,7 +190,6 @@ export const config = {
"/api/ops/:path*",
"/api/payments/:path*",
"/api/system/:path*",
"/api/history/:path*",
"/api/city/:path*/detail:path*",
"/api/scan/terminal/ai:path*",
],