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" : "不可用")} + +
+
+ ); +}