"use client"; import clsx from "clsx"; import Link from "next/link"; import { RefreshCw, Moon, Search, Sparkles, Sun, UserRound, } from "lucide-react"; import { startTransition, useCallback, useDeferredValue, useEffect, useMemo, 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 { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar"; import { OpportunityTable } from "@/components/dashboard/OpportunityTable"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, useDashboardStore, } from "@/hooks/useDashboardStore"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { dashboardClient } from "@/lib/dashboard-client"; import type { ScanOpportunityRow, ScanTerminalFilters, ScanTerminalResponse, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue } from "@/lib/dashboard-utils"; const DEFAULT_FILTERS: FilterState = { scan_mode: "tradable", min_price: 0.05, max_price: 0.95, min_edge_pct: 2, min_liquidity: 1000, high_liquidity_only: false, market_type: "maxtemp", time_range: "today", limit: 28, }; const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000; interface FilterState extends ScanTerminalFilters {} type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; 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 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 === "early_today") return 2; if (phase === "post_peak") return 3; if (phase === "tomorrow") return 4; if (phase === "week_ahead") return 5; return 6; } function sortRowsByUserTime(rows: ScanOpportunityRow[]) { return [...rows].sort((left, right) => { 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 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 byDate = new Map(); rows.forEach((row) => { const key = String(row.selected_date || row.local_date || "unknown"); const list = byDate.get(key) || []; list.push(row); byDate.set(key, list); }); return Array.from(byDate.entries()).sort(([left], [right]) => left.localeCompare(right)); }, [rows]); if (!groups.length) { return (
{locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"}
); } return (
{groups.map(([date, items]) => (
{formatShortDate(date, locale)}
{locale === "en-US" ? `${items.length} rows` : `${items.length} 条`}
{items.map((row) => ( ))}
))}
); } 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 [activeFilters, setActiveFilters] = useState(DEFAULT_FILTERS); const [terminalData, setTerminalData] = useState(null); const [loading, setLoading] = useState(false); const [aiLoading, setAiLoading] = useState(false); const [aiError, setAiError] = useState(null); const [error, setError] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); const [userLocalTime, setUserLocalTime] = useState("--"); const [themeMode, setThemeMode] = useState("dark"); const deferredRows = useDeferredValue(terminalData?.rows || []); const timeSortedRows = useMemo( () => sortRowsByUserTime(deferredRows), [deferredRows], ); 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 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, ]); const fetchTerminal = async (filters: FilterState, force = false) => { if (!isPro) return; setLoading(true); setAiError(null); try { const response = await dashboardClient.getScanTerminal(filters, { force }); startTransition(() => { setTerminalData(response); setActiveFilters(filters); setError(response.status === "failed" ? response.stale_reason || null : null); setSelectedRowId((current) => { if (current && response.rows.some((row) => row.id === current)) { return current; } return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null; }); }); } catch (fetchError) { setError(String(fetchError)); } finally { setLoading(false); } }; const runAiReview = async () => { if (!terminalData?.rows.length || aiLoading) return; setAiLoading(true); setAiError(null); try { const response = await dashboardClient.reviewScanTerminalWithAi({ filters: terminalData.filters || activeFilters, snapshotId: terminalData.snapshot_id || null, }); startTransition(() => { setTerminalData(response); setActiveFilters(response.filters || activeFilters); setError(response.status === "failed" ? response.stale_reason || null : null); setSelectedRowId((current) => { if (current && response.rows.some((row) => row.id === current)) { return current; } return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null; }); }); } catch (reviewError) { setAiError(String(reviewError)); } finally { setAiLoading(false); } }; useEffect(() => { if (!isPro) return; void fetchTerminal(DEFAULT_FILTERS, false); }, [isPro]); useEffect(() => { if (!isPro) return; const intervalId = window.setInterval(() => { void fetchTerminal(activeFilters, false); }, SCAN_AUTO_REFRESH_MS); return () => window.clearInterval(intervalId); }, [activeFilters, isPro]); useEffect(() => { if (!isPro && activeView !== "map") { setActiveView("map"); } }, [activeView, isPro]); 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 || (loading ? "loading" : error ? "failed" : "ready"); const staleReason = terminalData?.stale_reason || error || null; const aiScan = terminalData?.ai_scan || null; const aiStatusText = aiLoading ? isEn ? "V4 reviewing" : "V4 复核中" : aiError ? isEn ? "V4 failed" : "V4 失败" : aiScan?.status === "ready" ? isEn ? aiScan.cached ? "V4 cached" : "V4 reviewed" : aiScan.cached ? "V4 缓存" : "V4 已复核" : aiScan?.status ? isEn ? "Rule scan" : "使用规则扫描" : null; useEffect(() => { if (!activeDetailRow) return; if (!store.cityDetailsByName[activeDetailRow.city]) { void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {}); } }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]); const handleMapCitySelect = useCallback((cityName: string) => { setMapSelectedCityName(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); }, [timeSortedRows]); const handleSelectRow = useCallback((row: ScanOpportunityRow) => { setSelectedRowId(row.id); void store.selectCity(row.city); }, [store]); const openScanPaywall = useCallback(() => { setShowScanPaywall(true); }, []); const renderMainView = () => { if (resolvedView === "map") { return (
); } if (!isPro) { return (
{isEn ? "Scan is available on Pro" : "扫描功能需 Pro 权限"}
{isEn ? "Distribution view and city briefing remain available." : "分布视图和右侧城市简报仍可查看。"}
); } if (resolvedView === "calendar") { return ( ); } return ( <> ); }; if (store.proAccess.loading) { return (
{isEn ? "Checking access" : "正在检查权限"}
{isEn ? "Preparing your market scan terminal." : "正在准备市场扫描台。"}
); } return (
{isEn ? "Market Scan Terminal" : "市场扫描台"} {loading ? isEn ? "Refreshing current market snapshot" : "正在刷新当前市场快照" : terminalData?.stale ? isEn ? "Showing the last successful snapshot" : "当前显示上次成功快照" : isEn ? isPro ? "Read-only market scan with peak-first main signal" : "Free preview: distribution view and city briefing" : isPro ? "只读市场扫描,主信号按 EMOS 主峰优先" : "免费预览:分布视图和城市简报可查看"}
{userLocalTime} {isPro ? ( <> ) : ( )} {isPro ? ( ) : null}
{isPro ? ( ) : null}
{terminalData?.stale ? ( {isEn ? "Delayed snapshot" : "延迟快照"} ) : null} {loading ? ( {isEn ? "Refreshing" : "刷新中"} ) : null} {aiStatusText ? ( {aiStatusText} ) : null}
{scanStatus === "failed" && !terminalData ? (
{isEn ? "Scan failed" : "扫描失败"}
{staleReason}
) : ( renderMainView() )}
{showScanPaywall ? (
{ if (event.target === event.currentTarget) { setShowScanPaywall(false); } }} > setShowScanPaywall(false)} />
) : null}
); } export function ScanTerminalDashboard() { return ( ); }