Make freshness and timing drive city-card trust

Users need to know whether a city can be acted on before reading the full explanation. City cards now expose METAR or official-observation freshness, model update timing, market quote freshness, and AI state in a dedicated trust block, while the calendar view groups rows into action-oriented timing buckets with a single reason per city.

Constraint: Keep the existing card and calendar data contracts; derive freshness and action reasons from fields already present in the frontend payload.
Rejected: Add another long explanatory paragraph | it would repeat the same trust problem instead of making the first glance clearer.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm run build
Not-tested: Live browser visual QA with stale METAR and delayed quote examples.
This commit is contained in:
2569718930@qq.com
2026-04-28 07:33:46 +08:00
parent 1f364dfa4a
commit 326bfe258e
4 changed files with 315 additions and 45 deletions
+2
View File
@@ -5,6 +5,8 @@
- 新增 Qingdao / 青岛城市,结算锚点接入 Wunderground 青岛胶东国际机场 `ZSQD` 历史页,并补齐别名、时区、预热、官方来源和前端地区归类
- 城市决策卡顶部状态标签收口为 2-3 个高优先级信号,优先展示“实测突破 / 峰值窗口已过 / METAR 过旧 / AI 解读中 / 市场价格缺失 / 模型高度一致 / 需要等待下一报文”,让用户第一眼看到重点
- 城市决策卡 AI 机场报文区明确拆分“快速判断已完成,AI 正在补充机场报文细节… / AI 机场报文解读已完成 / AI 解读未完整返回,当前使用规则证据”三种状态,减少 fallback 与流式返回造成的误解
- 城市决策卡新增“数据新鲜度”区块,分别展示 METAR/官方观测、模型、市场价格和 AI 状态;过旧观测会标明“仅作背景参考”
- 日历视图升级为行动视图,按“现在可看 / 1-3 小时内 / 今天稍后 / 已过峰值,等待确认”分组,并为每个城市显示一句核心原因
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
@@ -11377,21 +11377,64 @@
}
.root :global(.scan-ai-city-freshness) {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 8px;
color: #8fa4bd;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
max-width: 620px;
margin-top: 10px;
color: #9fb2c7;
font-size: 11px;
font-weight: 760;
}
.root :global(.scan-ai-city-freshness strong) {
grid-column: 1 / -1;
color: #d8e7f8;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.root :global(.scan-ai-city-freshness span) {
display: inline-flex;
align-items: center;
gap: 2px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.08);
padding: 4px 7px;
}
.root :global(.scan-ai-city-freshness b) {
color: #d6e2f0;
font-style: normal;
font-weight: 900;
}
.root :global(.scan-ai-city-freshness em) {
color: #9fb2c7;
font-style: normal;
}
.root :global(.scan-ai-city-freshness span.fresh em),
.root :global(.scan-ai-city-freshness span.green em) {
color: #86efac;
}
.root :global(.scan-ai-city-freshness span.loading em),
.root :global(.scan-ai-city-freshness span.blue em) {
color: #9ecbff;
}
.root :global(.scan-ai-city-freshness span.stale em),
.root :global(.scan-ai-city-freshness span.amber em) {
color: #fcd34d;
}
.root :global(.scan-ai-city-freshness span.red em) {
color: #fca5a5;
}
.root :global(.scan-ai-city-hero-side) {
min-width: 180px;
display: grid;
@@ -12104,6 +12147,14 @@
font-weight: 800;
}
.root :global(.scan-calendar-reason) {
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
font-weight: 760;
line-height: 1.45;
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -13469,6 +13520,10 @@
color: #122033;
}
.root :global(.scan-terminal.light .scan-calendar-reason) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-calendar-countdown) {
border-color: rgba(10, 160, 100, 0.18);
background: rgba(220, 252, 239, 0.68);
@@ -115,7 +115,29 @@ function formatFreshnessAge(value: unknown, isEn: boolean) {
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
}
function buildObservationFreshnessLabel({
function formatUpdateTime(value: unknown, locale: string) {
const epochMs = parseEpochMs(value);
if (epochMs == null) return "";
const date = new Date(epochMs);
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
if (locale === "en-US") {
const day = sameDay
? "today"
: date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return `${day} ${time} updated`;
}
const day = sameDay
? "今日"
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
return `${day} ${time} 更新`;
}
function buildObservationFreshnessValue({
detail,
displayTime,
isEn,
@@ -126,20 +148,50 @@ function buildObservationFreshnessLabel({
isEn: boolean;
isHkoObservation: boolean;
}) {
const source = isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR";
const stale = Boolean(
detail?.metar_status?.stale_for_today ||
detail?.airport_current?.stale_for_today ||
detail?.current?.observation_status === "stale",
);
if (stale) return isEn ? `${source} stale` : `${source} 过旧`;
if (stale) {
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
}
const ageLabel = formatFreshnessAge(
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
isEn,
);
if (ageLabel) return `${source} ${ageLabel}`;
if (displayTime) return `${source} ${displayTime}`;
return isEn ? `${source} time pending` : `${source} 时间待确认`;
if (ageLabel) return ageLabel;
if (displayTime) return displayTime;
return isEn ? "time pending" : "时间待确认";
}
function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) {
return (
formatUpdateTime(detail?.updated_at, locale) ||
(isEn ? "latest run loaded" : "已加载最新模型")
);
}
function buildMarketFreshnessValue({
isEn,
marketScan,
marketStatus,
}: {
isEn: boolean;
marketScan: ReturnType<typeof useCityMarketScan>["marketScan"];
marketStatus: ReturnType<typeof useCityMarketScan>["marketStatus"];
}) {
if (marketStatus === "loading") return isEn ? "syncing" : "同步中";
if (!marketScan?.available) return isEn ? "missing" : "缺失";
const quoteAgeMs = Number(
marketScan.quote_age_ms ??
marketScan.yes_token?.quote_age_ms ??
marketScan.no_token?.quote_age_ms,
);
if (Number.isFinite(quoteAgeMs) && quoteAgeMs >= 0) {
return formatFreshnessAge(quoteAgeMs / 60_000, isEn);
}
return isEn ? "synced" : "已同步";
}
function uniqueStatusTags(tags: Array<StatusTag | null | undefined>) {
@@ -362,12 +414,6 @@ function AiPinnedCityCard({
!observedLowBreak &&
!peakHasPassed &&
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
const observationFreshnessLabel = buildObservationFreshnessLabel({
detail,
displayTime: metarReportTimeDisplay,
isEn,
isHkoObservation,
});
const aiRuleEvidenceMode = Boolean(
aiForecast.status === "failed" ||
(aiForecast.status === "ready" && !aiCityForecast) ||
@@ -421,24 +467,40 @@ function AiPinnedCityCard({
: aiRuleEvidenceMode
? "amber"
: "muted";
const marketFreshnessLabel =
marketDecisionView.status === "ready"
? isEn
? "Market synced"
: "市场已同步"
: marketDecisionView.status === "loading"
? isEn
? "Market loading"
: "市场同步中"
: isEn
? "No market price"
: "暂无市场价";
const marketStatusTone: StatusTagTone =
marketDecisionView.status === "ready"
? "green"
: marketDecisionView.status === "loading"
? "blue"
: "muted";
const dataFreshnessRows = [
{
label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR",
value: buildObservationFreshnessValue({
detail,
displayTime: metarReportTimeDisplay,
isEn,
isHkoObservation,
}),
tone: observationStale ? "stale" : "fresh",
},
{
label: isEn ? "Models" : "模型",
value: buildModelFreshnessValue(detail, locale, isEn),
tone: "fresh",
},
{
label: isEn ? "Market" : "市场价格",
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
tone:
marketDecisionView.status === "ready"
? "fresh"
: marketDecisionView.status === "loading"
? "loading"
: "stale",
},
];
const freshnessSeparator = isEn ? ": " : "";
const statusTags = uniqueStatusTags([
observedHighBreak
? {
@@ -544,10 +606,18 @@ function AiPinnedCityCard({
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
</div>
<div className="scan-ai-city-freshness">
<span>{observationFreshnessLabel}</span>
<span>{marketFreshnessLabel}</span>
<span>{aiStatusLabel}</span>
<div className="scan-ai-city-freshness" aria-label={isEn ? "Data freshness" : "数据新鲜度"}>
<strong>{isEn ? "Data freshness" : "数据新鲜度"}</strong>
{dataFreshnessRows.map((freshness) => (
<span key={freshness.label} className={freshness.tone}>
<b>{freshness.label}{freshnessSeparator}</b>
<em>{freshness.value}</em>
</span>
))}
<span className={aiStatusTone}>
<b>AI{freshnessSeparator}</b>
<em>{aiStatusLabel}</em>
</span>
</div>
</div>
<div className="scan-ai-city-hero-side">
@@ -3,7 +3,10 @@ import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
import {
formatShortDate,
getPeakCountdownMeta,
} from "@/components/dashboard/scan-terminal/decision-utils";
const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60;
const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60;
@@ -16,6 +19,13 @@ type CalendarMeta = ReturnType<typeof getPeakCountdownMeta> & {
endAtMs?: number | null;
};
type CalendarActionGroup = {
key: "now" | "next" | "later" | "past";
label: string;
subtitle: string;
sort: number;
};
function normalizeCalendarCityKey(value?: string | null) {
return String(value || "")
.trim()
@@ -154,6 +164,133 @@ function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs
return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES;
}
function getCalendarActionGroup(
row: ScanOpportunityRow,
meta: CalendarMeta,
nowMs: number,
locale: string,
): CalendarActionGroup {
const isEn = locale === "en-US";
const phase = String(row.window_phase || "").toLowerCase();
const startDelta =
meta.startAtMs != null ? (meta.startAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_start);
const endDelta =
meta.endAtMs != null ? (meta.endAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_end);
const isPast =
phase === "post_peak" ||
(endDelta != null && endDelta < 0) ||
meta.key === "past";
if (isPast) {
return {
key: "past",
label: isEn ? "Past peak · confirm" : "已过峰值,等待确认",
subtitle: isEn ? "Check whether a new high printed; avoid chasing if it did not." : "确认是否刷新高点;若无新高,避免追高。",
sort: 3,
};
}
const isNow =
phase === "active_peak" ||
(startDelta != null && startDelta <= 60) ||
meta.key === "active";
if (isNow) {
return {
key: "now",
label: isEn ? "Watch now" : "现在可看",
subtitle: isEn ? "Peak window is live or close enough to require immediate checks." : "峰值窗口正在进行或即将开始,需要马上核对。",
sort: 0,
};
}
if (startDelta != null && startDelta <= 180) {
return {
key: "next",
label: isEn ? "In 1-3 hours" : "1-3 小时内",
subtitle: isEn ? "Prepare the setup and wait for the next observation." : "提前准备,只等下一轮观测确认。",
sort: 1,
};
}
return {
key: "later",
label: isEn ? "Later today" : "今天稍后",
subtitle: isEn ? "Keep on the board, but do not spend attention yet." : "先放在行动面板,不需要立刻盯盘。",
sort: 2,
};
}
function getCalendarModelUpper(row: ScanOpportunityRow) {
const values = [
finiteCalendarNumber(row.cluster_core_high),
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
].filter((value): value is number => value != null);
return values.length ? Math.max(...values) : null;
}
function getCalendarModelLower(row: ScanOpportunityRow) {
const values = [
finiteCalendarNumber(row.cluster_core_low),
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
].filter((value): value is number => value != null);
return values.length ? Math.min(...values) : null;
}
function firstSentence(value?: string | null) {
const text = String(value || "").trim();
if (!text) return "";
const match = text.match(/^.*?[。.!?](?:\s|$)/);
return (match?.[0] || text).trim();
}
function buildCalendarCoreReason(
row: ScanOpportunityRow,
group: CalendarActionGroup,
locale: string,
) {
const isEn = locale === "en-US";
const tempSymbol = row.temp_symbol || "°C";
const currentTemp = finiteCalendarNumber(row.current_temp ?? row.metar_context?.last_temp);
const modelUpper = getCalendarModelUpper(row);
const modelLower = getCalendarModelLower(row);
const modelSpread =
modelUpper != null && modelLower != null ? modelUpper - modelLower : null;
if (currentTemp != null && modelUpper != null && currentTemp > modelUpper + 0.2) {
return isEn
? `Observed ${formatTemperatureValue(currentTemp, tempSymbol)} is above the model upper bound; watch whether the high keeps revising up.`
: `实测已高于模型上沿,需关注是否继续上修`;
}
if (group.key === "past") {
return isEn
? "Peak window has passed; avoid chasing if no new high prints."
: "峰值窗口已过,若无新高应避免追高";
}
if (row.metar_status?.stale_for_today || row.metar_context?.stale_for_today) {
return isEn
? "METAR is stale, so use it as background only."
: "METAR 已过旧,仅作背景参考";
}
if (currentTemp != null && modelLower != null && currentTemp < modelLower - 0.5) {
return isEn
? "Observed temperature is still below the model core; wait for the next report."
: "实测仍低于模型核心区,等待下一报文确认";
}
if (Number(row.cluster_model_count || 0) >= 4 && modelSpread != null && modelSpread <= 2) {
return isEn
? "Models are tightly aligned; the next observation should decide direction."
: "模型高度一致,等待下一报文确认方向";
}
const aiReason = firstSentence(
isEn
? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en || row.ai_reason_en || row.ai_city_thesis_en
: row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh || row.ai_reason_zh || row.ai_city_thesis_zh,
);
if (aiReason) return aiReason;
return group.key === "now"
? isEn
? "Peak timing is close; open the card and verify live evidence."
: "峰值时间接近,打开卡片核对实况证据"
: isEn
? "Keep it on the action board until the next observation."
: "先放入行动面板,等待下一轮观测";
}
export function CalendarView({
rows,
locale,
@@ -181,31 +318,38 @@ export function CalendarView({
}, []);
const groups = useMemo(() => {
const order = ["active", "next", "today", "later", "past"];
const byPhase = new Map<
string,
{
label: string;
subtitle: string;
sort: number;
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta }>;
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta; reason: string }>;
}
>();
dedupeCalendarRows(rows).forEach((row) => {
const meta = buildCalendarMeta(row, locale, snapshotMs, nowMs);
if (!isCalendarActionable(row, meta, nowMs)) return;
const current = byPhase.get(meta.key) || {
label: meta.groupLabel,
sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length,
const actionGroup = getCalendarActionGroup(row, meta, nowMs, locale);
const current = byPhase.get(actionGroup.key) || {
label: actionGroup.label,
subtitle: actionGroup.subtitle,
sort: actionGroup.sort,
items: [],
};
current.items.push({ row, meta });
byPhase.set(meta.key, current);
current.items.push({
row,
meta,
reason: buildCalendarCoreReason(row, actionGroup, locale),
});
byPhase.set(actionGroup.key, current);
});
return Array.from(byPhase.entries())
.sort(([, left], [, right]) => left.sort - right.sort)
.map(([key, group]) => ({
key,
label: group.label,
subtitle: group.subtitle,
items: group.items.sort((left, right) => {
if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort;
return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0);
@@ -233,9 +377,7 @@ export function CalendarView({
<div>
<div className="scan-calendar-date">{group.label}</div>
<div className="scan-calendar-subtitle">
{locale === "en-US"
? "Ordered by DEB peak-window countdown"
: "按 DEB 峰值窗口倒计时排序"}
{group.subtitle}
</div>
</div>
<div className="scan-calendar-count">
@@ -243,7 +385,7 @@ export function CalendarView({
</div>
</div>
<div className="scan-calendar-grid">
{group.items.map(({ row, meta }) => (
{group.items.map(({ row, meta, reason }) => (
<button
key={row.id}
type="button"
@@ -275,6 +417,7 @@ export function CalendarView({
{meta.cityWindowLabel || meta.detail}
</small>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-action">
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>