"use client"; import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import Link from "next/link"; import { BarChart3, ChevronDown, LogIn, Moon, Pin, Sun, UserRound, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import styles from "./Dashboard.module.css"; import detailChromeStyles from "./DetailPanelChrome.module.css"; import modalChromeStyles from "./ModalChrome.module.css"; import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel"; import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal"; import { MapCanvas } from "@/components/dashboard/MapCanvas"; import { ModelForecast } from "@/components/dashboard/PanelSections"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, useDashboardStore, } from "@/hooks/useDashboardStore"; import { useChart } from "@/hooks/useChart"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import type { CityDetail, ScanOpportunityRow, ScanTerminalResponse, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue, getModelView, getTemperatureChartData, getTodayPaceView, normalizeTemperatureLabel, } from "@/lib/dashboard-utils"; import { getMarketFocus, getRowMarketRegion, getRowPeakSortValue, } from "@/lib/scan-market-focus"; type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; type AiPinnedCity = { cityName: string; displayName?: string | null; addedAt: number; }; type AiCityForecastPayload = { status?: string | null; reason?: string | null; model?: string | null; provider?: string | null; city_forecast?: { predicted_max?: number | string | null; range_low?: number | string | null; range_high?: number | string | null; unit?: string | null; confidence?: string | null; final_judgment_zh?: string | null; final_judgment_en?: string | null; metar_read_zh?: string | null; metar_read_en?: string | null; reasoning_zh?: string | null; reasoning_en?: string | null; risks_zh?: string[] | null; risks_en?: string[] | null; model_cluster_note_zh?: string | null; model_cluster_note_en?: string | null; } | null; }; type AiCityForecastState = { status: "idle" | "loading" | "ready" | "failed"; payload?: AiCityForecastPayload | null; error?: string | null; }; function formatShortDate(value?: string | null, locale = "zh-CN") { const text = String(value || "").trim(); if (!text) return "--"; const date = new Date(`${text}T00:00:00`); if (Number.isNaN(date.getTime())) return text; return locale === "en-US" ? date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) : date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); } function formatCountdownMinutes(value?: number | null, locale = "zh-CN") { const numeric = Number(value); if (!Number.isFinite(numeric)) return "--"; const minutes = Math.max(0, Math.round(Math.abs(numeric))); const hours = Math.floor(minutes / 60); const remains = minutes % 60; if (locale === "en-US") { if (hours <= 0) return `${remains}m`; if (remains <= 0) return `${hours}h`; return `${hours}h ${remains}m`; } if (hours <= 0) return `${remains} 分钟`; if (remains <= 0) return `${hours} 小时`; return `${hours} 小时 ${remains} 分钟`; } function getPeakWindowLabel(row: ScanOpportunityRow) { const direct = String(row.peak_window_label || "").trim(); if (direct) return direct; const start = String(row.peak_window_start || "").trim(); const end = String(row.peak_window_end || "").trim(); if (start && end) return `${start}-${end}`; return "--"; } function getPeakCountdownMeta(row: ScanOpportunityRow, locale = "zh-CN") { const isEn = locale === "en-US"; const phase = String(row.window_phase || "").toLowerCase(); const startDelta = Number(row.minutes_until_peak_start); const endDelta = Number(row.minutes_until_peak_end); const hasStart = Number.isFinite(startDelta); const hasEnd = Number.isFinite(endDelta); if (phase === "active_peak" || (hasStart && startDelta <= 0 && hasEnd && endDelta >= 0)) { return { key: "active", groupLabel: isEn ? "Peak window now" : "峰值窗口进行中", tone: "active", sort: 0, title: isEn ? "At peak window" : "已进入峰值窗口", detail: hasEnd && endDelta >= 0 ? isEn ? `${formatCountdownMinutes(endDelta, locale)} left` : `剩余 ${formatCountdownMinutes(endDelta, locale)}` : getPeakWindowLabel(row), }; } if (hasStart && startDelta > 0 && startDelta <= 180) { return { key: "next", groupLabel: isEn ? "Next 3 hours" : "未来 3 小时到峰值", tone: "next", sort: 1000 + startDelta, title: isEn ? `${formatCountdownMinutes(startDelta, locale)} to peak` : `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`, detail: getPeakWindowLabel(row), }; } if (hasStart && startDelta > 0 && startDelta <= 1440) { return { key: "today", groupLabel: isEn ? "Later today" : "今日稍后", tone: "upcoming", sort: 2000 + startDelta, title: isEn ? `${formatCountdownMinutes(startDelta, locale)} to peak` : `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`, detail: getPeakWindowLabel(row), }; } if (hasStart && startDelta > 1440) { return { key: "later", groupLabel: isEn ? "Later sessions" : "后续交易时段", tone: "later", sort: 3000 + startDelta, title: isEn ? `${formatCountdownMinutes(startDelta, locale)} to peak` : `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`, detail: getPeakWindowLabel(row), }; } return { key: "past", groupLabel: isEn ? "Past peak" : "峰值已过", tone: "past", sort: 9000 + Math.abs(startDelta || 0), title: hasEnd && endDelta < 0 ? isEn ? `Peak passed ${formatCountdownMinutes(endDelta, locale)} ago` : `峰值已过 ${formatCountdownMinutes(endDelta, locale)}` : isEn ? "Peak window passed" : "峰值窗口已过", detail: getPeakWindowLabel(row), }; } function formatUserLocalTime() { const now = new Date(); return `${String(now.getHours()).padStart(2, "0")}:${String( now.getMinutes(), ).padStart(2, "0")}`; } function getLocalDateIndex(value?: string | null) { const text = String(value || "").trim(); if (!text) return 0; const date = new Date(`${text}T00:00:00`); if (Number.isNaN(date.getTime())) return 0; const today = new Date(); today.setHours(0, 0, 0, 0); return Math.round((date.getTime() - today.getTime()) / 86_400_000); } function getPhaseUrgency(row: ScanOpportunityRow) { const phase = String(row.window_phase || "").toLowerCase(); if (phase === "active_peak") return 0; if (phase === "setup_today") return 1; if (phase === "post_peak") return 2; if (phase === "early_today") return 3; if (phase === "tomorrow") return 4; if (phase === "week_ahead") return 5; return 6; } function sortRowsByUserTime(rows: ScanOpportunityRow[]) { const focus = getMarketFocus(rows); return [...rows].sort((left, right) => { if (focus) { const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1; const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1; if (leftFocusRank !== rightFocusRank) return leftFocusRank - rightFocusRank; } const leftPeakSort = getRowPeakSortValue(left); const rightPeakSort = getRowPeakSortValue(right); if (leftPeakSort.stage.rank !== rightPeakSort.stage.rank) { return leftPeakSort.stage.rank - rightPeakSort.stage.rank; } if (leftPeakSort.countdown !== rightPeakSort.countdown) { return leftPeakSort.countdown - rightPeakSort.countdown; } const leftDateIndex = getLocalDateIndex(left.selected_date || left.local_date); const rightDateIndex = getLocalDateIndex(right.selected_date || right.local_date); if (leftDateIndex !== rightDateIndex) return leftDateIndex - rightDateIndex; const leftRemaining = Number.isFinite(Number(left.remaining_window_minutes)) ? Number(left.remaining_window_minutes) : Number.POSITIVE_INFINITY; const rightRemaining = Number.isFinite(Number(right.remaining_window_minutes)) ? Number(right.remaining_window_minutes) : Number.POSITIVE_INFINITY; if (leftRemaining !== rightRemaining) return leftRemaining - rightRemaining; const leftPhase = getPhaseUrgency(left); const rightPhase = getPhaseUrgency(right); if (leftPhase !== rightPhase) return leftPhase - rightPhase; const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0); if (scoreDelta !== 0) return scoreDelta; return Number(right.edge_percent || 0) - Number(left.edge_percent || 0); }); } function normalizeCityKey(value?: string | null) { return String(value || "") .trim() .toLowerCase() .replace(/[\s_-]+/g, ""); } function prettifyCityName(value?: string | null) { return String(value || "") .trim() .replace(/[-_]+/g, " ") .replace(/\b\w/g, (match) => match.toUpperCase()); } function rowMatchesCity(row: ScanOpportunityRow, cityName: string) { const cityKey = normalizeCityKey(cityName); if (!cityKey) return false; return [row.city, row.city_display_name, row.display_name].some( (value) => normalizeCityKey(value) === cityKey, ); } function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) { const normalized = normalizeCityKey(cityName); if (!normalized) return null; return rows.find((row) => rowMatchesCity(row, cityName || "")) || null; } function CalendarView({ rows, locale, selectedRowId, onSelectRow, }: { rows: ScanOpportunityRow[]; locale: string; selectedRowId: string | null; onSelectRow: (row: ScanOpportunityRow) => void; }) { const groups = useMemo(() => { const order = ["active", "next", "today", "later", "past"]; const byPhase = new Map< string, { label: string; sort: number; items: Array<{ row: ScanOpportunityRow; meta: ReturnType }>; } >(); rows.forEach((row) => { const meta = getPeakCountdownMeta(row, locale); const current = byPhase.get(meta.key) || { label: meta.groupLabel, sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length, items: [], }; current.items.push({ row, meta }); byPhase.set(meta.key, current); }); return Array.from(byPhase.entries()) .sort(([, left], [, right]) => left.sort - right.sort) .map(([key, group]) => ({ key, label: group.label, items: group.items.sort((left, right) => { if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort; return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0); }), })); }, [locale, rows]); if (!groups.length) { return (
{locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"}
); } return (
{groups.map((group) => (
{group.label}
{locale === "en-US" ? "Ordered by DEB peak-window countdown" : "按 DEB 峰值窗口倒计时排序"}
{locale === "en-US" ? `${group.items.length} rows` : `${group.items.length} 条`}
{group.items.map(({ row, meta }) => ( ))}
))}
); } function findDetailForCity( detailsByName: Record, cityName?: string | null, ) { const target = normalizeCityKey(cityName); if (!target) return null; return ( Object.values(detailsByName).find((detail) => [detail?.name, detail?.display_name].some( (value) => normalizeCityKey(value) === target, ), ) || null ); } function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { const { locale } = useI18n(); const chartData = useMemo( () => getTemperatureChartData(detail, locale), [detail, locale], ); const forecastLabel = chartData?.datasets.hasMgmHourly ? locale === "en-US" ? "MGM forecast" : "MGM 预测" : locale === "en-US" ? "DEB forecast" : "DEB 预测"; const observationLabel = chartData?.observationLabel || (locale === "en-US" ? "METAR obs" : "METAR 实况"); const canvasRef = useChart(() => { if (!chartData) { return { data: { datasets: [], labels: [] }, type: "line", } satisfies ChartConfiguration<"line">; } const forecastPoints = chartData.datasets.hasMgmHourly ? chartData.datasets.mgmHourlyPoints : chartData.datasets.debPast.map( (value, index) => value ?? chartData.datasets.debFuture[index], ); return { data: { datasets: [ { borderColor: "#4DA3FF", borderWidth: 2, data: forecastPoints, fill: false, label: forecastLabel, pointRadius: 0, spanGaps: true, tension: 0.32, }, { backgroundColor: "#22C55E", borderColor: "#22C55E", borderWidth: 0, data: chartData.datasets.metarPoints, fill: false, label: observationLabel, pointHoverRadius: 5, pointRadius: 3.5, showLine: false, }, ], labels: chartData.times, }, options: { interaction: { intersect: false, mode: "index" }, layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } }, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: "rgba(11, 18, 32, 0.96)", borderColor: "rgba(77, 163, 255, 0.38)", borderWidth: 1, }, }, responsive: true, scales: { x: { grid: { color: "rgba(159, 178, 199, 0.08)" }, ticks: { callback: (_value, index) => typeof index === "number" && index % 4 === 0 ? chartData.times[index] : "", color: "#6B7A90", font: { size: 10 }, maxTicksLimit: 6, maxRotation: 0, }, }, y: { grid: { color: "rgba(159, 178, 199, 0.08)" }, max: chartData.max, min: chartData.min, ticks: { callback: (value) => `${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`, color: "#6B7A90", font: { size: 10 }, maxTicksLimit: 5, stepSize: chartData.yTickStep, }, }, }, }, type: "line", } satisfies ChartConfiguration<"line">; }, [chartData, detail.temp_symbol, forecastLabel, observationLabel]); return (
{locale === "en-US" ? "Intraday path" : "今日日内分析"}
{chartData ? (
{forecastLabel} {observationLabel}
) : null}
); } function AiPinnedCityCard({ item, detail, row, locale, collapsed, onRemove, onToggleCollapsed, }: { item: AiPinnedCity; detail: CityDetail | null; row: ScanOpportunityRow | null; locale: string; collapsed: boolean; 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 modelValues = modelView ? Object.values(modelView.models || {}) .map((value) => Number(value)) .filter((value) => Number.isFinite(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 currentTemp = detail?.airport_primary?.temp ?? detail?.airport_current?.temp ?? detail?.current?.temp ?? row?.current_temp ?? null; 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 = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; const airportStation = detail?.risk?.icao || detail?.current?.station_code || detail?.airport_current?.station_code || detail?.airport_primary?.station_code || ""; const [aiForecast, setAiForecast] = useState({ status: "idle", }); const detailCityName = detail?.name || item.cityName; const aiForecastKey = detail ? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}` : ""; useEffect(() => { if (!aiForecastKey || collapsed) return; let cancelled = false; setAiForecast({ status: "loading" }); fetch("/api/scan/terminal/ai-city", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, cache: "no-store", body: JSON.stringify({ city: detailCityName, force_refresh: false, }), }) .then(async (response) => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json() as Promise; }) .then((payload) => { if (!cancelled) { setAiForecast({ payload, status: "ready" }); } }) .catch((error) => { if (!cancelled) { setAiForecast({ error: String(error), status: "failed" }); } }); return () => { cancelled = true; }; }, [aiForecastKey, collapsed, detailCityName]); const aiCityForecast = aiForecast.payload?.city_forecast || null; const localizedFinalJudgment = (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; const localizedMetarRead = (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || ""; const localizedReasoning = (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; const localizedModelNote = (isEn ? aiCityForecast?.model_cluster_note_en : aiCityForecast?.model_cluster_note_zh) || ""; 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, ...localizedRisks, ].filter((line) => String(line || "").trim()); const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`; return (
{isEn ? "AI city forecast" : "AI 城市预测"}

{displayName}

{detail?.local_time || row?.local_time || "--"} DEB{" "} {deb != null ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) : "--"} {isEn ? "Model" : "模型"} {modelRange} {isEn ? "Peak" : "峰值"} {peakWindow}
{isEn ? "Expected high" : "预计最高温"} {paceView?.paceAdjustedHigh != null ? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 }) : deb != null ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) : "--"}
{detail && !collapsed ? (
{isEn ? "Final read" : "最终判断"} {paceTone === "warm" ? isEn ? "Running above DEB path" : "实测高于 DEB 路径" : paceTone === "cold" ? isEn ? "Running below DEB path" : "实测低于 DEB 路径" : isEn ? "Tracking DEB path" : "基本贴合 DEB 路径"}

{paceText}

{isEn ? "Observed" : "实测"}{" "} {currentTemp != null ? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 }) : "--"} {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} {isEn ? "Model support" : "模型支持"} {modelValues.length}
{isEn ? "AI airport weather read" : "AI 机场报文解读"}
{aiForecast.status === "loading" ? (

{isEn ? "Deepseek V4 flash is reading the latest airport bulletin..." : "Deepseek V4 flash 正在解读最新机场报文..."}

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

{localizedFinalJudgment || (isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")}

    {aiBullets.map((line, index) => (
  • {line}
  • ))}

{report ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` : isEn ? "Raw METAR: unavailable." : "原始 METAR:暂无。"}

) : aiForecast.status === "ready" ? (

{aiForecast.payload?.reason || (isEn ? "AI read is unavailable for this city right now." : "该城市暂时没有可用的 AI 解读。")}

) : aiForecast.status === "failed" ? (

{isEn ? "AI read failed. The raw METAR remains below as fallback context." : "AI 解读失败。下方仅保留原始 METAR 作为兜底上下文。"} {aiForecast.error ? ` ${aiForecast.error}` : ""}

) : (

{isEn ? "Waiting for AI to read the latest airport bulletin." : "等待 AI 解读最新机场报文。"}

)}
{isEn ? "Multi-model support" : "多模型支撑"}
) : !detail ? (
{isEn ? "Loading today analysis for this city..." : "正在加载该城市的今日日内分析..."}
) : null}
); } function AiPinnedForecastView({ items, rows, detailsByName, locale, onRemoveCity, }: { items: AiPinnedCity[]; rows: ScanOpportunityRow[]; detailsByName: Record; locale: string; onRemoveCity: (cityName: string) => void; }) { const isEn = locale === "en-US"; const [collapsedCities, setCollapsedCities] = useState>( () => new Set(), ); if (!items.length) { return (
{isEn ? "Click a city on the map" : "从分布视图点击城市"}
{isEn ? "Selected cities will appear here as pinned AI forecast blocks." : "被点击的城市会加入 AI 预测页,并保留为可固定的城市分析区块。"}
); } return (
{isEn ? "Pinned city workspace" : "固定城市工作区"} {isEn ? `${items.length} cities under AI forecast` : `${items.length} 个城市正在 AI 预测`}

{isEn ? "Map clicks add cities here. Pinned city analysis stays here until you remove it." : "地图点击会把城市加入这里;已固定的城市分析会保留,直到你手动移除。"}

{items.map((item) => { const detail = findDetailForCity(detailsByName, item.cityName); const row = findRowForCity(rows, item.cityName); const key = normalizeCityKey(item.cityName); const stableKey = key || item.cityName; return ( onRemoveCity(item.cityName)} onToggleCollapsed={() => { setCollapsedCities((current) => { const next = new Set(current); if (next.has(stableKey)) { next.delete(stableKey); } else { next.add(stableKey); } return next; }); }} /> ); })}
); } function AiForecastKPIBar({ pinnedCount, activeCityName, activeDetail, activeRow, locale, }: { pinnedCount: number; activeCityName?: string | null; activeDetail?: CityDetail | null; activeRow?: ScanOpportunityRow | null; locale: string; }) { const isEn = locale === "en-US"; const tempSymbol = activeDetail?.temp_symbol || activeRow?.temp_symbol || "°C"; const displayName = activeDetail?.display_name || activeRow?.city_display_name || activeRow?.display_name || activeCityName || "--"; const deb = activeDetail?.deb?.prediction ?? activeRow?.deb_prediction ?? null; const paceView = activeDetail ? getTodayPaceView(activeDetail, locale as "zh-CN" | "en-US") : null; const peakWindow = paceView?.peakWindowText || (activeRow ? getPeakWindowLabel(activeRow) : null) || "--"; const cards = [ { label: isEn ? "AI Workspace" : "AI 工作区", value: String(pinnedCount), note: isEn ? "Pinned cities from map clicks" : "地图点选后固定到 AI 预测", tone: "green", }, { label: isEn ? "Current City" : "当前城市", value: displayName, note: isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页", tone: "blue", }, { label: isEn ? "Forecast Center" : "预测中枢", value: deb != null ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) : "--", note: isEn ? "DEB final blended forecast" : "DEB 最终融合预测,不含市场价格", tone: "cyan", }, { label: isEn ? "Peak Window" : "峰值窗口", value: peakWindow, note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温", tone: "amber", }, ]; return (
{cards.map((card) => (
{card.label}
{card.value}
{card.note}
))}
); } function ScanTerminalScreen() { const store = useDashboardStore(); const { locale, toggleLocale } = useI18n(); const isEn = locale === "en-US"; const isPro = store.proAccess.subscriptionActive; const accountHref = store.proAccess.authenticated ? "/account" : "/auth/login?next=%2Faccount"; const [terminalData] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [aiPinnedCities, setAiPinnedCities] = useState([]); const [showScanPaywall, setShowScanPaywall] = useState(false); const [userLocalTime, setUserLocalTime] = useState("--"); const [themeMode, setThemeMode] = useState("dark"); const lastMapSelectedCityRef = useRef(""); const timeSortedRows = useMemo( () => sortRowsByUserTime(terminalData?.rows || []), [terminalData?.rows], ); const selectedRow = useMemo(() => { if (!timeSortedRows.length) return null; return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null; }, [timeSortedRows, selectedRowId]); const mapFocusedRow = useMemo(() => { return findRowForCity( timeSortedRows, mapSelectedCityName || store.selectedCity, ); }, [mapSelectedCityName, store.selectedCity, timeSortedRows]); const kpiCityName = mapSelectedCityName || store.selectedCity || aiPinnedCities[0]?.cityName || null; const kpiDetail = findDetailForCity(store.cityDetailsByName, kpiCityName) || (store.selectedDetail && normalizeCityKey(store.selectedDetail.name) === normalizeCityKey(kpiCityName) ? store.selectedDetail : null); const kpiRow = findRowForCity(timeSortedRows, kpiCityName); const mapFallbackRow = useMemo(() => { const rawCityName = mapSelectedCityName || store.selectedCity; const cityKey = normalizeCityKey(rawCityName); if (!cityKey || mapFocusedRow) return null; const selectedDetail = store.selectedDetail && normalizeCityKey(store.selectedDetail.name) === cityKey ? store.selectedDetail : Object.values(store.cityDetailsByName).find( (detail) => normalizeCityKey(detail?.name) === cityKey, ) || null; const selectedSummary = Object.values(store.citySummariesByName).find( (summary) => normalizeCityKey(summary?.name) === cityKey, ) || null; const selectedCityItem = store.cities.find( (city) => normalizeCityKey(city.name) === cityKey || normalizeCityKey(city.display_name) === cityKey, ) || null; const canonicalCity = selectedDetail?.name || selectedSummary?.name || selectedCityItem?.name || String(rawCityName || "").trim(); if (!canonicalCity) return null; const tempSymbol = selectedDetail?.temp_symbol || selectedSummary?.temp_symbol || (selectedCityItem?.temp_unit === "fahrenheit" ? "°F" : "°C"); const displayName = selectedDetail?.display_name || selectedSummary?.display_name || selectedCityItem?.display_name || canonicalCity; const currentTemp = selectedDetail?.current?.temp ?? selectedSummary?.current?.temp ?? null; return { id: `map-city:${canonicalCity}`, city: canonicalCity, city_display_name: displayName, display_name: displayName, selected_date: selectedDetail?.local_date || null, local_date: selectedDetail?.local_date || null, local_time: selectedDetail?.local_time || selectedSummary?.local_time || null, temp_symbol: tempSymbol, current_temp: currentTemp, current_max_so_far: selectedDetail?.current?.max_so_far ?? currentTemp ?? null, deb_prediction: selectedDetail?.deb?.prediction ?? selectedSummary?.deb?.prediction ?? null, airport: selectedDetail?.risk?.airport || selectedCityItem?.airport || selectedCityItem?.settlement_station_label || null, risk_level: selectedDetail?.risk?.level || selectedSummary?.risk?.level || selectedCityItem?.risk_level || "low", market_slug: null, market_question: isEn ? "City briefing" : "城市简报", target_label: isEn ? "City snapshot" : "城市概况", side: null, edge_percent: null, final_score: null, window_phase: "city_snapshot", tradable: false, active: false, closed: false, accepting_orders: false, } satisfies ScanOpportunityRow; }, [ isEn, mapFocusedRow, mapSelectedCityName, store.cityDetailsByName, store.citySummariesByName, store.cities, store.selectedCity, store.selectedDetail, ]); useEffect(() => { if (!store.proAccess.loading && !isPro && activeView === "calendar") { setActiveView("map"); } }, [activeView, isPro, store.proAccess.loading]); useEffect(() => { setUserLocalTime(formatUserLocalTime()); const intervalId = window.setInterval(() => { setUserLocalTime(formatUserLocalTime()); }, 10_000); return () => window.clearInterval(intervalId); }, []); useEffect(() => { const stored = window.localStorage.getItem("polyweather_scan_theme"); if (stored === "light") { setThemeMode("light"); } }, []); useEffect(() => { window.localStorage.setItem("polyweather_scan_theme", themeMode); }, [themeMode]); const resolvedView: ContentView = activeView; const mapFocusedCity = mapSelectedCityName || store.selectedCity; const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow || mapFallbackRow : selectedRow; const scanStatus = terminalData?.status || "ready"; const staleReason = terminalData?.stale_reason || null; useEffect(() => { if (!activeDetailRow) return; if (!store.cityDetailsByName[activeDetailRow.city]) { void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {}); } }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]); const addAiPinnedCity = useCallback((cityName: string) => { const cleanName = String(cityName || "").trim(); const key = normalizeCityKey(cleanName); if (!key) return; const matchedRow = findRowForCity(timeSortedRows, cleanName); const prettyName = prettifyCityName(cleanName); const displayName = matchedRow?.city_display_name || matchedRow?.display_name || getLocalizedCityName(cleanName, prettyName || cleanName, locale) || prettyName || cleanName; setAiPinnedCities((current) => { const existing = current.findIndex( (item) => normalizeCityKey(item.cityName) === key, ); const nextItem = { cityName: matchedRow?.city || cleanName, displayName, addedAt: Date.now(), }; if (existing >= 0) { const next = [...current]; next[existing] = { ...next[existing], ...nextItem }; return [ next[existing], ...next.filter((_, index) => index !== existing), ]; } return [nextItem, ...current].slice(0, 8); }); void store.ensureCityDetail(cleanName, false, "full").catch(() => {}); }, [locale, store.ensureCityDetail, timeSortedRows]); const removeAiPinnedCity = useCallback((cityName: string) => { const key = normalizeCityKey(cityName); setAiPinnedCities((current) => current.filter((item) => normalizeCityKey(item.cityName) !== key), ); }, []); useEffect(() => { aiPinnedCities.forEach((item) => { if (!findDetailForCity(store.cityDetailsByName, item.cityName)) { void store.ensureCityDetail(item.cityName, false, "full").catch(() => {}); } }); }, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]); const handleMapCitySelect = useCallback((cityName: string) => { setMapSelectedCityName(cityName); lastMapSelectedCityRef.current = normalizeCityKey(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); addAiPinnedCity(cityName); }, [addAiPinnedCity, timeSortedRows]); useEffect(() => { if (activeView !== "map") return; const selectedCity = String(store.selectedCity || "").trim(); const selectedKey = normalizeCityKey(selectedCity); if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return; lastMapSelectedCityRef.current = selectedKey; setMapSelectedCityName(selectedCity); const matchedRow = findRowForCity(timeSortedRows, selectedCity); setSelectedRowId(matchedRow?.id || null); addAiPinnedCity(selectedCity); }, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]); const handleSelectRow = useCallback((row: ScanOpportunityRow) => { const cityName = row.city || row.city_display_name || row.display_name || ""; if (!cityName) return; setSelectedRowId(row.id); const selectedCityKey = normalizeCityKey(store.selectedCity); const rowCityKey = normalizeCityKey(cityName); const hasCachedDetail = Boolean(store.cityDetailsByName[cityName]) || Object.values(store.cityDetailsByName).some((detail) => rowMatchesCity(row, detail?.name || detail?.display_name || ""), ); if (store.isPanelOpen && selectedCityKey === rowCityKey) { if (!hasCachedDetail) { void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); } return; } void store.selectCity(cityName); }, [store]); const openScanPaywall = useCallback(() => { setShowScanPaywall(true); }, []); const renderMainView = () => { if (resolvedView === "map") { return (
); } if (resolvedView === "list") { return ( ); } if (!isPro) { return (
{isEn ? "Scan is available on Pro" : "扫描功能需 Pro 权限"}
{isEn ? "Distribution view and city briefing remain available." : "分布视图和右侧城市简报仍可查看。"}
); } if (resolvedView === "calendar") { return ( ); } return null; }; if (store.proAccess.loading) { return (
{isEn ? "Checking access" : "正在检查权限"}
{isEn ? "Preparing your AI forecast workspace." : "正在准备 AI 预测台。"}
); } return (
{isEn ? "AI Forecast Terminal" : "AI 预测台"} {isEn ? "Click cities on the map to build an AI forecast workspace" : "点击地图城市加入 AI 预测工作区,按城市查看 DEB / 模型 / METAR"}
{userLocalTime} {isPro ? null : store.proAccess.authenticated ? ( ) : ( {isEn ? "Sign in" : "登录"} )} {store.proAccess.authenticated ? ( ) : null}
{terminalData?.stale ? ( {isEn ? "Delayed snapshot" : "延迟快照"} ) : null}
{scanStatus === "failed" && !terminalData ? (
{isEn ? "Scan failed" : "扫描失败"}
{staleReason}
) : ( renderMainView() )}
{showScanPaywall ? (
{ if (event.target === event.currentTarget) { setShowScanPaywall(false); } }} > setShowScanPaywall(false)} />
) : null}
); } export function ScanTerminalDashboard() { return ( ); }