diff --git a/frontend/components/dashboard/ScanTerminalCalendar.module.css b/frontend/components/dashboard/ScanTerminalCalendar.module.css
deleted file mode 100644
index 744ae7bd..00000000
--- a/frontend/components/dashboard/ScanTerminalCalendar.module.css
+++ /dev/null
@@ -1,310 +0,0 @@
-/* Scan terminal calendar/action view — user timezone oriented. */
-
-/* ── View root ── */
-.root :global(.scan-calendar-view) {
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding-right: 4px;
-}
-
-/* ── User clock header ── */
-.root :global(.scan-calendar-header) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 18px;
- border: 1px solid rgba(77, 163, 255, 0.16);
- border-radius: 16px;
- background: linear-gradient(135deg, rgba(77, 163, 255, 0.06), rgba(34, 197, 94, 0.03));
-}
-
-.root :global(.scan-calendar-clock) {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.root :global(.scan-calendar-clock-time) {
- font-size: 28px;
- font-weight: 800;
- font-variant-numeric: tabular-nums;
- color: var(--color-text-primary);
- letter-spacing: -0.02em;
- line-height: 1.1;
-}
-
-.root :global(.scan-calendar-clock-date) {
- font-size: 13px;
- font-weight: 600;
- color: var(--color-text-secondary);
-}
-
-.root :global(.scan-calendar-clock-meta) {
- font-size: 11px;
- font-weight: 700;
- color: var(--color-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-/* ── Empty state ── */
-.root :global(.scan-calendar-empty) {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 8px;
- padding: 64px 32px;
- text-align: center;
-}
-
-.root :global(.scan-calendar-empty-title) {
- font-size: 18px;
- font-weight: 800;
- color: var(--color-text-primary);
-}
-
-.root :global(.scan-calendar-empty-sub) {
- font-size: 13px;
- color: var(--color-text-secondary);
-}
-
-/* ── Groups ── */
-.root :global(.scan-calendar-groups) {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.root :global(.scan-calendar-group) {
- border: 1px solid rgba(90, 123, 166, 0.12);
- border-radius: 18px;
- background: rgba(8, 17, 30, 0.8);
- overflow: hidden;
-}
-
-.root :global(.scan-calendar-group-head) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(90, 123, 166, 0.1);
-}
-
-.root :global(.scan-calendar-group-label) {
- font-size: 16px;
- font-weight: 800;
- color: var(--color-text-primary);
-}
-
-.root :global(.scan-calendar-group-sub) {
- margin-top: 3px;
- color: var(--color-text-muted);
- font-size: 12px;
- font-weight: 600;
-}
-
-.root :global(.scan-calendar-group-count) {
- min-width: 26px;
- height: 26px;
- display: grid;
- place-items: center;
- border-radius: 999px;
- background: rgba(77, 163, 255, 0.12);
- color: var(--color-accent-secondary);
- font-size: 12px;
- font-weight: 800;
-}
-
-/* ── Card grid ── */
-.root :global(.scan-calendar-grid) {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
- gap: 12px;
- padding: 14px;
-}
-
-/* ── Card ── */
-.root :global(.scan-calendar-card) {
- text-align: left;
- padding: 14px;
- border: 1px solid rgba(90, 123, 166, 0.12);
- border-radius: 16px;
- background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
- cursor: pointer;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-
-.root :global(.scan-calendar-card:hover) {
- border-color: rgba(77, 163, 255, 0.24);
-}
-
-.root :global(.scan-calendar-card.selected) {
- border-color: rgba(77, 163, 255, 0.42);
- box-shadow: inset 0 0 0 1px rgba(77, 163, 255, 0.14);
-}
-
-.root :global(.scan-calendar-card.peak-active) {
- border-color: rgba(34, 197, 94, 0.3);
- background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
-}
-
-.root :global(.scan-calendar-card.peak-next) {
- border-color: rgba(245, 158, 11, 0.24);
-}
-
-.root :global(.scan-calendar-card.peak-past) {
- opacity: 0.72;
-}
-
-/* ── Card top row ── */
-.root :global(.scan-calendar-card-top) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 8px;
- margin-bottom: 10px;
-}
-
-.root :global(.scan-calendar-city) {
- font-size: 15px;
- font-weight: 800;
- color: var(--color-text-primary);
-}
-
-.root :global(.scan-calendar-badge) {
- flex-shrink: 0;
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.root :global(.scan-calendar-badge.tone-active) {
- background: rgba(34, 197, 94, 0.14);
- color: #4ade80;
-}
-
-.root :global(.scan-calendar-badge.tone-upcoming) {
- background: rgba(245, 158, 11, 0.14);
- color: #fbbf24;
-}
-
-.root :global(.scan-calendar-badge.tone-later) {
- background: rgba(77, 163, 255, 0.12);
- color: var(--color-accent-secondary);
-}
-
-.root :global(.scan-calendar-badge.tone-past) {
- background: rgba(148, 163, 184, 0.12);
- color: #94a3b8;
-}
-
-/* ── User local time (primary) ── */
-.root :global(.scan-calendar-user-time) {
- display: flex;
- flex-direction: column;
- gap: 2px;
- margin-bottom: 8px;
- padding: 10px 12px;
- border-radius: 10px;
- background: rgba(77, 163, 255, 0.06);
-}
-
-.root :global(.scan-calendar-user-label) {
- font-size: 10px;
- font-weight: 700;
- color: var(--color-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.root :global(.scan-calendar-user-time strong) {
- font-size: 16px;
- font-weight: 800;
- color: var(--color-text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-/* ── Countdown timer ── */
-.root :global(.scan-calendar-countdown) {
- margin-bottom: 8px;
-}
-
-.root :global(.scan-calendar-countdown-timer) {
- font-size: 14px;
- font-weight: 800;
-}
-
-.root :global(.scan-calendar-countdown-timer.tone-active) {
- color: #4ade80;
-}
-
-.root :global(.scan-calendar-countdown-timer.tone-upcoming) {
- color: #fbbf24;
-}
-
-.root :global(.scan-calendar-countdown-timer.tone-later) {
- color: var(--color-accent-secondary);
-}
-
-.root :global(.scan-calendar-countdown-timer.tone-past) {
- color: #94a3b8;
-}
-
-/* ── Reason text ── */
-.root :global(.scan-calendar-reason) {
- margin: 0 0 10px;
- color: var(--color-text-secondary);
- font-size: 12px;
- font-weight: 600;
- line-height: 1.5;
-}
-
-/* ── Card footer row ── */
-.root :global(.scan-calendar-card-foot) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding-top: 8px;
- border-top: 1px solid rgba(90, 123, 166, 0.08);
-}
-
-.root :global(.scan-calendar-deb) {
- display: flex;
- align-items: baseline;
- gap: 6px;
-}
-
-.root :global(.scan-calendar-deb span) {
- font-size: 11px;
- font-weight: 600;
- color: var(--color-text-muted);
-}
-
-.root :global(.scan-calendar-deb b) {
- font-size: 14px;
- font-weight: 800;
- color: var(--color-text-primary);
-}
-
-.root :global(.scan-calendar-phase) {
- font-size: 11px;
- font-weight: 700;
- color: var(--color-text-muted);
-}
-
-/* ── City window time (secondary, subtle) ── */
-.root :global(.scan-calendar-city-time) {
- margin-top: 8px;
- padding-top: 6px;
- border-top: 1px solid rgba(90, 123, 166, 0.06);
- font-size: 10px;
- font-weight: 600;
- color: var(--color-text-muted);
-}
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 3fb829b7..04849eab 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -30,7 +30,6 @@ import type {
ScanOpportunityRow,
} from "@/lib/dashboard-types";
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
-import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import {
@@ -46,7 +45,7 @@ import {
useUserLocalClock,
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
-type ContentView = "analysis" | "map" | "calendar";
+type ContentView = "analysis" | "map";
const CityDetailPanel = dynamic(
() =>
@@ -235,12 +234,6 @@ function ScanTerminalScreen() {
store.selectedDetail,
]);
- useEffect(() => {
- if (!store.proAccess.loading && !isPro && activeView === "calendar") {
- setActiveView("map");
- }
- }, [activeView, isPro, store.proAccess.loading]);
-
const resolvedView: ContentView = activeView;
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
const activeDetailRow =
@@ -352,16 +345,6 @@ function ScanTerminalScreen() {
);
}
- if (resolvedView === "calendar") {
- return (
-
- );
- }
return null;
};
@@ -507,22 +490,6 @@ function ScanTerminalScreen() {
>
{isEn ? "Decision Cards" : "城市决策卡"}
-
{terminalData?.generated_at ? (
@@ -550,8 +517,8 @@ function ScanTerminalScreen() {
disabled={scanLoading}
title={
isEn
- ? "Force refresh decision cards and calendar"
- : "强制刷新决策卡和日历视图"
+ ? "Force refresh decision cards"
+ : "强制刷新决策卡"
}
>
diff --git a/frontend/components/dashboard/ScanTerminalLightTheme.module.css b/frontend/components/dashboard/ScanTerminalLightTheme.module.css
index f7ce3475..5c2ef36c 100644
--- a/frontend/components/dashboard/ScanTerminalLightTheme.module.css
+++ b/frontend/components/dashboard/ScanTerminalLightTheme.module.css
@@ -30,7 +30,6 @@
.root :global(.scan-terminal.light .scan-detail-volume-big),
.root :global(.scan-terminal.light .scan-kv strong),
.root :global(.scan-terminal.light .scan-time-main),
-.root :global(.scan-terminal.light .scan-calendar-group-label),
.root :global(.scan-terminal.light .scan-summary-value),
.root :global(.scan-terminal.light .scan-distribution-card strong),
.root :global(.scan-terminal.light .scan-table-header),
@@ -141,8 +140,6 @@
.root :global(.scan-terminal.light .scan-mode-tab),
.root :global(.scan-terminal.light .scan-table-shell),
.root :global(.scan-terminal.light .scan-table-header),
-.root :global(.scan-terminal.light .scan-calendar-group),
-.root :global(.scan-terminal.light .scan-calendar-card),
.root :global(.scan-terminal.light .scan-summary-card),
.root :global(.scan-terminal.light .scan-settings-card),
.root :global(.scan-terminal.light .scan-distribution-card),
@@ -170,66 +167,6 @@
color: #475569;
}
-.root :global(.scan-terminal.light .scan-calendar-group-sub),
-.root :global(.scan-terminal.light .scan-calendar-deb span),
-.root :global(.scan-terminal.light .scan-calendar-city-time) {
- color: #647a98;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-deb b) {
- color: #122033;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-reason) {
- color: #334155;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-card.peak-active) {
- border-color: rgba(10, 160, 100, 0.34);
- background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86));
-}
-
-.root :global(.scan-terminal.light .scan-calendar-header) {
- background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(16, 185, 129, 0.03));
- border-color: rgba(37, 99, 235, 0.14);
-}
-
-.root :global(.scan-terminal.light .scan-calendar-clock-time) {
- color: #0F172A;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-clock-date) {
- color: #475569;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-clock-meta) {
- color: #64748B;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-user-time) {
- background: rgba(37, 99, 235, 0.04);
-}
-
-.root :global(.scan-terminal.light .scan-calendar-user-label),
-.root :global(.scan-terminal.light .scan-calendar-phase),
-.root :global(.scan-terminal.light .scan-calendar-group-sub),
-.root :global(.scan-terminal.light .scan-calendar-city-time) {
- color: #64748B;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-group-count) {
- background: rgba(37, 99, 235, 0.1);
- color: #2563EB;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-empty-title) {
- color: #0F172A;
-}
-
-.root :global(.scan-terminal.light .scan-calendar-empty-sub) {
- color: #475569;
-}
-
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
background: rgba(16, 185, 129, 0.16);
color: #069668;
@@ -695,9 +632,6 @@
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
-.root :global(.scan-terminal.light .scan-calendar-group-sub),
-.root :global(.scan-terminal.light .scan-calendar-deb span),
-.root :global(.scan-terminal.light .scan-calendar-city-time),
.root :global(.scan-terminal.light .scan-opportunity-ai small),
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts
index 05df6d8f..4e50af67 100644
--- a/frontend/components/dashboard/scan-root-styles.ts
+++ b/frontend/components/dashboard/scan-root-styles.ts
@@ -16,7 +16,6 @@ import historyModalStyles from "./HistoryModal.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
-import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
@@ -41,7 +40,6 @@ export const scanRootClass = clsx(
scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
- scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx
deleted file mode 100644
index 5297c46b..00000000
--- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx
+++ /dev/null
@@ -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 (
-
- );
-});
-
-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 (
-
-
- {isEn
- ? "No actionable windows in the next 12 hours"
- : "未来 12 小时内没有可行动日历窗口"}
-
-
- {isEn
- ? "Check back after the next observation cycle"
- : "等待下一轮观测后再查看"}
-
-
- );
- }
-
- return (
-
-
-
-
- {formatUserClock(now)}
-
-
- {formatUserFullDate(now, locale)}
-
-
-
- {isEn ? "Your local time · Auto-refreshes" : "您所在时区 · 自动刷新"}
-
-
-
-
- {groups.map((group) => (
-
-
-
-
{group.label}
-
{group.subtitle}
-
-
- {group.items.length}
-
-
-
- {group.items.map((item) => (
-
- ))}
-
-
- ))}
-
-
- );
-});
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts
deleted file mode 100644
index 3b1ad34f..00000000
--- a/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts
+++ /dev/null
@@ -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 {
- 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"),
- /峰值窗口已过,若无新高应避免追高/,
- );
-}
diff --git a/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts b/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts
deleted file mode 100644
index bbdd7287..00000000
--- a/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts
+++ /dev/null
@@ -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 & {
- 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();
- 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);
-}
diff --git a/frontend/components/dashboard/scan-terminal/decision-utils.ts b/frontend/components/dashboard/scan-terminal/decision-utils.ts
index 7f274e42..41d70f47 100644
--- a/frontend/components/dashboard/scan-terminal/decision-utils.ts
+++ b/frontend/components/dashboard/scan-terminal/decision-utils.ts
@@ -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;
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index e10f5161..e54edf4a 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -451,6 +451,7 @@ export interface ScanOpportunityRow {
trading_region?: string | null;
trading_region_label?: string | null;
trading_region_label_zh?: string | null;
+ trading_region_sort?: number | null;
tz_offset_seconds?: number | null;
selected_date?: string | null;
local_date?: string | null;
diff --git a/web/scan_terminal_city_row.py b/web/scan_terminal_city_row.py
index 1ea2c6a9..e9fc071d 100644
--- a/web/scan_terminal_city_row.py
+++ b/web/scan_terminal_city_row.py
@@ -88,6 +88,7 @@ def _build_terminal_row(
"trading_region": market_region["key"],
"trading_region_label": market_region["label_en"],
"trading_region_label_zh": market_region["label_zh"],
+ "trading_region_sort": market_region.get("sort_order", 0),
"tz_offset_seconds": tz_offset,
"selected_date": selected_date or None,
"local_date": data.get("local_date"),
diff --git a/web/scan_terminal_filters.py b/web/scan_terminal_filters.py
index ef25bc06..0b9daaa6 100644
--- a/web/scan_terminal_filters.py
+++ b/web/scan_terminal_filters.py
@@ -51,22 +51,22 @@ def normalize_scan_terminal_filters(
}
-def market_region_from_tz_offset(tz_offset_seconds: Any) -> Dict[str, str]:
- tz_offset = safe_int(tz_offset_seconds, 0)
- if tz_offset <= -7200:
- return {
- "key": "americas",
- "label_en": "Americas",
- "label_zh": "美洲",
- }
- if tz_offset >= 14400:
- return {
- "key": "asia_pacific",
- "label_en": "Asia-Pacific",
- "label_zh": "亚太",
- }
- return {
- "key": "europe_africa",
- "label_en": "Europe / Africa",
- "label_zh": "欧洲 / 非洲",
- }
+def market_region_from_tz_offset(tz_offset_seconds: Any) -> Dict[str, object]:
+ """Map UTC offset to geographic region with an east-to-west sort order.
+
+ Sort order follows the sun: 1=East Asia → 7=North America.
+ """
+ tz_hours = safe_int(tz_offset_seconds, 0) / 3600.0
+ if tz_hours >= 8:
+ return {"key": "east_asia", "label_en": "East Asia", "label_zh": "东亚", "sort_order": 1}
+ if tz_hours >= 7:
+ return {"key": "southeast_asia", "label_en": "Southeast Asia", "label_zh": "东南亚", "sort_order": 2}
+ if tz_hours >= 5:
+ return {"key": "central_asia", "label_en": "Central / South Asia", "label_zh": "中亚 / 南亚", "sort_order": 3}
+ if tz_hours >= 3:
+ return {"key": "west_asia", "label_en": "West Asia", "label_zh": "西亚", "sort_order": 4}
+ if tz_hours >= 0:
+ return {"key": "europe_africa", "label_en": "Europe / Africa", "label_zh": "欧洲 / 非洲", "sort_order": 5}
+ if tz_hours >= -5:
+ return {"key": "south_america", "label_en": "South America", "label_zh": "南美洲", "sort_order": 6}
+ return {"key": "north_america", "label_en": "North America", "label_zh": "北美洲", "sort_order": 7}