"use client"; import clsx from "clsx"; import Link from "next/link"; import { RefreshCw, Moon, Sun, UserRound, } from "lucide-react"; import { startTransition, useCallback, useDeferredValue, 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 { FilterState, ScanFilterPanel, } from "@/components/dashboard/ScanFilterPanel"; 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 { DashboardStoreProvider, useDashboardStore, } from "@/hooks/useDashboardStore"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { dashboardClient, type AssistantContextPayload, type AssistantOpportunityContext, } from "@/lib/dashboard-client"; import type { DistributionPreviewPoint, MarketScan, PrimarySignal, ScanOpportunityRow, ScanTerminalResponse, } from "@/lib/dashboard-types"; import { getLocalizedAirportName, getLocalizedCityName, } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue, normalizeTemperatureLabel, } 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, }; type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; type AssistantMessage = { id: string; role: "assistant" | "user"; content: string; }; function formatPercent(value?: number | null, signed = false) { if (value == null || Number.isNaN(Number(value))) return "--"; const numeric = Number(value); return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; } function formatProbability(value?: number | null) { if (value == null || Number.isNaN(Number(value))) return "--"; return `${(Number(value) * 100).toFixed(0)}%`; } function formatPrice(value?: number | null) { if (value == null || Number.isNaN(Number(value))) return "--"; return `${Math.round(Number(value) * 100)}¢`; } function formatVolume(value?: number | null) { const numeric = Number(value || 0); if (!Number.isFinite(numeric) || numeric <= 0) return "--"; if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`; if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`; return `$${numeric.toFixed(0)}`; } function formatRemainingWindow(value?: number | null, locale = "zh-CN") { if (value == null || !Number.isFinite(Number(value))) return "--"; const numeric = Math.max(0, Math.round(Number(value))); const hours = Math.floor(numeric / 60); const minutes = numeric % 60; if (locale === "en-US") { if (hours <= 0) return `${minutes}m`; return `${hours}h ${minutes}m`; } if (hours <= 0) return `${minutes} 分钟`; return `${hours}h ${minutes}m`; } function scoreTone(score?: number | null) { const numeric = Number(score || 0); if (numeric >= 85) return "green"; if (numeric >= 70) return "yellow"; return "red"; } function confidenceLabel(score?: number | null, locale = "zh-CN") { const numeric = Number(score || 0); if (locale === "en-US") { if (numeric >= 85) return "High"; if (numeric >= 70) return "Medium"; return "Watch"; } if (numeric >= 85) return "高"; if (numeric >= 70) return "中"; return "观察"; } 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 getSideRow( marketScan: MarketScan | null | undefined, selectedRow: ScanOpportunityRow, side: "yes" | "no", ) { const rows = Array.isArray(marketScan?.scan_rows) ? marketScan.scan_rows : []; const matched = rows.find( (row) => row.market_slug === selectedRow.market_slug && row.selected_date === selectedRow.selected_date && row.side === side, ); if (matched) return matched; if (selectedRow.side === side) return selectedRow; return null; } function getDetailSideAsk( scanRow: ScanOpportunityRow | null, marketScan: MarketScan | null | undefined, side: "yes" | "no", ) { if (scanRow?.ask != null) return scanRow.ask; if (side === "yes") { if (scanRow?.yes_ask != null) return scanRow.yes_ask; return marketScan?.yes_buy ?? null; } if (scanRow?.no_ask != null) return scanRow.no_ask; return marketScan?.no_buy ?? null; } function getDetailSideBid( scanRow: ScanOpportunityRow | null, marketScan: MarketScan | null | undefined, side: "yes" | "no", ) { if (scanRow?.bid != null) return scanRow.bid; if (side === "yes") { if (scanRow?.yes_bid != null) return scanRow.yes_bid; return marketScan?.yes_sell ?? null; } if (scanRow?.no_bid != null) return scanRow.no_bid; return marketScan?.no_sell ?? null; } function buildComparisonBuckets( marketScan: MarketScan | null | undefined, row: ScanOpportunityRow | null, ) { const rowPreview = Array.isArray(row?.distribution_preview) ? row.distribution_preview.filter( (item): item is DistributionPreviewPoint => Boolean(item && (item.label || item.value != null)), ) : []; if (rowPreview.length) { return rowPreview.slice(0, 6).map((item) => ({ label: String(item.label ?? item.value ?? "--"), model: Number(item.model_probability ?? 0) * 100, market: Number(item.market_probability ?? 0) * 100, highlighted: Boolean(item.highlighted), })); } const buckets = Array.isArray(marketScan?.top_buckets) ? marketScan?.top_buckets : Array.isArray(marketScan?.all_buckets) ? marketScan?.all_buckets?.slice(0, 6) : []; if (buckets.length) { return buckets .slice(0, 6) .map((bucket) => ({ label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"), model: Number(bucket.probability ?? 0) * 100, market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100, highlighted: false, })) .filter((bucket) => bucket.label !== "--"); } if (!row) return []; return [ { label: row.target_label || "--", model: Number(row.model_event_probability || 0) * 100, market: Number(row.market_event_probability || 0) * 100, highlighted: true, }, ]; } function hashClientText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) | 0; } return Math.abs(hash).toString(16).padStart(8, "0").slice(0, 8); } function normalizeAssistantProbability(value?: number | null) { if (!Number.isFinite(Number(value))) return null; const numeric = Number(value); return Math.abs(numeric) <= 1 ? numeric : numeric / 100; } function getAssistantSidePrice(row: ScanOpportunityRow, side: "yes" | "no") { if (side === "yes") { return row.yes_ask ?? (String(row.side || "").toLowerCase() === "yes" ? row.ask : null); } return row.no_ask ?? (String(row.side || "").toLowerCase() === "no" ? row.ask : null); } function buildAssistantOpportunity( row: ScanOpportunityRow, locale: string, ): AssistantOpportunityContext { const tempSymbol = row.temp_symbol || "°C"; const marketLabel = normalizeTemperatureLabel(row.target_label, tempSymbol) || row.market_question || row.target_label || null; const bestSide = String(row.side || "").toUpperCase(); return { city_name: row.city, city_display_name: getLocalizedCityName( row.city, row.city_display_name || row.display_name || row.city, locale, ), airport: getLocalizedAirportName(row.city, row.airport || "", locale), risk_level: row.risk_level || "low", tradable: row.tradable === true && row.closed !== true && row.active !== false && row.accepting_orders !== false, local_time: row.local_time || null, current_temperature: Number.isFinite(Number(row.current_temp)) ? Number(row.current_temp) : null, deb_prediction: Number.isFinite(Number(row.deb_prediction)) ? Number(row.deb_prediction) : null, temp_symbol: tempSymbol, today_high: Number.isFinite(Number(row.current_max_so_far)) ? Number(row.current_max_so_far) : Number.isFinite(Number(row.deb_prediction)) ? Number(row.deb_prediction) : null, market_question: row.market_question || null, market_label: marketLabel, selected_date: row.selected_date || row.local_date || null, best_side: bestSide === "YES" || bestSide === "NO" ? bestSide : null, yes_price: getAssistantSidePrice(row, "yes"), no_price: getAssistantSidePrice(row, "no"), edge_percent: Number.isFinite(Number(row.edge_percent)) ? Number(row.edge_percent) : null, market_probability: normalizeAssistantProbability( row.market_event_probability ?? row.market_probability, ), model_probability: normalizeAssistantProbability( row.model_event_probability ?? row.model_probability, ), status: row.closed ? "closed" : row.tradable ? "tradable" : "watch", }; } function buildAssistantContext(params: { terminalData: ScanTerminalResponse | null; rows: ScanOpportunityRow[]; selectedRow: ScanOpportunityRow | null; locale: string; totalCities: number; }): AssistantContextPayload { const opportunities = params.rows .slice(0, 52) .map((row) => buildAssistantOpportunity(row, params.locale)); const selectedCity = params.selectedRow ? buildAssistantOpportunity(params.selectedRow, params.locale) : opportunities[0] || null; const source = JSON.stringify({ generated_at: params.terminalData?.generated_at || "", rows: params.rows.slice(0, 20).map((row) => [ row.id, row.city, row.market_slug, row.side, row.edge_percent, row.yes_ask, row.no_ask, ]), }); return { snapshot_id: params.terminalData?.snapshot_id || `home-${hashClientText(source)}`, locale: params.locale, generated_at: params.terminalData?.generated_at || new Date().toISOString(), totals: { cities: params.totalCities || params.rows.length, tradable_markets: params.terminalData?.summary?.tradable_market_count ?? opportunities.filter((item) => item.tradable).length, high_risk: params.rows.filter((row) => row.risk_level === "high").length, medium_risk: params.rows.filter((row) => row.risk_level === "medium").length, low_risk: params.rows.filter((row) => row.risk_level === "low").length, }, selected_city: selectedCity, opportunities, glossary: params.locale === "en-US" ? [ { term: "edge", meaning: "Edge is model probability minus market-implied probability. Positive edge means the model is more favorable than the market price.", }, { term: "EMOS", meaning: "EMOS is the calibrated probability distribution for max-temperature buckets.", }, { term: "DEB", meaning: "DEB is PolyWeather's forecast anchor for the 24-hour maximum temperature.", }, ] : [ { term: "edge", meaning: "edge 是模型概率减去市场隐含概率。正 edge 表示模型判断比市场价格更有利。", }, { term: "EMOS", meaning: "EMOS 是最高温分桶的校准概率分布。", }, { term: "DEB", meaning: "DEB 是 PolyWeather 对 24 小时最高温的预测锚点。", }, ], }; } function ScanOpportunityDetailPanel({ row, marketScan, loading, }: { row: ScanOpportunityRow | null; marketScan?: MarketScan | null; loading?: boolean; }) { const store = useDashboardStore(); const { locale } = useI18n(); const isEn = locale === "en-US"; const isPro = store.proAccess.subscriptionActive; if (!row) { return ( ); } const localizedCityName = getLocalizedCityName( row.city, row.city_display_name || row.display_name || row.city, locale, ); const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale); const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined; const displayRow = detailSignal && detailSignal.market_slug === row.market_slug ? detailSignal : row; const tempSymbol = row.temp_symbol || displayRow.temp_symbol || "°C"; const yesRow = getSideRow(marketScan, row, "yes"); const noRow = getSideRow(marketScan, row, "no"); const comparisonBuckets = buildComparisonBuckets(marketScan, row); const maxBar = Math.max( 1, ...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]), ); const scoreClass = scoreTone(displayRow.final_score); const phaseMeta = getWindowPhaseMeta(displayRow, locale); const isCitySnapshot = String(row.window_phase || "").toLowerCase() === "city_snapshot" || !row.market_slug; const cityDetail = store.selectedDetail?.name?.toLowerCase() === row.city.toLowerCase() ? store.selectedDetail : store.cityDetailsByName[row.city] || null; const openTodayAnalysis = async () => { if (!row.city) return; await store.selectCity(row.city); await store.openTodayModal(); }; return ( ); } 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 OverviewMapView({ locale }: { locale: string }) { const store = useDashboardStore(); return (
{locale === "en-US" ? `Monitoring ${store.cities.length} cities on the original map canvas.` : `正在用原地图画布监控 ${store.cities.length} 个城市。`}
); } function AssistantWidget({ terminalData, rows, selectedRow, locale, totalCities, }: { terminalData: ScanTerminalResponse | null; rows: ScanOpportunityRow[]; selectedRow: ScanOpportunityRow | null; locale: string; totalCities: number; }) { const isEn = locale === "en-US"; const [open, setOpen] = useState(false); const [question, setQuestion] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [messages, setMessages] = useState(() => [ { id: "initial", role: "assistant", content: isEn ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", }, ]); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const dragStateRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number; moved: boolean; } | null>(null); const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); const context = useMemo( () => buildAssistantContext({ terminalData, rows, selectedRow, locale, totalCities, }), [locale, rows, selectedRow, terminalData, totalCities], ); useEffect(() => { setMessages((current) => current[0]?.id === "initial" ? [ { id: "initial", role: "assistant", content: isEn ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", }, ...current.slice(1), ] : current, ); }, [isEn]); useEffect(() => { const stored = window.localStorage.getItem("polyweather_ai_position_v1"); if (stored) { try { const parsed = JSON.parse(stored) as { x?: number; y?: number }; if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { setPosition({ x: Math.min(Math.max(Number(parsed.x), 12), window.innerWidth - 72), y: Math.min(Math.max(Number(parsed.y), 76), window.innerHeight - 72), }); return; } } catch { // Ignore invalid localStorage state. } } setPosition({ x: Math.max(16, window.innerWidth - 88), y: Math.max(88, window.innerHeight - 96), }); }, []); useEffect(() => { if (!position) return; window.localStorage.setItem( "polyweather_ai_position_v1", JSON.stringify(position), ); }, [position]); const clampPosition = useCallback((nextX: number, nextY: number, isPanel: boolean) => { const width = isPanel ? Math.min(380, window.innerWidth - 32) : 56; const height = isPanel ? Math.min(520, window.innerHeight - 96) : 56; return { x: Math.min(Math.max(nextX, 12), Math.max(12, window.innerWidth - width - 12)), y: Math.min(Math.max(nextY, 76), Math.max(76, window.innerHeight - height - 12)), }; }, []); const startDrag = useCallback( (event: React.PointerEvent) => { if (!position) return; dragStateRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: position.x, originY: position.y, moved: false, }; setDragging(true); event.currentTarget.setPointerCapture?.(event.pointerId); }, [position], ); const updateDrag = useCallback( (event: React.PointerEvent) => { const dragState = dragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) return; const deltaX = event.clientX - dragState.startX; const deltaY = event.clientY - dragState.startY; if (Math.abs(deltaX) + Math.abs(deltaY) > 4) { dragState.moved = true; } setPosition( clampPosition(dragState.originX + deltaX, dragState.originY + deltaY, open), ); }, [clampPosition, open], ); const endDrag = useCallback((event: React.PointerEvent) => { const dragState = dragStateRef.current; if (dragState?.pointerId === event.pointerId) { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (dragState.moved) { suppressClickRef.current = true; window.setTimeout(() => { suppressClickRef.current = false; }, 160); } window.setTimeout(() => { dragStateRef.current = null; }, 0); } }, []); const starterQuestions = useMemo(() => { const cityName = context.selected_city?.city_display_name || (isEn ? "the focus city" : "当前焦点城市"); return isEn ? [ "Which market is worth buying now?", "Rank opportunities by edge", `What is today's forecast high for ${cityName}?`, ] : [ "当前有哪些值得参与的市场?", "按 edge 排序", `${cityName} 今天预测最高温是多少?`, ]; }, [context.selected_city?.city_display_name, isEn]); const submitQuestion = useCallback( async (rawQuestion?: string) => { const nextQuestion = String(rawQuestion ?? question).trim(); if (!nextQuestion || submitting) return; const userMessage: AssistantMessage = { id: `user-${Date.now()}`, role: "user", content: nextQuestion, }; setMessages((current) => [...current, userMessage]); setQuestion(""); setError(null); setSubmitting(true); try { const response = await dashboardClient.askAssistant({ question: nextQuestion, locale, snapshotId: context.snapshot_id, context, }); setMessages((current) => [ ...current, { id: `assistant-${Date.now()}`, role: "assistant", content: response.answer, }, ]); } catch (submitError) { const message = String(submitError); const paywall = message.includes("402") || message.includes("assistant_requires_pro"); setError( paywall ? isEn ? "PolyWeather AI assistant is a Pro feature." : "AI 对话助手属于 Pro 功能,请先开通后使用。" : isEn ? "Assistant request failed. Try again after the market scan refreshes." : "AI 请求失败。请等市场扫描刷新后再试。", ); } finally { setSubmitting(false); } }, [context, isEn, locale, question, submitting], ); const handleLauncherClick = useCallback(() => { if (suppressClickRef.current || dragStateRef.current?.moved) return; setOpen(true); setPosition((current) => { const next = current || { x: window.innerWidth - 420, y: window.innerHeight - 520, }; return clampPosition(next.x, next.y, true); }); }, [clampPosition]); if (!position) return null; return (
{!open ? ( ) : (
{isEn ? "AI Assistant" : "AI 对话助手"} {isEn ? "Current market snapshot only" : "仅基于当前市场快照"}
{isEn ? "Uses only scan data already provided by PolyWeather; no invented prices, probabilities, or cities." : "只使用 PolyWeather 当前扫描数据,不编造价格、概率或城市。"}
{messages.map((message) => (

{message.content}

))} {submitting ? (

{isEn ? "Reading the current scan..." : "正在读取当前扫描数据..."}

) : null}
{starterQuestions.map((starter) => ( ))}