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}