diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx index df6a0ebf..286448c9 100644 --- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -4,298 +4,14 @@ import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue } from "@/lib/temperature-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; -}; - -type CalendarActionGroup = { - key: "now" | "next" | "later" | "past"; - label: string; - subtitle: string; - sort: number; -}; - -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); -} - -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()]; -} - -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; -} - -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(); -} - -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." - : "先放入行动面板,等待下一轮观测"; -} + buildCalendarCoreReason, + buildCalendarMeta, + dedupeCalendarRows, + formatCalendarCardShortDate, + getCalendarActionGroup, + isCalendarActionable, + type CalendarActionItem, +} from "@/components/dashboard/scan-terminal/calendar-action-utils"; const CalendarActionCard = memo(function CalendarActionCard({ item, @@ -349,7 +65,7 @@ const CalendarActionCard = memo(function CalendarActionCard({
- {formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"} + {formatCalendarCardShortDate(row, locale)} · {row.local_time || "--"} {phaseMeta.label}
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts new file mode 100644 index 00000000..665a2329 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state"; +import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; +import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; + +function market(status: MarketDecisionView["status"]): MarketDecisionView { + return { + bucketLabel: "--", + confidence: "--", + edgeText: "--", + impliedText: "--", + modelText: "--", + priceText: "--", + reason: "", + status, + title: "", + tone: "watch", + }; +} + +function ai(status: AiCityForecastState["status"], extra: Partial = {}): AiCityForecastState { + return { status, ...extra }; +} + +export function runTests() { + const breakout = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: ai("ready"), + aiRuleEvidenceMode: false, + isEn: false, + isHkoObservation: false, + marketDecisionView: market("ready"), + modelHighlyConsistent: true, + needsNextBulletin: false, + observationStale: false, + observedHighBreak: true, + observedLowBreak: false, + observedLowLag: false, + peakHasPassed: false, + }); + + assert.equal(breakout.urgency, "now"); + assert.equal(breakout.recommendation, "watch"); + assert.match(breakout.primaryReason, /实测已突破模型上沿/); + assert.doesNotMatch(breakout.primaryReason, /等待确认|等待下一报文/); + assert.ok(breakout.badges.some((badge) => badge.label === "实测突破")); + + const marketUnavailable = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: ai("ready"), + aiRuleEvidenceMode: false, + isEn: false, + isHkoObservation: false, + marketDecisionView: market("unavailable"), + modelHighlyConsistent: false, + needsNextBulletin: false, + observationStale: false, + observedHighBreak: false, + observedLowBreak: false, + observedLowLag: false, + peakHasPassed: false, + }); + + assert.equal(marketUnavailable.marketStatus, "unavailable"); + assert.match(marketUnavailable.primaryReason, /暂无可交易价格/); + assert.doesNotMatch(marketUnavailable.primaryReason, /未接入|系统缺失|系统坏/); + + const fallback = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: ai("ready", { payload: { degraded: true } }), + aiRuleEvidenceMode: true, + isEn: false, + isHkoObservation: false, + marketDecisionView: market("ready"), + modelHighlyConsistent: false, + needsNextBulletin: false, + observationStale: false, + observedHighBreak: false, + observedLowBreak: false, + observedLowLag: false, + peakHasPassed: false, + }); + + assert.equal(fallback.aiStatus, "fallback"); + assert.equal(fallback.aiStatusLabel, "规则证据模式"); + assert.notEqual(fallback.aiStatusLabel, "AI 解读已完成"); + + const partialStream = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: ai("loading", { streamText: "partial" }), + aiRuleEvidenceMode: false, + isEn: false, + isHkoObservation: false, + marketDecisionView: market("ready"), + modelHighlyConsistent: false, + needsNextBulletin: true, + observationStale: false, + observedHighBreak: false, + observedLowBreak: false, + observedLowLag: true, + peakHasPassed: false, + }); + + assert.equal(partialStream.aiStatus, "deepseek-loading"); + assert.equal(partialStream.aiStatusLabel, "快速判断已完成"); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts new file mode 100644 index 00000000..424fc1db --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state"; +import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; + +const readyMarket: MarketDecisionView = { + bucketLabel: "--", + confidence: "--", + edgeText: "--", + impliedText: "--", + modelText: "--", + priceText: "--", + reason: "", + status: "ready", + title: "", + tone: "neutral", +}; + +export function runTests() { + const peakPassed = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: { status: "ready" }, + aiRuleEvidenceMode: false, + isEn: false, + isHkoObservation: false, + marketDecisionView: readyMarket, + modelHighlyConsistent: true, + needsNextBulletin: false, + observationStale: false, + observedHighBreak: false, + observedLowBreak: false, + observedLowLag: false, + peakHasPassed: true, + }); + + assert.equal(peakPassed.urgency, "past"); + assert.equal(peakPassed.recommendation, "avoid"); + assert.match(peakPassed.primaryReason, /峰值窗口已过/); + assert.doesNotMatch(peakPassed.primaryReason, /值得关注/); + assert.deepEqual( + peakPassed.badges.map((badge) => badge.label), + ["峰值窗口已过", "模型高度一致"], + ); + + const staleMetar = buildCityDecisionState({ + aiCityForecast: null, + aiForecast: { status: "ready" }, + aiRuleEvidenceMode: false, + isEn: false, + isHkoObservation: false, + marketDecisionView: readyMarket, + modelHighlyConsistent: false, + needsNextBulletin: false, + observationStale: true, + observedHighBreak: false, + observedLowBreak: false, + observedLowLag: false, + peakHasPassed: false, + }); + + assert.equal(staleMetar.evidenceQuality, "stale"); + assert.equal(staleMetar.recommendation, "background"); + assert.ok(staleMetar.badges.some((badge) => badge.label === "METAR 过旧")); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts new file mode 100644 index 00000000..3b1ad34f --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/calendarGrouping.test.ts @@ -0,0 +1,81 @@ +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/__tests__/marketDecisionView.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts new file mode 100644 index 00000000..ade2f53d --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { + buildMarketDecisionView, + pickMarketBucketForWeatherCenter, +} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; +import type { MarketScan } from "@/lib/dashboard-types"; + +export function runTests() { + const unavailable = buildMarketDecisionView({ + expectedHigh: 24, + isEn: false, + marketScan: { available: false }, + marketStatus: "ready", + tempSymbol: "°C", + }); + + assert.equal(unavailable.status, "unavailable"); + assert.equal(unavailable.title, "市场价格暂不可用"); + assert.match(unavailable.reason, /暂无可交易价格/); + assert.match(unavailable.reason, /天气判断仍可参考/); + assert.doesNotMatch(unavailable.reason, /未接入|系统缺失|系统坏/); + + const mismatchedScan: MarketScan = { + available: true, + all_buckets: [ + { + label: "40°C", + temp: 40, + unit: "C", + model_probability: 0.2, + market_price: 0.3, + yes_buy: 0.31, + }, + ], + market_price: 0.3, + model_probability: 0.55, + yes_buy: 0.31, + }; + const mismatched = buildMarketDecisionView({ + expectedHigh: 24, + isEn: false, + marketScan: mismatchedScan, + marketStatus: "ready", + tempSymbol: "°C", + }); + + assert.equal(pickMarketBucketForWeatherCenter(mismatchedScan, 24, "°C"), null); + assert.equal(mismatched.status, "ready"); + assert.equal(mismatched.title, "市场温度桶需重新匹配"); + assert.equal(mismatched.edgeText, "--"); + assert.match(mismatched.reason, /温度桶与今日预计高点不够匹配/); + + const matched = buildMarketDecisionView({ + expectedHigh: 24.3, + isEn: false, + marketScan: { + available: true, + all_buckets: [ + { + label: "24°C", + temp: 24, + unit: "C", + model_probability: 0.64, + market_price: 0.41, + yes_buy: 0.42, + slug: "tokyo-high-24c", + }, + ], + market_price: 0.41, + model_probability: 0.64, + yes_buy: 0.42, + }, + marketStatus: "ready", + tempSymbol: "°C", + }); + + assert.equal(matched.status, "ready"); + assert.equal(matched.bucketLabel, "24°C"); + assert.equal(matched.priceText, "42¢"); + assert.match(matched.reason, /模型概率 64\.0%/); +} diff --git a/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts b/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts new file mode 100644 index 00000000..bbdd7287 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/calendar-action-utils.ts @@ -0,0 +1,299 @@ +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/package.json b/frontend/package.json index b58b218b..8ab47694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "node scripts/sync-next-server-chunks.mjs --clean-root && next dev", "build": "next build && node scripts/sync-next-server-chunks.mjs", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:business": "node scripts/run-business-state-tests.mjs" }, "dependencies": { "@radix-ui/react-slot": "^1.1.2", diff --git a/frontend/scripts/run-business-state-tests.mjs b/frontend/scripts/run-business-state-tests.mjs new file mode 100644 index 00000000..74e1ec3d --- /dev/null +++ b/frontend/scripts/run-business-state-tests.mjs @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const nodeRequire = createRequire(import.meta.url); +const moduleCache = new Map(); + +function toWindowsSafePath(value) { + return process.platform === "win32" && value.startsWith("/") + ? value.slice(1) + : value; +} + +function resolveModule(specifier, fromFile) { + if (specifier.startsWith("@/")) { + return resolveFile(path.join(projectRoot, specifier.slice(2))); + } + if (specifier.startsWith(".")) { + return resolveFile(path.resolve(path.dirname(fromFile), specifier)); + } + return specifier; +} + +function resolveFile(basePath) { + const candidates = [ + basePath, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.jsx`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + path.join(basePath, "index.js"), + ]; + const match = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); + if (!match) { + throw new Error(`Cannot resolve module file: ${basePath}`); + } + return match; +} + +function loadLocalModule(filename) { + const normalized = path.normalize(filename); + if (moduleCache.has(normalized)) return moduleCache.get(normalized).exports; + + const source = fs.readFileSync(normalized, "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: normalized, + }).outputText; + + const module = { exports: {} }; + moduleCache.set(normalized, module); + + const localRequire = (specifier) => { + const resolved = resolveModule(specifier, normalized); + if (typeof resolved === "string" && path.isAbsolute(resolved)) { + return loadLocalModule(resolved); + } + return nodeRequire(specifier); + }; + + const wrapper = `(function (exports, require, module, __filename, __dirname) {\n${transpiled}\n})`; + const compiled = vm.runInThisContext(wrapper, { filename: normalized }); + compiled(module.exports, localRequire, module, normalized, path.dirname(normalized)); + return module.exports; +} + +function collectTests(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return collectTests(fullPath); + return /\.test\.tsx?$/.test(entry.name) ? [fullPath] : []; + }); +} + +const testsRoot = path.join(projectRoot, "components", "dashboard", "scan-terminal", "__tests__"); +const testFiles = fs.existsSync(testsRoot) ? collectTests(testsRoot).sort() : []; + +if (!testFiles.length) { + throw new Error(`No business state tests found under ${testsRoot}`); +} + +let passed = 0; +for (const file of testFiles) { + const exported = loadLocalModule(toWindowsSafePath(file)); + const run = exported.runTests || exported.default; + if (typeof run !== "function") { + throw new Error(`${file} must export runTests()`); + } + await run(); + passed += 1; + console.log(`✓ ${path.relative(projectRoot, file)}`); +} + +console.log(`Business state tests passed: ${passed}/${testFiles.length}`);