移除日历视图:功能与决策卡重叠,维护成本高于价值
- 删除 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:
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ import type {
|
|||||||
ScanOpportunityRow,
|
ScanOpportunityRow,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
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 { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +45,7 @@ import {
|
|||||||
useUserLocalClock,
|
useUserLocalClock,
|
||||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
||||||
|
|
||||||
type ContentView = "analysis" | "map" | "calendar";
|
type ContentView = "analysis" | "map";
|
||||||
|
|
||||||
const CityDetailPanel = dynamic(
|
const CityDetailPanel = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -235,12 +234,6 @@ function ScanTerminalScreen() {
|
|||||||
store.selectedDetail,
|
store.selectedDetail,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!store.proAccess.loading && !isPro && activeView === "calendar") {
|
|
||||||
setActiveView("map");
|
|
||||||
}
|
|
||||||
}, [activeView, isPro, store.proAccess.loading]);
|
|
||||||
|
|
||||||
const resolvedView: ContentView = activeView;
|
const resolvedView: ContentView = activeView;
|
||||||
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
||||||
const activeDetailRow =
|
const activeDetailRow =
|
||||||
@@ -352,16 +345,6 @@ function ScanTerminalScreen() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resolvedView === "calendar") {
|
|
||||||
return (
|
|
||||||
<CalendarView
|
|
||||||
rows={timeSortedRows}
|
|
||||||
locale={locale}
|
|
||||||
selectedRowId={selectedRowId}
|
|
||||||
onSelectRow={handleSelectRow}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,22 +490,6 @@ function ScanTerminalScreen() {
|
|||||||
>
|
>
|
||||||
{isEn ? "Decision Cards" : "城市决策卡"}
|
{isEn ? "Decision Cards" : "城市决策卡"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={resolvedView === "calendar"}
|
|
||||||
className={resolvedView === "calendar" ? "active" : ""}
|
|
||||||
title={!isPro ? (isEn ? "Pro forecast calendar required" : "日历预测需 Pro") : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isPro) {
|
|
||||||
openScanPaywall();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveView("calendar");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEn ? "Calendar View" : "日历视图"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-list-status">
|
<div className="scan-list-status">
|
||||||
{terminalData?.generated_at ? (
|
{terminalData?.generated_at ? (
|
||||||
@@ -550,8 +517,8 @@ function ScanTerminalScreen() {
|
|||||||
disabled={scanLoading}
|
disabled={scanLoading}
|
||||||
title={
|
title={
|
||||||
isEn
|
isEn
|
||||||
? "Force refresh decision cards and calendar"
|
? "Force refresh decision cards"
|
||||||
: "强制刷新决策卡和日历视图"
|
: "强制刷新决策卡"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={scanLoading ? "spin" : undefined} />
|
<RefreshCw size={14} className={scanLoading ? "spin" : undefined} />
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
.root :global(.scan-terminal.light .scan-detail-volume-big),
|
.root :global(.scan-terminal.light .scan-detail-volume-big),
|
||||||
.root :global(.scan-terminal.light .scan-kv strong),
|
.root :global(.scan-terminal.light .scan-kv strong),
|
||||||
.root :global(.scan-terminal.light .scan-time-main),
|
.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-summary-value),
|
||||||
.root :global(.scan-terminal.light .scan-distribution-card strong),
|
.root :global(.scan-terminal.light .scan-distribution-card strong),
|
||||||
.root :global(.scan-terminal.light .scan-table-header),
|
.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-mode-tab),
|
||||||
.root :global(.scan-terminal.light .scan-table-shell),
|
.root :global(.scan-terminal.light .scan-table-shell),
|
||||||
.root :global(.scan-terminal.light .scan-table-header),
|
.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-summary-card),
|
||||||
.root :global(.scan-terminal.light .scan-settings-card),
|
.root :global(.scan-terminal.light .scan-settings-card),
|
||||||
.root :global(.scan-terminal.light .scan-distribution-card),
|
.root :global(.scan-terminal.light .scan-distribution-card),
|
||||||
@@ -170,66 +167,6 @@
|
|||||||
color: #475569;
|
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) {
|
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
|
||||||
background: rgba(16, 185, 129, 0.16);
|
background: rgba(16, 185, 129, 0.16);
|
||||||
color: #069668;
|
color: #069668;
|
||||||
@@ -695,9 +632,6 @@
|
|||||||
.root :global(.scan-terminal.light .panel-weather-chip),
|
.root :global(.scan-terminal.light .panel-weather-chip),
|
||||||
.root :global(.scan-terminal.light .scan-distribution-card),
|
.root :global(.scan-terminal.light .scan-distribution-card),
|
||||||
.root :global(.scan-terminal.light .scan-chart-legend),
|
.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-opportunity-ai small),
|
||||||
.root :global(.scan-terminal.light .scan-ai-log-head span),
|
.root :global(.scan-terminal.light .scan-ai-log-head span),
|
||||||
.root :global(.scan-terminal.light .scan-ai-log-time),
|
.root :global(.scan-terminal.light .scan-ai-log-time),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import historyModalStyles from "./HistoryModal.module.css";
|
|||||||
import modalChromeStyles from "./ModalChrome.module.css";
|
import modalChromeStyles from "./ModalChrome.module.css";
|
||||||
import scanTerminalStyles from "./ScanTerminal.module.css";
|
import scanTerminalStyles from "./ScanTerminal.module.css";
|
||||||
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
|
||||||
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
|
|
||||||
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
|
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
|
||||||
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
|
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
|
||||||
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
|
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
|
||||||
@@ -41,7 +40,6 @@ export const scanRootClass = clsx(
|
|||||||
scanTerminalStateStyles.root,
|
scanTerminalStateStyles.root,
|
||||||
scanTerminalOpportunityStyles.root,
|
scanTerminalOpportunityStyles.root,
|
||||||
scanTerminalCardStyles.root,
|
scanTerminalCardStyles.root,
|
||||||
scanTerminalCalendarStyles.root,
|
|
||||||
scanTerminalMobileStyles.root,
|
scanTerminalMobileStyles.root,
|
||||||
scanTerminalLightThemeStyles.root,
|
scanTerminalLightThemeStyles.root,
|
||||||
detailChromeStyles.root,
|
detailChromeStyles.root,
|
||||||
|
|||||||
@@ -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[]) {
|
export function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
|
||||||
const focus = getMarketFocus(rows);
|
const focus = getMarketFocus(rows);
|
||||||
return [...rows].sort((left, right) => {
|
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) {
|
if (focus) {
|
||||||
const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
|
const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
|
||||||
const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;
|
const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ export interface ScanOpportunityRow {
|
|||||||
trading_region?: string | null;
|
trading_region?: string | null;
|
||||||
trading_region_label?: string | null;
|
trading_region_label?: string | null;
|
||||||
trading_region_label_zh?: string | null;
|
trading_region_label_zh?: string | null;
|
||||||
|
trading_region_sort?: number | null;
|
||||||
tz_offset_seconds?: number | null;
|
tz_offset_seconds?: number | null;
|
||||||
selected_date?: string | null;
|
selected_date?: string | null;
|
||||||
local_date?: string | null;
|
local_date?: string | null;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def _build_terminal_row(
|
|||||||
"trading_region": market_region["key"],
|
"trading_region": market_region["key"],
|
||||||
"trading_region_label": market_region["label_en"],
|
"trading_region_label": market_region["label_en"],
|
||||||
"trading_region_label_zh": market_region["label_zh"],
|
"trading_region_label_zh": market_region["label_zh"],
|
||||||
|
"trading_region_sort": market_region.get("sort_order", 0),
|
||||||
"tz_offset_seconds": tz_offset,
|
"tz_offset_seconds": tz_offset,
|
||||||
"selected_date": selected_date or None,
|
"selected_date": selected_date or None,
|
||||||
"local_date": data.get("local_date"),
|
"local_date": data.get("local_date"),
|
||||||
|
|||||||
@@ -51,22 +51,22 @@ def normalize_scan_terminal_filters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def market_region_from_tz_offset(tz_offset_seconds: Any) -> Dict[str, str]:
|
def market_region_from_tz_offset(tz_offset_seconds: Any) -> Dict[str, object]:
|
||||||
tz_offset = safe_int(tz_offset_seconds, 0)
|
"""Map UTC offset to geographic region with an east-to-west sort order.
|
||||||
if tz_offset <= -7200:
|
|
||||||
return {
|
Sort order follows the sun: 1=East Asia → 7=North America.
|
||||||
"key": "americas",
|
"""
|
||||||
"label_en": "Americas",
|
tz_hours = safe_int(tz_offset_seconds, 0) / 3600.0
|
||||||
"label_zh": "美洲",
|
if tz_hours >= 8:
|
||||||
}
|
return {"key": "east_asia", "label_en": "East Asia", "label_zh": "东亚", "sort_order": 1}
|
||||||
if tz_offset >= 14400:
|
if tz_hours >= 7:
|
||||||
return {
|
return {"key": "southeast_asia", "label_en": "Southeast Asia", "label_zh": "东南亚", "sort_order": 2}
|
||||||
"key": "asia_pacific",
|
if tz_hours >= 5:
|
||||||
"label_en": "Asia-Pacific",
|
return {"key": "central_asia", "label_en": "Central / South Asia", "label_zh": "中亚 / 南亚", "sort_order": 3}
|
||||||
"label_zh": "亚太",
|
if tz_hours >= 3:
|
||||||
}
|
return {"key": "west_asia", "label_en": "West Asia", "label_zh": "西亚", "sort_order": 4}
|
||||||
return {
|
if tz_hours >= 0:
|
||||||
"key": "europe_africa",
|
return {"key": "europe_africa", "label_en": "Europe / Africa", "label_zh": "欧洲 / 非洲", "sort_order": 5}
|
||||||
"label_en": "Europe / Africa",
|
if tz_hours >= -5:
|
||||||
"label_zh": "欧洲 / 非洲",
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user