Files
PolyWeather/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts
T
2569718930@qq.com 12d90c5051 Lock decision edge cases with business state tests
The dashboard risk is now mostly contradictory state combinations rather than styling. This extracts calendar action grouping into a pure utility and adds a lightweight TypeScript business-state runner so key decision states can be asserted without adding a test dependency.

Constraint: No new dependencies; use the existing TypeScript package for a local runner.

Rejected: Only rely on Next build/typecheck | it cannot catch product-language regressions such as fallback AI being labeled complete.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run test:business

Tested: npm run build

Not-tested: Browser-rendered mobile fold interaction snapshots.
2026-04-28 13:13:57 +08:00

300 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}