feat: add CalendarView component for visualizing actionable scan opportunities
This commit is contained in:
@@ -1,10 +1,21 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
import { formatTemperatureValue } from "@/lib/dashboard-utils";
|
import { formatTemperatureValue } from "@/lib/dashboard-utils";
|
||||||
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-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<typeof getPeakCountdownMeta> & {
|
||||||
|
localWindowLabel?: string | null;
|
||||||
|
cityWindowLabel?: string | null;
|
||||||
|
startAtMs?: number | null;
|
||||||
|
endAtMs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeCalendarCityKey(value?: string | null) {
|
function normalizeCalendarCityKey(value?: string | null) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -37,6 +48,112 @@ function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
|
|||||||
return [...bestByCard.values()];
|
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({
|
export function CalendarView({
|
||||||
rows,
|
rows,
|
||||||
locale,
|
locale,
|
||||||
@@ -48,6 +165,21 @@ export function CalendarView({
|
|||||||
selectedRowId: string | null;
|
selectedRowId: string | null;
|
||||||
onSelectRow: (row: ScanOpportunityRow) => void;
|
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 groups = useMemo(() => {
|
||||||
const order = ["active", "next", "today", "later", "past"];
|
const order = ["active", "next", "today", "later", "past"];
|
||||||
const byPhase = new Map<
|
const byPhase = new Map<
|
||||||
@@ -55,11 +187,12 @@ export function CalendarView({
|
|||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
sort: number;
|
sort: number;
|
||||||
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
|
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta }>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
dedupeCalendarRows(rows).forEach((row) => {
|
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) || {
|
const current = byPhase.get(meta.key) || {
|
||||||
label: meta.groupLabel,
|
label: meta.groupLabel,
|
||||||
sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length,
|
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);
|
return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0);
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
}, [locale, rows]);
|
}, [locale, nowMs, rows, snapshotMs]);
|
||||||
|
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
return (
|
return (
|
||||||
<div className="scan-empty-state compact">
|
<div className="scan-empty-state compact">
|
||||||
<div className="scan-empty-title">
|
<div className="scan-empty-title">
|
||||||
{locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"}
|
{locale === "en-US"
|
||||||
|
? "No actionable calendar windows in the next 12 hours"
|
||||||
|
: "未来 12 小时内没有可行动日历窗口"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -129,7 +264,16 @@ export function CalendarView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="scan-calendar-countdown">
|
<div className="scan-calendar-countdown">
|
||||||
{meta.title}
|
{meta.title}
|
||||||
<small>{meta.detail}</small>
|
{meta.localWindowLabel ? (
|
||||||
|
<small>
|
||||||
|
{locale === "en-US" ? "Your time: " : "本地时间:"}
|
||||||
|
{meta.localWindowLabel}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
<small>
|
||||||
|
{locale === "en-US" ? "City window: " : "城市窗口:"}
|
||||||
|
{meta.cityWindowLabel || meta.detail}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-calendar-action">
|
<div className="scan-calendar-action">
|
||||||
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
|
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user