移除日历视图:功能与决策卡重叠,维护成本高于价值

- 删除 CalendarView.tsx、calendar-action-utils.ts 及其测试文件
- 删除 ScanTerminalCalendar.module.css
- ScanTerminalDashboard:移除日历 tab 按钮、日历渲染分支、ContentView 类型中的 calendar
- ScanTerminalLightTheme:清理所有 .scan-calendar-* 规则(~40 行)
- scan-root-styles.ts:移除 ScanTerminalCalendar 导入

同时完善地理排序:
- scan_terminal_filters.py:market_region_from_tz_offset 细化为 7 个区域(东亚/东南亚/中亚/西亚/欧洲非洲/南美/北美)并增加 sort_order
- scan_terminal_city_row.py:传递 trading_region_sort
- dashboard-types.ts:增加 trading_region_sort 字段
- decision-utils.ts:sortRowsByUserTime 按 trading_region_sort 优先排序

Rejected: keep-calendar-view, calendar-actions-overlap-with-decision-cards
Tested: npx tsc --noEmit, python -m ruff check .
This commit is contained in:
2569718930@qq.com
2026-05-10 15:48:18 +08:00
parent 5ff9fade5f
commit 9635387beb
11 changed files with 28 additions and 1054 deletions
@@ -1,241 +0,0 @@
import { memo, useEffect, useMemo, useState } from "react";
import { getWindowPhaseMeta } from "@/components/dashboard/opportunity-window-phase";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import {
buildCalendarCoreReason,
buildCalendarMeta,
dedupeCalendarRows,
getCalendarActionGroup,
isCalendarActionable,
type CalendarActionItem,
} from "@/components/dashboard/scan-terminal/calendar-action-utils";
function formatUserClock(now: Date) {
const h = String(now.getHours()).padStart(2, "0");
const m = String(now.getMinutes()).padStart(2, "0");
const s = String(now.getSeconds()).padStart(2, "0");
return `${h}:${m}:${s}`;
}
function formatUserFullDate(now: Date, locale: string) {
return now.toLocaleDateString(locale === "en-US" ? "en-US" : "zh-CN", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
const CalendarActionCard = memo(function CalendarActionCard({
item,
locale,
selected,
onSelectRow,
}: {
item: CalendarActionItem;
locale: string;
selected: boolean;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const { row, meta, reason } = item;
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
const isEn = locale === "en-US";
return (
<button
type="button"
className={`scan-calendar-card peak-${meta.tone} ${selected ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
<div className="scan-calendar-card-top">
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<span className={`scan-calendar-badge tone-${meta.tone}`}>
{meta.tone === "active"
? isEn ? "NOW" : "进行中"
: meta.tone === "upcoming"
? isEn ? "SOON" : "即将"
: meta.tone === "later"
? isEn ? "LATER" : "稍后"
: isEn ? "PAST" : "已过"}
</span>
</div>
<div className="scan-calendar-user-time">
<span className="scan-calendar-user-label">
{isEn ? "Your time" : "本地时间"}
</span>
<strong>{meta.localWindowLabel || meta.detail}</strong>
</div>
<div className="scan-calendar-countdown">
<span className={`scan-calendar-countdown-timer tone-${meta.tone}`}>
{meta.title}
</span>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-card-foot">
<div className="scan-calendar-deb">
<span>{isEn ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-phase">
<span>{phaseMeta.label}</span>
</div>
</div>
{meta.cityWindowLabel && meta.cityWindowLabel !== meta.detail ? (
<div className="scan-calendar-city-time">
{isEn ? "City window: " : "城市窗口:"}
{meta.cityWindowLabel}
</div>
) : null}
</button>
);
});
export const CalendarView = memo(function CalendarView({
rows,
locale,
selectedRowId,
onSelectRow,
}: {
rows: ScanOpportunityRow[];
locale: string;
selectedRowId: string | null;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const [snapshotMs, setSnapshotMs] = useState(() => Date.now());
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
setSnapshotMs(Date.now());
setNowMs(Date.now());
}, [rows]);
useEffect(() => {
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
}, 30_000);
return () => window.clearInterval(intervalId);
}, []);
const groups = useMemo(() => {
const byPhase = new Map<
string,
{
label: string;
subtitle: string;
sort: number;
items: CalendarActionItem[];
}
>();
dedupeCalendarRows(rows).forEach((row) => {
const meta = buildCalendarMeta(row, locale, snapshotMs, nowMs);
if (!isCalendarActionable(row, meta, nowMs)) return;
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,
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);
}),
}));
}, [locale, nowMs, rows, snapshotMs]);
const now = new Date(nowMs);
const isEn = locale === "en-US";
if (!groups.length) {
return (
<div className="scan-calendar-empty">
<div className="scan-calendar-empty-title">
{isEn
? "No actionable windows in the next 12 hours"
: "未来 12 小时内没有可行动日历窗口"}
</div>
<div className="scan-calendar-empty-sub">
{isEn
? "Check back after the next observation cycle"
: "等待下一轮观测后再查看"}
</div>
</div>
);
}
return (
<div className="scan-calendar-view">
<div className="scan-calendar-header">
<div className="scan-calendar-clock">
<span className="scan-calendar-clock-time">
{formatUserClock(now)}
</span>
<span className="scan-calendar-clock-date">
{formatUserFullDate(now, locale)}
</span>
</div>
<div className="scan-calendar-clock-meta">
{isEn ? "Your local time · Auto-refreshes" : "您所在时区 · 自动刷新"}
</div>
</div>
<div className="scan-calendar-groups">
{groups.map((group) => (
<section key={group.key} className="scan-calendar-group">
<div className="scan-calendar-group-head">
<div>
<div className="scan-calendar-group-label">{group.label}</div>
<div className="scan-calendar-group-sub">{group.subtitle}</div>
</div>
<div className="scan-calendar-group-count">
{group.items.length}
</div>
</div>
<div className="scan-calendar-grid">
{group.items.map((item) => (
<CalendarActionCard
key={item.row.id}
item={item}
locale={locale}
selected={selectedRowId === item.row.id}
onSelectRow={onSelectRow}
/>
))}
</div>
</section>
))}
</div>
</div>
);
});
@@ -1,81 +0,0 @@
import assert from "node:assert/strict";
import {
buildCalendarCoreReason,
buildCalendarMeta,
getCalendarActionGroup,
} from "@/components/dashboard/scan-terminal/calendar-action-utils";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
function row(overrides: Partial<ScanOpportunityRow>): ScanOpportunityRow {
return {
city: "London",
city_display_name: "London",
current_temp: 21,
deb_prediction: 24,
id: "london-2026-04-27",
local_date: "2026-04-27",
local_time: "4/27·00:01",
selected_date: "2026-04-27",
temp_symbol: "°C",
...overrides,
} as ScanOpportunityRow;
}
export function runTests() {
const snapshotMs = Date.UTC(2026, 3, 27, 16, 1);
const sameNow = snapshotMs;
const localTimeCase = row({
minutes_until_peak_end: 119,
minutes_until_peak_start: 0,
window_phase: "active_peak",
});
const meta = buildCalendarMeta(localTimeCase, "zh-CN", snapshotMs, sameNow);
assert.equal(meta.startAtMs, snapshotMs);
assert.ok(meta.localWindowLabel, "should expose the user's local peak window");
assert.notEqual(meta.localWindowLabel, localTimeCase.local_time);
assert.doesNotMatch(meta.localWindowLabel || "", /4\/27·00:01/);
const activeGroup = getCalendarActionGroup(localTimeCase, meta, sameNow, "zh-CN");
assert.equal(activeGroup.key, "now");
const breakoutReason = buildCalendarCoreReason(
row({
cluster_core_high: 24.7,
cluster_core_low: 23.1,
cluster_model_count: 6,
current_temp: 25.2,
model_cluster_sources: {
ecmwf: 24.4,
gfs: 24.7,
},
}),
activeGroup,
"zh-CN",
);
assert.match(breakoutReason, /实测已高于模型上沿/);
assert.doesNotMatch(breakoutReason, /等待下一报文确认方向/);
const pastMeta = buildCalendarMeta(
row({
minutes_until_peak_end: -20,
minutes_until_peak_start: -120,
window_phase: "post_peak",
}),
"zh-CN",
snapshotMs,
sameNow,
);
const pastGroup = getCalendarActionGroup(
row({ minutes_until_peak_end: -20, minutes_until_peak_start: -120, window_phase: "post_peak" }),
pastMeta,
sameNow,
"zh-CN",
);
assert.equal(pastGroup.key, "past");
assert.match(
buildCalendarCoreReason(row({}), pastGroup, "zh-CN"),
/峰值窗口已过,若无新高应避免追高/,
);
}
@@ -1,299 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import {
formatShortDate,
getPeakCountdownMeta,
} from "@/components/dashboard/scan-terminal/decision-utils";
export const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60;
export const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60;
const MINUTE_MS = 60_000;
export type CalendarMeta = ReturnType<typeof getPeakCountdownMeta> & {
localWindowLabel?: string | null;
cityWindowLabel?: string | null;
startAtMs?: number | null;
endAtMs?: number | null;
};
export type CalendarActionGroup = {
key: "now" | "next" | "later" | "past";
label: string;
subtitle: string;
sort: number;
};
export type CalendarActionItem = {
row: ScanOpportunityRow;
meta: CalendarMeta;
reason: string;
};
function normalizeCalendarCityKey(value?: string | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
function getCalendarCardKey(row: ScanOpportunityRow) {
const city =
normalizeCalendarCityKey(row.city) ||
normalizeCalendarCityKey(row.city_display_name) ||
normalizeCalendarCityKey(row.display_name);
const date = String(row.selected_date || row.local_date || "").trim();
return `${city || row.id}:${date || "date-unknown"}`;
}
function getCalendarRowScore(row: ScanOpportunityRow) {
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
}
export function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
const bestByCard = new Map<string, ScanOpportunityRow>();
rows.forEach((row) => {
const key = getCalendarCardKey(row);
const current = bestByCard.get(key);
if (!current || getCalendarRowScore(row) > getCalendarRowScore(current)) {
bestByCard.set(key, row);
}
});
return [...bestByCard.values()];
}
export function finiteCalendarNumber(value?: number | null) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function formatUserLocalDate(value: Date, locale: string) {
return value.toLocaleDateString(locale === "en-US" ? "en-US" : "zh-CN", {
month: "short",
day: "numeric",
});
}
function formatUserLocalTime(value: Date, locale: string) {
return value.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
export function formatUserLocalWindow(
startAtMs: number,
endAtMs: number,
locale: string,
) {
const start = new Date(startAtMs);
const end = new Date(endAtMs);
const startDate = formatUserLocalDate(start, locale);
const endDate = formatUserLocalDate(end, locale);
const startTime = formatUserLocalTime(start, locale);
const endTime = formatUserLocalTime(end, locale);
if (start.toDateString() === end.toDateString()) {
return `${startDate} ${startTime}-${endTime}`;
}
return `${startDate} ${startTime}${endDate} ${endTime}`;
}
export function buildCalendarMeta(
row: ScanOpportunityRow,
locale: string,
snapshotMs: number,
nowMs: number,
): CalendarMeta {
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
if (startDelta === null || endDelta === null) {
const fallback = getPeakCountdownMeta(row, locale);
return {
...fallback,
cityWindowLabel: fallback.detail,
localWindowLabel: null,
startAtMs: null,
endAtMs: null,
};
}
const startAtMs = snapshotMs + startDelta * MINUTE_MS;
const endAtMs = snapshotMs + endDelta * MINUTE_MS;
const liveStartDelta = (startAtMs - nowMs) / MINUTE_MS;
const liveEndDelta = (endAtMs - nowMs) / MINUTE_MS;
const meta = getPeakCountdownMeta(
{
...row,
window_phase: null,
minutes_until_peak_start: liveStartDelta,
minutes_until_peak_end: liveEndDelta,
},
locale,
);
return {
...meta,
cityWindowLabel: meta.detail,
localWindowLabel: formatUserLocalWindow(startAtMs, endAtMs, locale),
startAtMs,
endAtMs,
};
}
export function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs: number) {
if (meta.startAtMs !== null && meta.startAtMs !== undefined) {
const endAtMs = meta.endAtMs ?? meta.startAtMs;
return (
meta.startAtMs <= nowMs + CALENDAR_UPCOMING_HORIZON_MINUTES * MINUTE_MS &&
endAtMs >= nowMs - CALENDAR_POST_PEAK_GRACE_MINUTES * MINUTE_MS
);
}
const phase = String(row.window_phase || "").toLowerCase();
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
if (phase === "active_peak" || (startDelta !== null && startDelta <= 0 && endDelta !== null && endDelta >= 0)) {
return true;
}
if (phase === "post_peak") {
return endDelta === null || endDelta >= -CALENDAR_POST_PEAK_GRACE_MINUTES;
}
if (startDelta === null) {
return phase === "setup_today";
}
return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES;
}
export 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();
}
export 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 formatCalendarCardShortDate(row: ScanOpportunityRow, locale: string) {
return formatShortDate(row.selected_date || row.local_date, locale);
}
@@ -151,6 +151,10 @@ export function getPhaseUrgency(row: ScanOpportunityRow) {
export function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
const focus = getMarketFocus(rows);
return [...rows].sort((left, right) => {
const rl = Number(left.trading_region_sort) || 0;
const rr = Number(right.trading_region_sort) || 0;
if (rl !== rr) return rl - rr;
if (focus) {
const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;