From de107f2673bcafff71c9bff9b5b916d547ad1cae Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 09:43:20 +0800 Subject: [PATCH] Keep city decision cards extensible as product components AiPinnedForecastView had become a mini application that owned list state, card shell, freshness, market, AI evidence, and model evidence rendering in one place. This split keeps the workspace wrapper thin and gives the decision card explicit product-component seams for future recommendation reasons, risk levels, and mobile-specific layout work. Constraint: Preserve existing weather, market, AI read, chart, refresh, remove, collapse, and mobile behavior. Rejected: Rewrite the decision-state construction in the same pass | this component split should stay behavior-preserving before a state-model refactor. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Browser visual regression on physical mobile devices. --- .../scan-terminal/AiEvidencePanel.tsx | 115 +++ .../scan-terminal/AiPinnedCityCard.tsx | 737 +++++++++++++++++ .../AiPinnedCityCardSections.tsx | 380 --------- .../scan-terminal/AiPinnedForecastView.tsx | 741 +----------------- .../scan-terminal/CityCardHeader.tsx | 138 ++++ .../scan-terminal/CityStatusTags.tsx | 25 + .../scan-terminal/DataFreshnessBar.tsx | 39 + .../scan-terminal/MarketDecisionLine.tsx | 51 ++ .../scan-terminal/ModelEvidencePanel.tsx | 21 + .../scan-terminal/WeatherDecisionBand.tsx | 86 ++ 10 files changed, 1214 insertions(+), 1119 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx create mode 100644 frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/AiPinnedCityCardSections.tsx create mode 100644 frontend/components/dashboard/scan-terminal/CityCardHeader.tsx create mode 100644 frontend/components/dashboard/scan-terminal/CityStatusTags.tsx create mode 100644 frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx create mode 100644 frontend/components/dashboard/scan-terminal/MarketDecisionLine.tsx create mode 100644 frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx create mode 100644 frontend/components/dashboard/scan-terminal/WeatherDecisionBand.tsx diff --git a/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx new file mode 100644 index 00000000..49f4673c --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { + AiCityForecastPayload, + AiCityForecastState, +} from "@/components/dashboard/scan-terminal/types"; + +export function AiEvidencePanel({ + aiBullets, + aiCityForecast, + aiForecast, + aiReadCompleteText, + aiReadInProgressText, + aiRuleEvidenceMode, + aiRuleEvidenceText, + fallbackAiReason, + isCompactCard, + isEn, + isHkoObservation, + localModelSupportNote, + localizedFinalJudgment, + rawObservationText, +}: { + aiBullets: string[]; + aiCityForecast: AiCityForecastPayload["city_forecast"] | null; + aiForecast: AiCityForecastState; + aiReadCompleteText: string; + aiReadInProgressText: string; + aiRuleEvidenceMode: boolean; + aiRuleEvidenceText: string; + fallbackAiReason: string; + isCompactCard: boolean; + isEn: boolean; + isHkoObservation: boolean; + localModelSupportNote: string; + localizedFinalJudgment: string; + rawObservationText: string; +}) { + return ( +
+ + {isHkoObservation + ? isEn + ? "Evidence · AI HKO observation read" + : "证据 · AI 香港天文台观测解读" + : isEn + ? "Evidence · AI airport read" + : "证据 · AI 机场报文解读"} + +
+ {aiForecast.status === "loading" ? ( + <> +

{aiReadInProgressText}

+ {localizedFinalJudgment || aiForecast.streamText ? ( +

+ {localizedFinalJudgment || aiForecast.streamText} +

+ ) : null} +

+ {isEn + ? isHkoObservation + ? "Rule evidence is shown first; the full HKO AI read will merge automatically." + : "Rule evidence is shown first; the full airport AI read will merge automatically." + : isHkoObservation + ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" + : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"} +

+ + ) : aiForecast.status === "ready" && aiCityForecast ? ( + <> +

+ {aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText} +

+ +

{rawObservationText}

+ + ) : aiForecast.status === "ready" ? ( + <> +

{aiRuleEvidenceText}

+ + + ) : aiForecast.status === "failed" ? ( + <> +

{aiRuleEvidenceText}

+ + + ) : ( +

+ {isEn + ? isHkoObservation + ? "Waiting for AI to read the latest HKO observation." + : "Waiting for AI to read the latest airport bulletin." + : isHkoObservation + ? "等待 AI 解读最新香港天文台观测。" + : "等待 AI 解读最新机场报文。"} +

+ )} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx new file mode 100644 index 00000000..a0a56588 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -0,0 +1,737 @@ +"use client"; + +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; +import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel"; +import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader"; +import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel"; +import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand"; +import { + buildMarketDecisionView, + buildWeatherDecisionView, + resolveExpectedHighCandidate, +} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; +import { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils"; +import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; +import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types"; +import { + useAiCityForecast, + useCityMarketScan, +} from "@/components/dashboard/scan-terminal/use-ai-city-card-data"; +import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; +import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils"; + +function toFiniteDecisionNumber(value: unknown) { + if (value == null || value === "") return null; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function parseEpochMs(value: unknown) { + if (value == null || value === "") return null; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + const parsed = new Date(String(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); +} + +function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) { + const offsetSeconds = Number(detail?.utc_offset_seconds); + const epochMs = + parseEpochMs(detail?.airport_current?.report_time) ?? + parseEpochMs(detail?.airport_current?.obs_time_epoch) ?? + parseEpochMs(detail?.airport_current?.obs_time) ?? + parseEpochMs(detail?.current?.report_time) ?? + parseEpochMs(detail?.current?.obs_time_epoch) ?? + parseEpochMs(detail?.current?.obs_time); + if (epochMs != null) { + const utc = new Date(epochMs); + const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String( + utc.getUTCMinutes(), + ).padStart(2, "0")}Z`; + if (Number.isFinite(offsetSeconds)) { + const local = new Date(epochMs + offsetSeconds * 1000); + const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String( + local.getUTCMinutes(), + ).padStart(2, "0")}`; + return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; + } + return zText; + } + + const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i); + if (!rawToken) return ""; + const zText = `${rawToken[2]}:${rawToken[3]}Z`; + if (!Number.isFinite(offsetSeconds)) return zText; + const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]); + if (!Number.isFinite(utcMinutes)) return zText; + const localMinutes = Math.round( + ((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440, + ); + const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String( + localMinutes % 60, + ).padStart(2, "0")}`; + return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; +} + +function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) { + if (!text || !displayTime) return text; + const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`; + return text + .replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) + .replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) + .replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`); +} + +function isHkoObservationCity(detail?: CityDetail | null) { + const source = String( + detail?.current?.settlement_source || + detail?.settlement_station?.settlement_source || + "", + ) + .trim() + .toLowerCase(); + return source === "hko"; +} + +type StatusTagTone = "green" | "blue" | "amber" | "red" | "muted"; + +type StatusTag = { + label: string; + tone: StatusTagTone; +}; + +function formatFreshnessAge(value: unknown, isEn: boolean) { + const minutes = Number(value); + if (!Number.isFinite(minutes) || minutes < 0) return ""; + if (minutes < 1) return isEn ? "just now" : "刚刚"; + if (minutes < 60) { + const rounded = Math.max(1, Math.round(minutes)); + return isEn ? `${rounded}m ago` : `${rounded} 分钟前`; + } + const hours = Math.floor(minutes / 60); + const remaining = Math.round(minutes % 60); + if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`; + return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`; +} + +function formatUpdateTime(value: unknown, locale: string) { + const epochMs = parseEpochMs(value); + if (epochMs == null) return ""; + const date = new Date(epochMs); + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + if (locale === "en-US") { + const day = sameDay + ? "today" + : date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return `${day} ${time} updated`; + } + const day = sameDay + ? "今日" + : date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); + return `${day} ${time} 更新`; +} + +function buildObservationFreshnessValue({ + detail, + displayTime, + isEn, + isHkoObservation, +}: { + detail: CityDetail | null; + displayTime: string; + isEn: boolean; + isHkoObservation: boolean; +}) { + const stale = Boolean( + detail?.metar_status?.stale_for_today || + detail?.airport_current?.stale_for_today || + detail?.current?.observation_status === "stale", + ); + if (stale) { + return isEn ? "stale; background only" : "已过旧,仅作背景参考"; + } + const ageLabel = formatFreshnessAge( + isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min, + isEn, + ); + if (ageLabel) return ageLabel; + if (displayTime) return displayTime; + return isEn ? "time pending" : "时间待确认"; +} + +function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) { + return ( + formatUpdateTime(detail?.updated_at, locale) || + (isEn ? "latest run loaded" : "已加载最新模型") + ); +} + +function buildMarketFreshnessValue({ + isEn, + marketScan, + marketStatus, +}: { + isEn: boolean; + marketScan: ReturnType["marketScan"]; + marketStatus: ReturnType["marketStatus"]; +}) { + if (marketStatus === "loading") return isEn ? "syncing" : "同步中"; + if (!marketScan?.available) return isEn ? "temporarily unavailable" : "暂不可用"; + const quoteAgeMs = Number( + marketScan.quote_age_ms ?? + marketScan.yes_token?.quote_age_ms ?? + marketScan.no_token?.quote_age_ms, + ); + if (Number.isFinite(quoteAgeMs) && quoteAgeMs >= 0) { + return formatFreshnessAge(quoteAgeMs / 60_000, isEn); + } + return isEn ? "synced" : "已同步"; +} + +function uniqueStatusTags(tags: Array) { + const seen = new Set(); + return tags.filter((tag): tag is StatusTag => { + if (!tag?.label || seen.has(tag.label)) return false; + seen.add(tag.label); + return true; + }); +} + +export function AiPinnedCityCard({ + item, + detail, + row, + locale, + collapsed, + removing, + onRefreshCityDetail, + onRemove, + onToggleCollapsed, +}: { + item: AiPinnedCity; + detail: CityDetail | null; + row: ScanOpportunityRow | null; + locale: string; + collapsed: boolean; + removing?: boolean; + onRefreshCityDetail: (cityName: string) => Promise; + onRemove: () => void; + onToggleCollapsed: () => void; +}) { + const isEn = locale === "en-US"; + const displayName = + detail?.display_name || + row?.city_display_name || + row?.display_name || + item.displayName || + item.cityName; + const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C"; + const modelView = detail ? getModelView(detail, detail.local_date) : null; + const modelEntries = modelView + ? Object.entries(modelView.models || {}) + .map(([name, value]) => [name, Number(value)] as const) + .filter(([, value]) => Number.isFinite(value)) + : []; + const modelValues = modelEntries.map(([, value]) => value); + const modelMin = modelValues.length ? Math.min(...modelValues) : null; + const modelMax = modelValues.length ? Math.max(...modelValues) : null; + const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null; + const peakWindow = + paceView?.peakWindowText || + (row ? getPeakWindowLabel(row) : null) || + "--"; + const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null; + const isHkoObservation = isHkoObservationCity(detail); + const currentTemp = + (isHkoObservation + ? detail?.current?.temp ?? row?.current_temp + : detail?.airport_primary?.temp ?? + detail?.airport_current?.temp ?? + detail?.current?.temp ?? + row?.current_temp) ?? null; + const debNumber = toFiniteDecisionNumber(deb); + const currentTempNumber = toFiniteDecisionNumber(currentTemp); + const modelRange = + modelMin != null && modelMax != null + ? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}` + : "--"; + const paceTone = paceView?.biasTone || "neutral"; + const paceText = + paceView?.summary || + (isEn + ? "Waiting for intraday observations to compare against the DEB path." + : "等待更多日内实测,用来对照 DEB 预测路径。"); + const report = isHkoObservation + ? "" + : detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; + const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn); + const observationStation = isHkoObservation + ? detail?.current?.station_name || + detail?.current?.station_code || + detail?.settlement_station?.settlement_station_label || + detail?.settlement_station?.settlement_station_code || + "香港天文台" + : detail?.risk?.icao || + detail?.current?.station_code || + detail?.airport_current?.station_code || + detail?.airport_primary?.station_code || + ""; + const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测"; + const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations"; + const rawObservationText = isHkoObservation + ? `${isEn ? "Observation source" : "观测来源"}:${observationStation || (isEn ? "Hong Kong Observatory" : "香港天文台")}${metarReportTimeDisplay ? `,${metarReportTimeDisplay}` : ""}` + : report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${observationStation} ${report}`.trim()}` + : isEn + ? "Raw METAR: unavailable." + : "原始 METAR:暂无。"; + const detailCityName = detail?.name || item.cityName; + const [refreshingDetail, setRefreshingDetail] = useState(false); + const { aiForecast, refreshAiForecast } = useAiCityForecast({ + detail, + detailCityName, + enabled: Boolean(detail), + isEn, + locale, + report, + }); + const { marketScan, marketStatus } = useCityMarketScan({ + detail, + detailCityName, + enabled: Boolean(detail), + }); + const isRefreshing = refreshingDetail || aiForecast.status === "loading"; + const [isCompactCard, setIsCompactCard] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const media = window.matchMedia("(max-width: 820px)"); + const syncCompactMode = () => setIsCompactCard(media.matches); + syncCompactMode(); + media.addEventListener("change", syncCompactMode); + return () => media.removeEventListener("change", syncCompactMode); + }, []); + + const aiCityForecast = aiForecast.payload?.city_forecast || null; + const localizedFinalJudgmentRaw = + (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || + (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || + ""; + const localizedMetarReadRaw = + (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || + ""; + const localizedReasoningRaw = + (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || + ""; + const localizedFinalJudgment = normalizeMetarReadTime( + localizedFinalJudgmentRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedMetarRead = normalizeMetarReadTime( + localizedMetarReadRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedReasoning = normalizeMetarReadTime( + localizedReasoningRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedModelNote = + (isEn + ? aiCityForecast?.model_cluster_note_en + : aiCityForecast?.model_cluster_note_zh) || ""; + const modelPreview = modelEntries + .slice(0, 4) + .map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`) + .join(isEn ? " / " : " / "); + const localModelSupportNote = modelEntries.length + ? isEn + ? modelEntries.length <= 2 + ? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.` + : `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.` + : modelEntries.length <= 2 + ? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。` + : `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。` + : isEn + ? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.` + : `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`; + const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max); + const decisionExpectedHighNumber = resolveExpectedHighCandidate({ + aiPredictedMax, + currentTemp: currentTempNumber, + deb: debNumber, + modelMax, + modelMin, + paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null, + }); + const decisionView = buildWeatherDecisionView({ + aiCityForecast, + currentTemp: currentTempNumber, + deb: debNumber, + isEn, + localModelSupportNote, + modelEntries, + modelMax, + modelMin, + paceTone, + paceView, + peakWindow, + tempSymbol, + }); + const expectedHighText = + decisionExpectedHighNumber != null + ? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 }) + : "--"; + const currentTempText = + currentTempNumber != null + ? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 }) + : "--"; + const debText = + debNumber != null + ? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 }) + : "--"; + const marketDecisionView = buildMarketDecisionView({ + expectedHigh: decisionExpectedHighNumber, + isEn, + marketScan, + marketStatus, + tempSymbol, + }); + const aiMeta = aiCityForecast?._polyweather_meta || null; + const guardReason = aiMeta?.deterministic_guard_reason || {}; + const observationStale = Boolean( + detail?.metar_status?.stale_for_today || + detail?.airport_current?.stale_for_today || + detail?.current?.observation_status === "stale" || + guardReason.observation_stale, + ); + const observedHighBreak = Boolean( + guardReason.observed_high_break || + (currentTempNumber != null && + modelMax != null && + currentTempNumber > modelMax + 0.2), + ); + const observedLowBreak = Boolean(guardReason.observed_low_break); + const observedLowLag = Boolean(guardReason.observed_low_lag); + const peakHasPassed = Boolean( + guardReason.peak_has_passed || + ["past", "post_peak", "after_peak"].includes( + String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(), + ), + ); + const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null; + const modelHighlyConsistent = + modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2; + const needsNextBulletin = + !observationStale && + !observedHighBreak && + !observedLowBreak && + !peakHasPassed && + (observedLowLag || paceTone === "neutral" || aiForecast.status === "loading"); + const aiRuleEvidenceMode = Boolean( + aiForecast.status === "failed" || + (aiForecast.status === "ready" && !aiCityForecast) || + aiForecast.payload?.degraded || + aiMeta?.fallback, + ); + const aiReadInProgressText = isEn + ? isHkoObservation + ? "Fast read is ready; AI is adding HKO observation details..." + : "Fast read is ready; AI is adding airport bulletin details..." + : isHkoObservation + ? "快速判断已完成,AI 正在补充香港天文台观测细节…" + : "快速判断已完成,AI 正在补充机场报文细节…"; + const aiReadCompleteText = isEn + ? isHkoObservation + ? "AI HKO observation read is complete." + : "AI airport bulletin read is complete." + : isHkoObservation + ? "AI 香港天文台观测解读已完成" + : "AI 机场报文解读已完成"; + const aiRuleEvidenceText = isEn + ? "AI read did not return completely; rule evidence is being used." + : "AI 解读未完整返回,当前使用规则证据"; + const aiStatusLabel = + aiForecast.status === "loading" + ? isEn + ? "Fast read ready" + : "快速判断已完成" + : aiForecast.status === "ready" && aiCityForecast + ? aiRuleEvidenceMode + ? isEn + ? "Rule evidence" + : "规则证据模式" + : isEn + ? "AI read complete" + : "AI 解读已完成" + : aiRuleEvidenceMode + ? isEn + ? "Rule evidence" + : "规则证据模式" + : isEn + ? "AI pending" + : "AI 待返回"; + const aiStatusTone: StatusTagTone = + aiForecast.status === "loading" + ? "blue" + : aiForecast.status === "ready" && aiCityForecast + ? aiRuleEvidenceMode + ? "amber" + : "green" + : aiRuleEvidenceMode + ? "amber" + : "muted"; + const marketStatusTone: StatusTagTone = + marketDecisionView.status === "ready" + ? "green" + : marketDecisionView.status === "loading" + ? "blue" + : "muted"; + const dataFreshnessRows = [ + { + label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR", + value: buildObservationFreshnessValue({ + detail, + displayTime: metarReportTimeDisplay, + isEn, + isHkoObservation, + }), + tone: observationStale ? "stale" : "fresh", + }, + { + label: isEn ? "Models" : "模型", + value: buildModelFreshnessValue(detail, locale, isEn), + tone: "fresh", + }, + { + label: isEn ? "Market" : "市场价格", + value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }), + tone: + marketDecisionView.status === "ready" + ? "fresh" + : marketDecisionView.status === "loading" + ? "loading" + : "stale", + }, + ]; + const freshnessSeparator = isEn ? ": " : ":"; + const statusTags = uniqueStatusTags([ + observedHighBreak + ? { + label: isEn ? "Observed breakout" : "实测突破", + tone: "red", + } + : null, + peakHasPassed + ? { + label: isEn ? "Peak window passed" : "峰值窗口已过", + tone: "muted", + } + : null, + observationStale + ? { + label: isEn + ? isHkoObservation + ? "HKO stale" + : "METAR stale" + : isHkoObservation + ? "观测过旧" + : "METAR 过旧", + tone: "amber", + } + : null, + observedLowBreak + ? { + label: isEn ? "Peak revised down" : "峰值下修", + tone: "blue", + } + : null, + aiForecast.status === "loading" + ? { + label: isEn ? "Fast read ready" : "快速判断已完成", + tone: aiStatusTone, + } + : null, + marketDecisionView.status === "unavailable" + ? { + label: isEn ? "Market unavailable" : "市场价暂不可用", + tone: marketStatusTone, + } + : null, + modelHighlyConsistent + ? { + label: isEn ? "Models agree" : "模型高度一致", + tone: "green", + } + : null, + observedLowLag || needsNextBulletin + ? { + label: isEn ? "Wait next report" : "需要等待下一报文", + tone: "amber", + } + : null, + ]).slice(0, 3); + const localizedRisksRaw = + (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; + const localizedRisks = Array.isArray(localizedRisksRaw) + ? localizedRisksRaw + : localizedRisksRaw + ? [String(localizedRisksRaw)] + : []; + const aiBullets = [ + localizedMetarRead, + localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", + localizedModelNote || localModelSupportNote, + ...localizedRisks, + ].filter((line) => String(line || "").trim()); + const fallbackAiReason = + (isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) || + aiForecast.payload?.reason || + ""; + const decisionWhyText = observedHighBreak + ? isEn + ? "Worth watching now: observation has broken above the model range." + : "当前值得关注:实测已突破模型上沿。" + : peakHasPassed + ? isEn + ? "Avoid chasing now: peak window has passed; wait to confirm no new high." + : "当前不宜追高:峰值窗口已过,等待确认是否还有新高。" + : observationStale + ? isEn + ? "Use as background only: observation is stale and needs the next report." + : "当前仅作背景:观测已过旧,需要下一报文确认。" + : marketDecisionView.status === "unavailable" + ? isEn + ? "Weather evidence remains usable, but no tradable quote is available yet." + : "当前可参考天气:暂无可交易价格。" + : modelHighlyConsistent + ? isEn + ? "Worth watching now: models agree; wait for observation confirmation." + : "当前值得关注:模型高度一致,等待实测确认。" + : needsNextBulletin + ? isEn + ? "Wait for confirmation: the next bulletin should decide direction." + : "当前建议等待:下一报文更适合决定方向。" + : isEn + ? "Watch the peak window and compare observations against the expected high." + : "当前重点:盯住峰值窗口,把实测与预计高点对照。"; + const marketLineText = + marketDecisionView.status === "ready" + ? `${marketDecisionView.bucketLabel} · ${marketDecisionView.priceText}` + : marketDecisionView.status === "loading" + ? isEn + ? "syncing" + : "同步中" + : isEn + ? "temporarily unavailable" + : "暂不可用"; + + const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; + + return ( +
+ { + event.preventDefault(); + event.stopPropagation(); + if (refreshingDetail) return; + setRefreshingDetail(true); + void onRefreshCityDetail(detailCityName) + .catch(() => {}) + .finally(() => { + refreshAiForecast(); + setRefreshingDetail(false); + }); + }} + onRemove={(event) => { + event.preventDefault(); + event.stopPropagation(); + onRemove(); + }} + onToggleCollapsed={onToggleCollapsed} + peakWindow={peakWindow} + removing={removing} + rowLocalTime={row?.local_time} + statusTags={statusTags} + /> + + {detail && !collapsed ? ( +
+ + +
+ + +
+ + + +
+ ) : !detail ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCardSections.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCardSections.tsx deleted file mode 100644 index f909d19c..00000000 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCardSections.tsx +++ /dev/null @@ -1,380 +0,0 @@ -"use client"; - -import clsx from "clsx"; -import { ChevronDown, RefreshCw, X } from "lucide-react"; -import type { MouseEvent } from "react"; -import type { - MarketDecisionView, - WeatherDecisionView, -} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; -import type { - AiCityForecastPayload, - AiCityForecastState, -} from "@/components/dashboard/scan-terminal/types"; - -type StatusTone = "green" | "blue" | "amber" | "red" | "muted"; - -export type CityStatusTag = { - label: string; - tone: StatusTone; -}; - -export type DataFreshnessRow = { - label: string; - value: string; - tone: string; -}; - -export function CityCardHeader({ - aiStatusLabel, - aiStatusTone, - collapseId, - collapsed, - currentTempText, - dataFreshnessRows, - debText, - detailLocalTime, - displayName, - expectedHighText, - freshnessSeparator, - isEn, - isRefreshing, - modelRange, - onRefresh, - onRemove, - onToggleCollapsed, - peakWindow, - removing, - rowLocalTime, - statusTags, -}: { - aiStatusLabel: string; - aiStatusTone: StatusTone; - collapseId: string; - collapsed: boolean; - currentTempText: string; - dataFreshnessRows: DataFreshnessRow[]; - debText: string; - detailLocalTime?: string | null; - displayName: string; - expectedHighText: string; - freshnessSeparator: string; - isEn: boolean; - isRefreshing: boolean; - modelRange: string; - onRefresh: (event: MouseEvent) => void; - onRemove: (event: MouseEvent) => void; - onToggleCollapsed: () => void; - peakWindow: string; - removing?: boolean; - rowLocalTime?: string | null; - statusTags: CityStatusTag[]; -}) { - return ( -
-
- - {isEn ? "Deep analysis" : "城市深度分析"} - -

{displayName}

-
- - {isEn ? "Observed" : "当前温度"} - {currentTempText} - - - {isEn ? "Expected high" : "预测高点"} - {expectedHighText} - - - {isEn ? "Peak" : "峰值时间"} - {peakWindow} - -
-
- {statusTags.map((tag) => ( - - {tag.label} - - ))} -
-
- {detailLocalTime || rowLocalTime || "--"} - DEB {debText} - {isEn ? "Model" : "模型"} {modelRange} - {isEn ? "Peak" : "峰值"} {peakWindow} -
-
- {isEn ? "Data freshness" : "数据新鲜度"} - {dataFreshnessRows.map((freshness) => ( - - {freshness.label}{freshnessSeparator} - {freshness.value} - - ))} - - AI{freshnessSeparator} - {aiStatusLabel} - -
-
-
- {isEn ? "Expected high" : "预计最高温"} - {expectedHighText} -
- - - -
-
-
- ); -} - -export function WeatherDecisionBand({ - currentTempText, - decisionView, - decisionWhyText, - isEn, - longText, - marketDecisionView, - marketLineText, - paceDeltaText, - peakWindow, -}: { - currentTempText: string; - decisionView: WeatherDecisionView; - decisionWhyText: string; - isEn: boolean; - longText: string; - marketDecisionView: MarketDecisionView; - marketLineText: string; - paceDeltaText: string; - peakWindow: string; -}) { - return ( -
-
- {decisionView.kicker} - {decisionView.action} -

{decisionWhyText}

-

{longText}

-
- {decisionView.reasons.map((reason, index) => ( - {reason} - ))} -
-

{decisionView.risk}

-
- {isEn ? "Market price" : "市场价格"} - {marketLineText} -
-
-
- {isEn ? "Polymarket price layer" : "Polymarket 价格层"} - {marketDecisionView.title} -

{marketDecisionView.reason}

-
-
- - {isEn ? "Bucket" : "温度桶"} {marketDecisionView.bucketLabel} - - - {isEn ? "YES buy" : "YES 买价"} {marketDecisionView.priceText} - - - {isEn ? "Model-market" : "模型-市场差"} {marketDecisionView.edgeText} - -
- {marketDecisionView.marketUrl ? ( - - {isEn ? "Open market" : "打开市场"} - - ) : null} -
-
-
- - {isEn ? "Expected high" : "预计高点"} - {decisionView.expectedHigh} - - - {isEn ? "Weather range" : "天气区间"} - {decisionView.targetRange} - - - {isEn ? "Confidence" : "信心"} - {decisionView.confidence} - - - {isEn ? "Observed" : "实测"} - {currentTempText} - - - {isEn ? "Path delta" : "路径偏差"} {paceDeltaText} - - - {isEn ? "Peak window" : "峰值窗口"} {peakWindow} - - - {isEn ? "Market implied" : "市场隐含"} {marketDecisionView.impliedText} - - - {isEn ? "Model prob" : "模型概率"} {marketDecisionView.modelText} - - - {isEn ? "Quote status" : "报价状态"}{" "} - {marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")} - -
-
- ); -} - -export function AiEvidencePanel({ - aiBullets, - aiCityForecast, - aiForecast, - aiReadCompleteText, - aiReadInProgressText, - aiRuleEvidenceMode, - aiRuleEvidenceText, - fallbackAiReason, - isCompactCard, - isEn, - isHkoObservation, - localModelSupportNote, - localizedFinalJudgment, - rawObservationText, -}: { - aiBullets: string[]; - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; - aiForecast: AiCityForecastState; - aiReadCompleteText: string; - aiReadInProgressText: string; - aiRuleEvidenceMode: boolean; - aiRuleEvidenceText: string; - fallbackAiReason: string; - isCompactCard: boolean; - isEn: boolean; - isHkoObservation: boolean; - localModelSupportNote: string; - localizedFinalJudgment: string; - rawObservationText: string; -}) { - return ( -
- - {isHkoObservation - ? isEn - ? "Evidence · AI HKO observation read" - : "证据 · AI 香港天文台观测解读" - : isEn - ? "Evidence · AI airport read" - : "证据 · AI 机场报文解读"} - -
- {aiForecast.status === "loading" ? ( - <> -

{aiReadInProgressText}

- {localizedFinalJudgment || aiForecast.streamText ? ( -

- {localizedFinalJudgment || aiForecast.streamText} -

- ) : null} -

- {isEn - ? isHkoObservation - ? "Rule evidence is shown first; the full HKO AI read will merge automatically." - : "Rule evidence is shown first; the full airport AI read will merge automatically." - : isHkoObservation - ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" - : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"} -

- - ) : aiForecast.status === "ready" && aiCityForecast ? ( - <> -

- {aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText} -

-
    - {[localizedFinalJudgment, ...aiBullets] - .filter((line) => String(line || "").trim()) - .map((line, index) => ( -
  • {line}
  • - ))} -
-

{rawObservationText}

- - ) : aiForecast.status === "ready" ? ( - <> -

{aiRuleEvidenceText}

-
    - {fallbackAiReason ?
  • {fallbackAiReason}
  • : null} -
  • {localModelSupportNote}
  • -
  • {rawObservationText}
  • -
- - ) : aiForecast.status === "failed" ? ( - <> -

{aiRuleEvidenceText}

-
    - {aiForecast.error ?
  • {aiForecast.error}
  • : null} -
  • {localModelSupportNote}
  • -
  • {rawObservationText}
  • -
- - ) : ( -

- {isEn - ? isHkoObservation - ? "Waiting for AI to read the latest HKO observation." - : "Waiting for AI to read the latest airport bulletin." - : isHkoObservation - ? "等待 AI 解读最新香港天文台观测。" - : "等待 AI 解读最新机场报文。"} -

- )} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 67b9e146..ca9e5d4e 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -1,748 +1,11 @@ "use client"; -import clsx from "clsx"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ModelForecast } from "@/components/dashboard/PanelSections"; -import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; -import { - AiEvidencePanel, - CityCardHeader, - WeatherDecisionBand, -} from "@/components/dashboard/scan-terminal/AiPinnedCityCardSections"; -import { - buildMarketDecisionView, - buildWeatherDecisionView, - resolveExpectedHighCandidate, -} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; +import { AiPinnedCityCard } from "@/components/dashboard/scan-terminal/AiPinnedCityCard"; import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils"; -import { findRowForCity, getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils"; -import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; +import { findRowForCity, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils"; import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types"; -import { - useAiCityForecast, - useCityMarketScan, -} from "@/components/dashboard/scan-terminal/use-ai-city-card-data"; import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; -import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils"; - -function toFiniteDecisionNumber(value: unknown) { - if (value == null || value === "") return null; - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; -} - -function parseEpochMs(value: unknown) { - if (value == null || value === "") return null; - const numeric = Number(value); - if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; - const parsed = new Date(String(value)); - return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); -} - -function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) { - const offsetSeconds = Number(detail?.utc_offset_seconds); - const epochMs = - parseEpochMs(detail?.airport_current?.report_time) ?? - parseEpochMs(detail?.airport_current?.obs_time_epoch) ?? - parseEpochMs(detail?.airport_current?.obs_time) ?? - parseEpochMs(detail?.current?.report_time) ?? - parseEpochMs(detail?.current?.obs_time_epoch) ?? - parseEpochMs(detail?.current?.obs_time); - if (epochMs != null) { - const utc = new Date(epochMs); - const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String( - utc.getUTCMinutes(), - ).padStart(2, "0")}Z`; - if (Number.isFinite(offsetSeconds)) { - const local = new Date(epochMs + offsetSeconds * 1000); - const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String( - local.getUTCMinutes(), - ).padStart(2, "0")}`; - return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; - } - return zText; - } - - const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i); - if (!rawToken) return ""; - const zText = `${rawToken[2]}:${rawToken[3]}Z`; - if (!Number.isFinite(offsetSeconds)) return zText; - const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]); - if (!Number.isFinite(utcMinutes)) return zText; - const localMinutes = Math.round( - ((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440, - ); - const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String( - localMinutes % 60, - ).padStart(2, "0")}`; - return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; -} - -function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) { - if (!text || !displayTime) return text; - const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`; - return text - .replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) - .replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) - .replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`); -} - -function isHkoObservationCity(detail?: CityDetail | null) { - const source = String( - detail?.current?.settlement_source || - detail?.settlement_station?.settlement_source || - "", - ) - .trim() - .toLowerCase(); - return source === "hko"; -} - -type StatusTagTone = "green" | "blue" | "amber" | "red" | "muted"; - -type StatusTag = { - label: string; - tone: StatusTagTone; -}; - -function formatFreshnessAge(value: unknown, isEn: boolean) { - const minutes = Number(value); - if (!Number.isFinite(minutes) || minutes < 0) return ""; - if (minutes < 1) return isEn ? "just now" : "刚刚"; - if (minutes < 60) { - const rounded = Math.max(1, Math.round(minutes)); - return isEn ? `${rounded}m ago` : `${rounded} 分钟前`; - } - const hours = Math.floor(minutes / 60); - const remaining = Math.round(minutes % 60); - if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`; - return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`; -} - -function formatUpdateTime(value: unknown, locale: string) { - const epochMs = parseEpochMs(value); - if (epochMs == null) return ""; - const date = new Date(epochMs); - const now = new Date(); - const sameDay = date.toDateString() === now.toDateString(); - const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", { - hour: "2-digit", - minute: "2-digit", - }); - if (locale === "en-US") { - const day = sameDay - ? "today" - : date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - return `${day} ${time} updated`; - } - const day = sameDay - ? "今日" - : date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); - return `${day} ${time} 更新`; -} - -function buildObservationFreshnessValue({ - detail, - displayTime, - isEn, - isHkoObservation, -}: { - detail: CityDetail | null; - displayTime: string; - isEn: boolean; - isHkoObservation: boolean; -}) { - const stale = Boolean( - detail?.metar_status?.stale_for_today || - detail?.airport_current?.stale_for_today || - detail?.current?.observation_status === "stale", - ); - if (stale) { - return isEn ? "stale; background only" : "已过旧,仅作背景参考"; - } - const ageLabel = formatFreshnessAge( - isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min, - isEn, - ); - if (ageLabel) return ageLabel; - if (displayTime) return displayTime; - return isEn ? "time pending" : "时间待确认"; -} - -function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) { - return ( - formatUpdateTime(detail?.updated_at, locale) || - (isEn ? "latest run loaded" : "已加载最新模型") - ); -} - -function buildMarketFreshnessValue({ - isEn, - marketScan, - marketStatus, -}: { - isEn: boolean; - marketScan: ReturnType["marketScan"]; - marketStatus: ReturnType["marketStatus"]; -}) { - if (marketStatus === "loading") return isEn ? "syncing" : "同步中"; - if (!marketScan?.available) return isEn ? "temporarily unavailable" : "暂不可用"; - const quoteAgeMs = Number( - marketScan.quote_age_ms ?? - marketScan.yes_token?.quote_age_ms ?? - marketScan.no_token?.quote_age_ms, - ); - if (Number.isFinite(quoteAgeMs) && quoteAgeMs >= 0) { - return formatFreshnessAge(quoteAgeMs / 60_000, isEn); - } - return isEn ? "synced" : "已同步"; -} - -function uniqueStatusTags(tags: Array) { - const seen = new Set(); - return tags.filter((tag): tag is StatusTag => { - if (!tag?.label || seen.has(tag.label)) return false; - seen.add(tag.label); - return true; - }); -} - -function AiPinnedCityCard({ - item, - detail, - row, - locale, - collapsed, - removing, - onRefreshCityDetail, - onRemove, - onToggleCollapsed, -}: { - item: AiPinnedCity; - detail: CityDetail | null; - row: ScanOpportunityRow | null; - locale: string; - collapsed: boolean; - removing?: boolean; - onRefreshCityDetail: (cityName: string) => Promise; - onRemove: () => void; - onToggleCollapsed: () => void; -}) { - const isEn = locale === "en-US"; - const displayName = - detail?.display_name || - row?.city_display_name || - row?.display_name || - item.displayName || - item.cityName; - const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C"; - const modelView = detail ? getModelView(detail, detail.local_date) : null; - const modelEntries = modelView - ? Object.entries(modelView.models || {}) - .map(([name, value]) => [name, Number(value)] as const) - .filter(([, value]) => Number.isFinite(value)) - : []; - const modelValues = modelEntries.map(([, value]) => value); - const modelMin = modelValues.length ? Math.min(...modelValues) : null; - const modelMax = modelValues.length ? Math.max(...modelValues) : null; - const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null; - const peakWindow = - paceView?.peakWindowText || - (row ? getPeakWindowLabel(row) : null) || - "--"; - const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null; - const isHkoObservation = isHkoObservationCity(detail); - const currentTemp = - (isHkoObservation - ? detail?.current?.temp ?? row?.current_temp - : detail?.airport_primary?.temp ?? - detail?.airport_current?.temp ?? - detail?.current?.temp ?? - row?.current_temp) ?? null; - const debNumber = toFiniteDecisionNumber(deb); - const currentTempNumber = toFiniteDecisionNumber(currentTemp); - const modelRange = - modelMin != null && modelMax != null - ? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}` - : "--"; - const paceTone = paceView?.biasTone || "neutral"; - const paceText = - paceView?.summary || - (isEn - ? "Waiting for intraday observations to compare against the DEB path." - : "等待更多日内实测,用来对照 DEB 预测路径。"); - const report = isHkoObservation - ? "" - : detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; - const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn); - const observationStation = isHkoObservation - ? detail?.current?.station_name || - detail?.current?.station_code || - detail?.settlement_station?.settlement_station_label || - detail?.settlement_station?.settlement_station_code || - "香港天文台" - : detail?.risk?.icao || - detail?.current?.station_code || - detail?.airport_current?.station_code || - detail?.airport_primary?.station_code || - ""; - const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测"; - const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations"; - const rawObservationText = isHkoObservation - ? `${isEn ? "Observation source" : "观测来源"}:${observationStation || (isEn ? "Hong Kong Observatory" : "香港天文台")}${metarReportTimeDisplay ? `,${metarReportTimeDisplay}` : ""}` - : report - ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${observationStation} ${report}`.trim()}` - : isEn - ? "Raw METAR: unavailable." - : "原始 METAR:暂无。"; - const detailCityName = detail?.name || item.cityName; - const [refreshingDetail, setRefreshingDetail] = useState(false); - const { aiForecast, refreshAiForecast } = useAiCityForecast({ - detail, - detailCityName, - enabled: Boolean(detail), - isEn, - locale, - report, - }); - const { marketScan, marketStatus } = useCityMarketScan({ - detail, - detailCityName, - enabled: Boolean(detail), - }); - const isRefreshing = refreshingDetail || aiForecast.status === "loading"; - const [isCompactCard, setIsCompactCard] = useState(false); - - useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) return; - const media = window.matchMedia("(max-width: 820px)"); - const syncCompactMode = () => setIsCompactCard(media.matches); - syncCompactMode(); - media.addEventListener("change", syncCompactMode); - return () => media.removeEventListener("change", syncCompactMode); - }, []); - - const aiCityForecast = aiForecast.payload?.city_forecast || null; - const localizedFinalJudgmentRaw = - (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || - (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || - ""; - const localizedMetarReadRaw = - (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || - ""; - const localizedReasoningRaw = - (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || - ""; - const localizedFinalJudgment = normalizeMetarReadTime( - localizedFinalJudgmentRaw, - metarReportTimeDisplay, - isEn, - ); - const localizedMetarRead = normalizeMetarReadTime( - localizedMetarReadRaw, - metarReportTimeDisplay, - isEn, - ); - const localizedReasoning = normalizeMetarReadTime( - localizedReasoningRaw, - metarReportTimeDisplay, - isEn, - ); - const localizedModelNote = - (isEn - ? aiCityForecast?.model_cluster_note_en - : aiCityForecast?.model_cluster_note_zh) || ""; - const modelPreview = modelEntries - .slice(0, 4) - .map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`) - .join(isEn ? " / " : " / "); - const localModelSupportNote = modelEntries.length - ? isEn - ? modelEntries.length <= 2 - ? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.` - : `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.` - : modelEntries.length <= 2 - ? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。` - : `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。` - : isEn - ? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.` - : `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`; - const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max); - const decisionExpectedHighNumber = resolveExpectedHighCandidate({ - aiPredictedMax, - currentTemp: currentTempNumber, - deb: debNumber, - modelMax, - modelMin, - paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null, - }); - const decisionView = buildWeatherDecisionView({ - aiCityForecast, - currentTemp: currentTempNumber, - deb: debNumber, - isEn, - localModelSupportNote, - modelEntries, - modelMax, - modelMin, - paceTone, - paceView, - peakWindow, - tempSymbol, - }); - const expectedHighText = - decisionExpectedHighNumber != null - ? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 }) - : "--"; - const currentTempText = - currentTempNumber != null - ? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 }) - : "--"; - const debText = - debNumber != null - ? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 }) - : "--"; - const marketDecisionView = buildMarketDecisionView({ - expectedHigh: decisionExpectedHighNumber, - isEn, - marketScan, - marketStatus, - tempSymbol, - }); - const aiMeta = aiCityForecast?._polyweather_meta || null; - const guardReason = aiMeta?.deterministic_guard_reason || {}; - const observationStale = Boolean( - detail?.metar_status?.stale_for_today || - detail?.airport_current?.stale_for_today || - detail?.current?.observation_status === "stale" || - guardReason.observation_stale, - ); - const observedHighBreak = Boolean( - guardReason.observed_high_break || - (currentTempNumber != null && - modelMax != null && - currentTempNumber > modelMax + 0.2), - ); - const observedLowBreak = Boolean(guardReason.observed_low_break); - const observedLowLag = Boolean(guardReason.observed_low_lag); - const peakHasPassed = Boolean( - guardReason.peak_has_passed || - ["past", "post_peak", "after_peak"].includes( - String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(), - ), - ); - const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null; - const modelHighlyConsistent = - modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2; - const needsNextBulletin = - !observationStale && - !observedHighBreak && - !observedLowBreak && - !peakHasPassed && - (observedLowLag || paceTone === "neutral" || aiForecast.status === "loading"); - const aiRuleEvidenceMode = Boolean( - aiForecast.status === "failed" || - (aiForecast.status === "ready" && !aiCityForecast) || - aiForecast.payload?.degraded || - aiMeta?.fallback, - ); - const aiReadInProgressText = isEn - ? isHkoObservation - ? "Fast read is ready; AI is adding HKO observation details..." - : "Fast read is ready; AI is adding airport bulletin details..." - : isHkoObservation - ? "快速判断已完成,AI 正在补充香港天文台观测细节…" - : "快速判断已完成,AI 正在补充机场报文细节…"; - const aiReadCompleteText = isEn - ? isHkoObservation - ? "AI HKO observation read is complete." - : "AI airport bulletin read is complete." - : isHkoObservation - ? "AI 香港天文台观测解读已完成" - : "AI 机场报文解读已完成"; - const aiRuleEvidenceText = isEn - ? "AI read did not return completely; rule evidence is being used." - : "AI 解读未完整返回,当前使用规则证据"; - const aiStatusLabel = - aiForecast.status === "loading" - ? isEn - ? "Fast read ready" - : "快速判断已完成" - : aiForecast.status === "ready" && aiCityForecast - ? aiRuleEvidenceMode - ? isEn - ? "Rule evidence" - : "规则证据模式" - : isEn - ? "AI read complete" - : "AI 解读已完成" - : aiRuleEvidenceMode - ? isEn - ? "Rule evidence" - : "规则证据模式" - : isEn - ? "AI pending" - : "AI 待返回"; - const aiStatusTone: StatusTagTone = - aiForecast.status === "loading" - ? "blue" - : aiForecast.status === "ready" && aiCityForecast - ? aiRuleEvidenceMode - ? "amber" - : "green" - : aiRuleEvidenceMode - ? "amber" - : "muted"; - const marketStatusTone: StatusTagTone = - marketDecisionView.status === "ready" - ? "green" - : marketDecisionView.status === "loading" - ? "blue" - : "muted"; - const dataFreshnessRows = [ - { - label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR", - value: buildObservationFreshnessValue({ - detail, - displayTime: metarReportTimeDisplay, - isEn, - isHkoObservation, - }), - tone: observationStale ? "stale" : "fresh", - }, - { - label: isEn ? "Models" : "模型", - value: buildModelFreshnessValue(detail, locale, isEn), - tone: "fresh", - }, - { - label: isEn ? "Market" : "市场价格", - value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }), - tone: - marketDecisionView.status === "ready" - ? "fresh" - : marketDecisionView.status === "loading" - ? "loading" - : "stale", - }, - ]; - const freshnessSeparator = isEn ? ": " : ":"; - const statusTags = uniqueStatusTags([ - observedHighBreak - ? { - label: isEn ? "Observed breakout" : "实测突破", - tone: "red", - } - : null, - peakHasPassed - ? { - label: isEn ? "Peak window passed" : "峰值窗口已过", - tone: "muted", - } - : null, - observationStale - ? { - label: isEn - ? isHkoObservation - ? "HKO stale" - : "METAR stale" - : isHkoObservation - ? "观测过旧" - : "METAR 过旧", - tone: "amber", - } - : null, - observedLowBreak - ? { - label: isEn ? "Peak revised down" : "峰值下修", - tone: "blue", - } - : null, - aiForecast.status === "loading" - ? { - label: isEn ? "Fast read ready" : "快速判断已完成", - tone: aiStatusTone, - } - : null, - marketDecisionView.status === "unavailable" - ? { - label: isEn ? "Market unavailable" : "市场价暂不可用", - tone: marketStatusTone, - } - : null, - modelHighlyConsistent - ? { - label: isEn ? "Models agree" : "模型高度一致", - tone: "green", - } - : null, - observedLowLag || needsNextBulletin - ? { - label: isEn ? "Wait next report" : "需要等待下一报文", - tone: "amber", - } - : null, - ]).slice(0, 3); - const localizedRisksRaw = - (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; - const localizedRisks = Array.isArray(localizedRisksRaw) - ? localizedRisksRaw - : localizedRisksRaw - ? [String(localizedRisksRaw)] - : []; - const aiBullets = [ - localizedMetarRead, - localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", - localizedModelNote || localModelSupportNote, - ...localizedRisks, - ].filter((line) => String(line || "").trim()); - const fallbackAiReason = - (isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) || - aiForecast.payload?.reason || - ""; - const decisionWhyText = observedHighBreak - ? isEn - ? "Worth watching now: observation has broken above the model range." - : "当前值得关注:实测已突破模型上沿。" - : peakHasPassed - ? isEn - ? "Avoid chasing now: peak window has passed; wait to confirm no new high." - : "当前不宜追高:峰值窗口已过,等待确认是否还有新高。" - : observationStale - ? isEn - ? "Use as background only: observation is stale and needs the next report." - : "当前仅作背景:观测已过旧,需要下一报文确认。" - : marketDecisionView.status === "unavailable" - ? isEn - ? "Weather evidence remains usable, but no tradable quote is available yet." - : "当前可参考天气:暂无可交易价格。" - : modelHighlyConsistent - ? isEn - ? "Worth watching now: models agree; wait for observation confirmation." - : "当前值得关注:模型高度一致,等待实测确认。" - : needsNextBulletin - ? isEn - ? "Wait for confirmation: the next bulletin should decide direction." - : "当前建议等待:下一报文更适合决定方向。" - : isEn - ? "Watch the peak window and compare observations against the expected high." - : "当前重点:盯住峰值窗口,把实测与预计高点对照。"; - const marketLineText = - marketDecisionView.status === "ready" - ? `${marketDecisionView.bucketLabel} · ${marketDecisionView.priceText}` - : marketDecisionView.status === "loading" - ? isEn - ? "syncing" - : "同步中" - : isEn - ? "temporarily unavailable" - : "暂不可用"; - - const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; - - return ( -
- { - event.preventDefault(); - event.stopPropagation(); - if (refreshingDetail) return; - setRefreshingDetail(true); - void onRefreshCityDetail(detailCityName) - .catch(() => {}) - .finally(() => { - refreshAiForecast(); - setRefreshingDetail(false); - }); - }} - onRemove={(event) => { - event.preventDefault(); - event.stopPropagation(); - onRemove(); - }} - onToggleCollapsed={onToggleCollapsed} - peakWindow={peakWindow} - removing={removing} - rowLocalTime={row?.local_time} - statusTags={statusTags} - /> - - {detail && !collapsed ? ( -
- - -
- - -
- -
-
- {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"} -
- -
- -
- ) : !detail ? ( -
- -
- ) : null} -
- ); -} export function AiPinnedForecastView({ items, diff --git a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx new file mode 100644 index 00000000..fcc61732 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { ChevronDown, RefreshCw, X } from "lucide-react"; +import type { MouseEvent } from "react"; +import { + CityStatusTags, + type CityStatusTag, + type StatusTone, +} from "@/components/dashboard/scan-terminal/CityStatusTags"; +import { + DataFreshnessBar, + type DataFreshnessRow, +} from "@/components/dashboard/scan-terminal/DataFreshnessBar"; + +export function CityCardHeader({ + aiStatusLabel, + aiStatusTone, + collapseId, + collapsed, + currentTempText, + dataFreshnessRows, + debText, + detailLocalTime, + displayName, + expectedHighText, + freshnessSeparator, + isEn, + isRefreshing, + modelRange, + onRefresh, + onRemove, + onToggleCollapsed, + peakWindow, + removing, + rowLocalTime, + statusTags, +}: { + aiStatusLabel: string; + aiStatusTone: StatusTone; + collapseId: string; + collapsed: boolean; + currentTempText: string; + dataFreshnessRows: DataFreshnessRow[]; + debText: string; + detailLocalTime?: string | null; + displayName: string; + expectedHighText: string; + freshnessSeparator: string; + isEn: boolean; + isRefreshing: boolean; + modelRange: string; + onRefresh: (event: MouseEvent) => void; + onRemove: (event: MouseEvent) => void; + onToggleCollapsed: () => void; + peakWindow: string; + removing?: boolean; + rowLocalTime?: string | null; + statusTags: CityStatusTag[]; +}) { + return ( +
+
+ + {isEn ? "Deep analysis" : "城市深度分析"} + +

{displayName}

+
+ + {isEn ? "Observed" : "当前温度"} + {currentTempText} + + + {isEn ? "Expected high" : "预测高点"} + {expectedHighText} + + + {isEn ? "Peak" : "峰值时间"} + {peakWindow} + +
+ +
+ {detailLocalTime || rowLocalTime || "--"} + DEB {debText} + {isEn ? "Model" : "模型"} {modelRange} + {isEn ? "Peak" : "峰值"} {peakWindow} +
+ +
+
+ {isEn ? "Expected high" : "预计最高温"} + {expectedHighText} +
+ + + +
+
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx b/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx new file mode 100644 index 00000000..7c948f1c --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx @@ -0,0 +1,25 @@ +"use client"; + +import clsx from "clsx"; + +export type StatusTone = "green" | "blue" | "amber" | "red" | "muted"; + +export type CityStatusTag = { + label: string; + tone: StatusTone; +}; + +export function CityStatusTags({ tags }: { tags: CityStatusTag[] }) { + return ( +
+ {tags.map((tag) => ( + + {tag.label} + + ))} +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx b/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx new file mode 100644 index 00000000..054c3c63 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { StatusTone } from "@/components/dashboard/scan-terminal/CityStatusTags"; + +export type DataFreshnessRow = { + label: string; + value: string; + tone: string; +}; + +export function DataFreshnessBar({ + aiStatusLabel, + aiStatusTone, + freshnessSeparator, + isEn, + rows, +}: { + aiStatusLabel: string; + aiStatusTone: StatusTone; + freshnessSeparator: string; + isEn: boolean; + rows: DataFreshnessRow[]; +}) { + return ( +
+ {isEn ? "Data freshness" : "数据新鲜度"} + {rows.map((freshness) => ( + + {freshness.label}{freshnessSeparator} + {freshness.value} + + ))} + + AI{freshnessSeparator} + {aiStatusLabel} + +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/MarketDecisionLine.tsx b/frontend/components/dashboard/scan-terminal/MarketDecisionLine.tsx new file mode 100644 index 00000000..1680156a --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/MarketDecisionLine.tsx @@ -0,0 +1,51 @@ +"use client"; + +import clsx from "clsx"; +import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; + +export function MarketDecisionLine({ + isEn, + marketDecisionView, + marketLineText, +}: { + isEn: boolean; + marketDecisionView: MarketDecisionView; + marketLineText: string; +}) { + return ( + <> +
+ {isEn ? "Market price" : "市场价格"} + {marketLineText} +
+
+
+ {isEn ? "Polymarket price layer" : "Polymarket 价格层"} + {marketDecisionView.title} +

{marketDecisionView.reason}

+
+
+ + {isEn ? "Bucket" : "温度桶"} {marketDecisionView.bucketLabel} + + + {isEn ? "YES buy" : "YES 买价"} {marketDecisionView.priceText} + + + {isEn ? "Model-market" : "模型-市场差"} {marketDecisionView.edgeText} + +
+ {marketDecisionView.marketUrl ? ( + + {isEn ? "Open market" : "打开市场"} + + ) : null} +
+ + ); +} diff --git a/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx new file mode 100644 index 00000000..2327af0d --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { ModelForecast } from "@/components/dashboard/PanelSections"; +import type { CityDetail } from "@/lib/dashboard-types"; + +export function ModelEvidencePanel({ + detail, + isEn, +}: { + detail: CityDetail; + isEn: boolean; +}) { + return ( +
+
+ {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"} +
+ +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/WeatherDecisionBand.tsx b/frontend/components/dashboard/scan-terminal/WeatherDecisionBand.tsx new file mode 100644 index 00000000..54b542ad --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/WeatherDecisionBand.tsx @@ -0,0 +1,86 @@ +"use client"; + +import clsx from "clsx"; +import type { + MarketDecisionView, + WeatherDecisionView, +} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; +import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine"; + +export function WeatherDecisionBand({ + currentTempText, + decisionView, + decisionWhyText, + isEn, + longText, + marketDecisionView, + marketLineText, + paceDeltaText, + peakWindow, +}: { + currentTempText: string; + decisionView: WeatherDecisionView; + decisionWhyText: string; + isEn: boolean; + longText: string; + marketDecisionView: MarketDecisionView; + marketLineText: string; + paceDeltaText: string; + peakWindow: string; +}) { + return ( +
+
+ {decisionView.kicker} + {decisionView.action} +

{decisionWhyText}

+

{longText}

+
+ {decisionView.reasons.map((reason, index) => ( + {reason} + ))} +
+

{decisionView.risk}

+ +
+
+ + {isEn ? "Expected high" : "预计高点"} + {decisionView.expectedHigh} + + + {isEn ? "Weather range" : "天气区间"} + {decisionView.targetRange} + + + {isEn ? "Confidence" : "信心"} + {decisionView.confidence} + + + {isEn ? "Observed" : "实测"} + {currentTempText} + + + {isEn ? "Path delta" : "路径偏差"} {paceDeltaText} + + + {isEn ? "Peak window" : "峰值窗口"} {peakWindow} + + + {isEn ? "Market implied" : "市场隐含"} {marketDecisionView.impliedText} + + + {isEn ? "Model prob" : "模型概率"} {marketDecisionView.modelText} + + + {isEn ? "Quote status" : "报价状态"}{" "} + {marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")} + +
+
+ ); +}