From 421222a52d892fa77cbafaf4178e6fcbb5217d00 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 27 Apr 2026 07:09:16 +0800 Subject: [PATCH] feat: add CalendarView component for visualizing actionable scan opportunities --- .../dashboard/scan-terminal/CalendarView.tsx | 156 +++++++++++++++++- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx index 6f1ea328..72c08bcd 100644 --- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -1,10 +1,21 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue } from "@/lib/dashboard-utils"; import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils"; +const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60; +const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60; +const MINUTE_MS = 60_000; + +type CalendarMeta = ReturnType & { + localWindowLabel?: string | null; + cityWindowLabel?: string | null; + startAtMs?: number | null; + endAtMs?: number | null; +}; + function normalizeCalendarCityKey(value?: string | null) { return String(value || "") .trim() @@ -37,6 +48,112 @@ function dedupeCalendarRows(rows: ScanOpportunityRow[]) { return [...bestByCard.values()]; } +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", + }); +} + +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}`; +} + +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, + }; +} + +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 CalendarView({ rows, locale, @@ -48,6 +165,21 @@ export function CalendarView({ 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()); + }, 60_000); + return () => window.clearInterval(intervalId); + }, []); + const groups = useMemo(() => { const order = ["active", "next", "today", "later", "past"]; const byPhase = new Map< @@ -55,11 +187,12 @@ export function CalendarView({ { label: string; sort: number; - items: Array<{ row: ScanOpportunityRow; meta: ReturnType }>; + items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta }>; } >(); dedupeCalendarRows(rows).forEach((row) => { - const meta = getPeakCountdownMeta(row, locale); + const meta = buildCalendarMeta(row, locale, snapshotMs, nowMs); + if (!isCalendarActionable(row, meta, nowMs)) return; const current = byPhase.get(meta.key) || { label: meta.groupLabel, sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length, @@ -78,13 +211,15 @@ export function CalendarView({ return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0); }), })); - }, [locale, rows]); + }, [locale, nowMs, rows, snapshotMs]); if (!groups.length) { return (
- {locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"} + {locale === "en-US" + ? "No actionable calendar windows in the next 12 hours" + : "未来 12 小时内没有可行动日历窗口"}
); @@ -129,7 +264,16 @@ export function CalendarView({
{meta.title} - {meta.detail} + {meta.localWindowLabel ? ( + + {locale === "en-US" ? "Your time: " : "本地时间:"} + {meta.localWindowLabel} + + ) : null} + + {locale === "en-US" ? "City window: " : "城市窗口:"} + {meta.cityWindowLabel || meta.detail} +
{locale === "en-US" ? "DEB high" : "DEB 预测高点"}