Files
PolyWeather/frontend/components/dashboard/PolyWeatherDashboard.tsx
T

3319 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Search } from "lucide-react";
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import {
DashboardStoreProvider,
useDashboardStore,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import { ScanFilterPanel } from "@/components/dashboard/ScanFilterPanel";
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import type {
CityDetail,
CityListItem,
CitySummary,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
RiskLevel,
} from "@/lib/dashboard-types";
import type {
AssistantContextPayload,
AssistantOpportunityContext,
} from "@/lib/dashboard-client";
import { dashboardClient, getCityRevision } from "@/lib/dashboard-client";
import {
getLocalizedAirportDisplay,
getLocalizedCityDisplay,
} from "@/lib/dashboard-home-copy";
import {
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
const loadHistoryModal = () =>
import("@/components/dashboard/HistoryModal").then(
(module) => module.HistoryModal,
);
const loadFutureForecastModal = () =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
);
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then(
(module) => module.MapCanvas,
),
{
ssr: false,
loading: () => <div className="map" aria-hidden="true" />,
},
);
const HistoryModal = dynamic(loadHistoryModal, {
ssr: false,
loading: () => null,
});
const FutureForecastModal = dynamic(loadFutureForecastModal, {
ssr: false,
loading: () => null,
});
type CitySnapshot = {
city: CityListItem;
detail?: CityDetail | null;
score: number;
summary?: CitySummary | null;
tradableOpportunity: boolean;
};
const RISK_SCORE: Record<string, number> = {
high: 3,
medium: 2,
low: 1,
};
function getRiskCopy(level: RiskLevel | undefined, locale: string) {
if (level === "high") return locale === "en-US" ? "High variance" : "高波动";
if (level === "medium") return locale === "en-US" ? "Watch list" : "重点观察";
if (level === "low") return locale === "en-US" ? "Stable" : "低波动";
return locale === "en-US" ? "Unrated" : "待评级";
}
function getTempSymbol(
city: CityListItem,
summary?: CitySummary | null,
detail?: CityDetail | null,
) {
if (summary?.temp_symbol) return summary.temp_symbol;
if (detail?.temp_symbol) return detail.temp_symbol;
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
}
function formatTemperature(value: number | null | undefined, symbol: string) {
if (!Number.isFinite(Number(value))) return "--";
return `${Math.round(Number(value))}${symbol}`;
}
function formatPercent(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
return `${Math.round(Number(value) * 100)}%`;
}
function formatDelta(
current: number | null | undefined,
forecast: number | null | undefined,
symbol: string,
) {
if (!Number.isFinite(Number(current)) || !Number.isFinite(Number(forecast))) {
return "--";
}
const delta = Number(current) - Number(forecast);
const sign = delta > 0 ? "+" : "";
return `${sign}${delta.toFixed(1)}${symbol}`;
}
function buildSnapshot(
city: CityListItem,
summary?: CitySummary | null,
detail?: CityDetail | null,
): CitySnapshot {
const tier =
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level;
const hitRate = Number(city.deb_recent_hit_rate ?? 0);
const sampleCount = Number(city.deb_recent_sample_count ?? 0);
const normalizedEdge = normalizeEdgePercent(getMarketEdgeValue(detail));
const tradableOpportunity = isTradableMarketOpportunity(detail);
const score =
(detail?.market_scan && !tradableOpportunity ? -10_000 : 0) +
(tradableOpportunity && normalizedEdge != null
? 1000 + normalizedEdge * 10
: 0) +
(RISK_SCORE[String(tier || "")] || 0) * 100 +
hitRate * 100 +
Math.min(sampleCount, 60) / 10;
return { city, detail, score, summary, tradableOpportunity };
}
function getProbabilityLabel(
bucket: {
label?: string | null;
value?: number | null;
bucket?: string | null;
},
symbol: string,
) {
if (bucket.label)
return normalizeTemperatureBucketLabel(bucket.label, symbol);
if (bucket.bucket)
return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
if (Number.isFinite(Number(bucket.value))) {
return `≥ ${Math.round(Number(bucket.value))}${symbol}`;
}
return "--";
}
function normalizeTemperatureBucketLabel(label: string, symbol: string) {
const normalizedSymbol = symbol.includes("F") ? "°F" : "°C";
return String(label || "")
.trim()
.replace(
/(-?\d+(?:\.\d+)?)\s*°?\s*[CF]\b/gi,
(_, value) => `${Math.round(Number(value))}${normalizedSymbol}`,
);
}
function parseBucketThreshold(bucket?: {
label?: string | null;
value?: number | null;
temp?: number | null;
bucket?: string | null;
range?: string | null;
}) {
const directValue = bucket?.value ?? bucket?.temp;
if (Number.isFinite(Number(directValue))) return Number(directValue);
const text = String(bucket?.label || bucket?.bucket || bucket?.range || "");
const match = text.match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const parsed = Number(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
function getBucketMatchKey(bucket?: {
label?: string | null;
value?: number | null;
temp?: number | null;
bucket?: string | null;
range?: string | null;
}) {
const threshold = parseBucketThreshold(bucket);
if (threshold != null) return `t:${threshold.toFixed(2)}`;
return `l:${String(bucket?.label || bucket?.bucket || bucket?.range || "")
.trim()
.toLowerCase()}`;
}
function normalizeProbabilityValue(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
return Number(value);
}
function getDisplayScore(rawScore: number): number {
if (rawScore <= 0) return Math.max(0, 70 + rawScore / 1000); // Penalty cases
// Scale the positive scores to 80-100
// rawScore for tradable is roughly 1000 + edge*10 + risk*100 + hitRate*100
// typical high score is 1000 + 50 + 400 + 90 = 1540
if (rawScore > 1000) {
return Math.min(100, 80 + (rawScore - 1000) / 30);
}
return Math.min(80, Math.max(0, (rawScore / 1000) * 80));
}
function buildMarketAlignedProbabilities(
marketScan: MarketScan | null | undefined,
probabilityBuckets: ProbabilityBucket[],
symbol: string,
) {
const modelByKey = new Map(
probabilityBuckets.map((bucket) => [getBucketMatchKey(bucket), bucket]),
);
const marketBuckets: Array<MarketTopBucket | ProbabilityBucket> = [
...(Array.isArray(marketScan?.all_buckets) ? marketScan.all_buckets : []),
...(Array.isArray(marketScan?.top_buckets) ? marketScan.top_buckets : []),
...(marketScan?.temperature_bucket ? [marketScan.temperature_bucket] : []),
];
const aligned: ProbabilityBucket[] = [];
const seen = new Set<string>();
for (const marketBucket of marketBuckets) {
const key = getBucketMatchKey(marketBucket);
if (!key || seen.has(key)) continue;
seen.add(key);
const matchedModelBucket = modelByKey.get(key);
aligned.push({
label: getProbabilityLabel(marketBucket, symbol),
value:
marketBucket.value ??
("temp" in marketBucket ? marketBucket.temp : undefined) ??
matchedModelBucket?.value ??
null,
bucket:
("bucket" in marketBucket ? marketBucket.bucket : undefined) ??
matchedModelBucket?.bucket ??
null,
range:
("range" in marketBucket ? marketBucket.range : undefined) ??
matchedModelBucket?.range ??
null,
unit:
marketBucket.unit ??
matchedModelBucket?.unit ??
(symbol === "°F" ? "F" : "C"),
probability:
normalizeProbabilityValue(matchedModelBucket?.probability) ?? null,
});
}
return aligned.length ? aligned.slice(0, 4) : probabilityBuckets.slice(0, 4);
}
function formatProbability(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
return `${Math.round(normalized)}%`;
}
function formatCents(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
return `${Math.round(normalized)}¢`;
}
function formatEdge(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
const sign = normalized > 0 ? "+" : "";
return `${sign}${normalized.toFixed(1)}%`;
}
function getScanStatusLabel(detail?: CityDetail | null): string {
const scan = detail?.market_scan;
if (!scan?.available) return "无盘口";
const pa = scan?.price_analysis;
const bestSide = pa?.best_side;
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
const edgeVal =
sideView?.edge != null
? Math.abs(Number(sideView.edge)) > 1
? Number(sideView.edge) / 100
: Number(sideView.edge)
: null;
if (edgeVal != null && edgeVal >= 0.05) return "触达博弈";
if (edgeVal != null && edgeVal >= 0.02) return "即时确认";
if (edgeVal != null && edgeVal > 0) return "早期机会";
return "市场";
}
function formatVolume(value?: number | null): string {
if (value == null) return "--";
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${Math.round(value / 1_000)}K`;
return `$${value.toFixed(0)}`;
}
function normalizeEdgePercent(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function getBestSide(detail?: CityDetail | null) {
const bestSide = String(
detail?.market_scan?.price_analysis?.best_side || "",
).toLowerCase();
if (bestSide.includes("yes")) return "yes";
if (bestSide.includes("no")) return "no";
return null;
}
function getSideEdgePercent(
detail: CityDetail | null | undefined,
side: "yes" | "no",
) {
const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
}
function getExecutableEdgePercent(detail?: CityDetail | null) {
const bestSide = getBestSide(detail);
if (bestSide) {
const bestEdge = getSideEdgePercent(detail, bestSide);
if (bestEdge != null) return bestEdge;
}
const sideEdges = [
getSideEdgePercent(detail, "yes"),
getSideEdgePercent(detail, "no"),
].filter((value): value is number => value != null && value > 0);
if (sideEdges.length) return Math.max(...sideEdges);
return normalizeEdgePercent(detail?.market_scan?.edge_percent);
}
function getBestSideModelProbability(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const pYes = normalizeProbabilityValue(
marketScan?.price_analysis?.model_probability ??
marketScan?.model_probability ??
marketScan?.temperature_bucket?.probability,
);
if (pYes == null) return null;
return getBestSide(detail) === "no" ? 1 - pYes : pYes;
}
function getBestSideAsk(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const side = getBestSide(detail);
if (side === "yes") {
return (
marketScan?.yes_buy ??
marketScan?.yes_token?.buy_price ??
marketScan?.price_analysis?.yes?.ask
);
}
if (side === "no") {
return (
marketScan?.no_buy ??
marketScan?.no_token?.buy_price ??
marketScan?.price_analysis?.no?.ask
);
}
return marketScan?.market_price ?? marketScan?.midpoint ?? null;
}
function getTradeSideLabel(side: "yes" | "no" | null, locale: string) {
if (side === "yes") return locale === "en-US" ? "BUY YES" : "买 YES";
if (side === "no") return locale === "en-US" ? "BUY NO" : "买 NO";
return locale === "en-US" ? "WATCH" : "观察";
}
function getTradeFormulaLabel(side: "yes" | "no" | null, locale: string) {
if (side === "no") {
return locale === "en-US"
? "Edge = EMOS P(NO) - NO ask"
: "Edge = EMOS P(NO) - NO 买价";
}
return locale === "en-US"
? "Edge = EMOS P(YES) - YES ask"
: "Edge = EMOS P(YES) - YES 买价";
}
function isLiveMarketScan(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
if (!marketScan) return false;
if (marketScan.available === false) return false;
if (marketScan.primary_market?.closed === true) return false;
if (marketScan.primary_market?.active === false) return false;
if (marketScan.selected_date && detail?.local_date) {
if (marketScan.selected_date < detail.local_date) return false;
}
const endDateMs = marketScan.primary_market?.end_date
? Date.parse(marketScan.primary_market.end_date)
: Number.NaN;
if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
return true;
}
function buildSparklinePoints(values: number[] | undefined) {
if (!values?.length) return "";
const width = 92;
const height = 28;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
return values
.map((value, index) => {
const x =
values.length === 1 ? width : (index / (values.length - 1)) * width;
const y = height - ((value - min) / span) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
type AssistantMessage = {
id: string;
role: "assistant" | "user";
content: string;
cached?: boolean;
};
type OpportunityScanState = "pending" | "complete" | "empty" | "error";
const HOME_OPPORTUNITY_REFRESH_MS = 30_000;
type AssistantDockPosition = {
right: number;
bottom: number;
};
type AssistantDockDragState = {
pointerId: number;
startX: number;
startY: number;
right: number;
bottom: number;
hasMoved: boolean;
};
const HOME_AI_DOCK_POSITION_STORAGE_KEY =
"polyweather_home_ai_dock_position_v2";
function readAssistantDockPosition() {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AssistantDockPosition | null;
if (
parsed &&
Number.isFinite(Number(parsed.right)) &&
Number.isFinite(Number(parsed.bottom))
) {
return {
right: Number(parsed.right),
bottom: Number(parsed.bottom),
};
}
} catch {
// Ignore malformed dock position cache.
}
return null;
}
function writeAssistantDockPosition(position: AssistantDockPosition | null) {
if (typeof window === "undefined") return;
if (!position) {
window.localStorage.removeItem(HOME_AI_DOCK_POSITION_STORAGE_KEY);
return;
}
window.localStorage.setItem(
HOME_AI_DOCK_POSITION_STORAGE_KEY,
JSON.stringify(position),
);
}
function getDefaultAssistantDockPosition() {
if (typeof window === "undefined" || window.innerWidth <= 960) {
return null;
}
return {
right: 24,
bottom: 24,
};
}
function clampAssistantDockPosition(
position: AssistantDockPosition | null,
dockElement?: HTMLElement | null,
) {
if (typeof window === "undefined" || window.innerWidth <= 960) {
return null;
}
const rect = dockElement?.getBoundingClientRect();
const width = rect?.width || 340;
const height = rect?.height || 88;
const minOffset = 24;
const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset);
const maxBottom = Math.max(
minOffset,
window.innerHeight - height - minOffset,
);
const base = position || getDefaultAssistantDockPosition();
if (!base) return null;
return {
right: Math.min(maxRight, Math.max(minOffset, Number(base.right))),
bottom: Math.min(maxBottom, Math.max(minOffset, Number(base.bottom))),
};
}
function hashSnapshotText(source: string) {
let hash = 5381;
for (let index = 0; index < source.length; index += 1) {
hash = (hash * 33) ^ source.charCodeAt(index);
}
return Math.abs(hash >>> 0).toString(16);
}
function normalizeAssistantPercent(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function normalizeAssistantCents(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function buildAssistantOpportunityContext(
snapshot: CitySnapshot,
locale: string,
): AssistantOpportunityContext {
const { city, detail, summary, tradableOpportunity } = snapshot;
const symbol = getTempSymbol(city, summary, detail);
const marketScan = detail?.market_scan;
const marketBucket =
marketScan?.temperature_bucket || marketScan?.top_buckets?.[0] || null;
const marketQuestion =
marketScan?.primary_market?.question ||
(marketBucket
? `${getProbabilityLabel(marketBucket, symbol)} ${
marketScan?.selected_date || detail?.local_date || ""
}`
: null);
const localizedCityName = getLocalizedCityDisplay(
city,
locale,
summary,
detail,
);
const localizedAirport = getLocalizedAirportDisplay(city, locale, detail);
const marketProbability =
marketScan?.market_price ??
readNumericField(marketBucket, "market_price") ??
readNumericField(marketBucket, "yes_buy") ??
marketScan?.yes_buy;
const modelProbability =
marketScan?.model_probability ?? marketBucket?.probability ?? null;
return {
city_name: city.name,
city_display_name: localizedCityName,
airport: localizedAirport,
risk_level: String(
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level ||
"",
),
tradable: tradableOpportunity,
local_time: summary?.local_time || detail?.local_time || null,
current_temperature:
summary?.current?.temp ?? detail?.current?.temp ?? null,
deb_prediction: summary?.deb?.prediction ?? detail?.deb?.prediction ?? null,
temp_symbol: symbol,
today_high:
detail?.forecast?.today_high ??
detail?.current?.max_so_far ??
detail?.airport_current?.max_so_far ??
null,
market_question: marketQuestion,
market_label: marketBucket
? getProbabilityLabel(marketBucket, symbol)
: null,
selected_date: marketScan?.selected_date || detail?.local_date || null,
best_side: marketScan?.price_analysis?.best_side || null,
yes_price: normalizeAssistantCents(
marketScan?.yes_buy ??
readNumericField(marketBucket, "yes_buy") ??
marketScan?.yes_token?.buy_price ??
marketScan?.yes_token?.midpoint,
),
no_price: normalizeAssistantCents(
marketScan?.no_buy ??
readNumericField(marketBucket, "no_buy") ??
marketScan?.no_token?.buy_price ??
marketScan?.no_token?.midpoint,
),
edge_percent: normalizeAssistantPercent(getMarketEdgeValue(detail)),
market_probability: normalizeAssistantPercent(marketProbability),
model_probability: normalizeAssistantPercent(modelProbability),
status: tradableOpportunity
? "tradable"
: marketScan
? "inactive"
: "market_pending",
};
}
function buildAssistantContextPayload(
snapshots: CitySnapshot[],
selectedCity: string | null,
locale: string,
): AssistantContextPayload {
const opportunities = snapshots.map((snapshot) =>
buildAssistantOpportunityContext(snapshot, locale),
);
const revisionSeed = snapshots
.map((snapshot) => {
const revision =
getCityRevision(snapshot.detail) || getCityRevision(snapshot.summary);
return `${snapshot.city.name}:${revision}:${snapshot.tradableOpportunity ? 1 : 0}`;
})
.join("|");
const snapshotId = `home-${hashSnapshotText(revisionSeed || String(Date.now()))}`;
const selected =
opportunities.find((item) => item.city_name === selectedCity) || null;
return {
snapshot_id: snapshotId,
locale,
generated_at: new Date().toISOString(),
totals: {
cities: snapshots.length,
tradable_markets: opportunities.filter((item) => item.tradable).length,
high_risk: opportunities.filter((item) => item.risk_level === "high")
.length,
medium_risk: opportunities.filter((item) => item.risk_level === "medium")
.length,
low_risk: opportunities.filter((item) => item.risk_level === "low")
.length,
},
selected_city: selected,
opportunities,
glossary:
locale === "en-US"
? [
{
term: "edge",
meaning:
"Edge is the gap between model probability and market-implied probability. Positive edge means the model is more optimistic than the market.",
},
{
term: "market probability",
meaning:
"Market probability is the implied probability backed out from the live YES/NO price.",
},
{
term: "DEB",
meaning:
"DEB is the internal forecast anchor for the day-max settlement temperature.",
},
{
term: "EMOS",
meaning:
"EMOS is the calibrated probability ladder for the 24-hour max-temperature outcome buckets.",
},
]
: [
{
term: "edge",
meaning:
"edge 是模型概率和市场隐含概率之间的差值。正值表示模型比市场更乐观。",
},
{
term: "市场概率",
meaning:
"市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
},
{
term: "DEB",
meaning: "DEB 是系统对当日结算最高温的核心预测锚点之一。",
},
{
term: "EMOS",
meaning: "EMOS 是 24 小时最高温结果分桶使用的校准概率分布。",
},
],
};
}
function buildAssistantGreeting(
locale: string,
selectedCityName?: string | null,
) {
if (locale === "en-US") {
return selectedCityName
? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.`
: "Ask about current temperature, today's forecast high, market edge, or live opportunities.";
}
return selectedCityName
? `可以直接问我 ${selectedCityName} 的当前温度、今日最高温预测、市场 edge 或实时机会。`
: "可以直接问我当前温度、今日最高温预测、市场 edge 或实时机会。";
}
type HomeWeatherIconKind =
| "clear"
| "partly"
| "cloudy"
| "rain"
| "storm"
| "mist"
| "wind";
type HomeTrendChart = {
forecastPath: string;
legendText: string;
observationDots: Array<{ cx: number; cy: number; key: string }>;
hourlyReports: Array<{
key: string;
label: string;
temperatureText: string;
}>;
yAxisLabels: Array<{ key: string; label: string; y: number }>;
xAxisLabels: Array<{ key: string; label: string; x: number }>;
};
type HomeForecastDay = {
key: string;
label: string;
maxTemp: number;
};
type HomeSummaryCard = {
key: string;
title: string;
linkLabel?: string;
items: Array<{
label: string;
value: string;
accent?: "green" | "amber" | "red" | "cyan";
}>;
};
function buildDashboardSummaryCards(
snapshots: CitySnapshot[],
locale: string,
): HomeSummaryCard[] {
const topTradable = snapshots.filter(
(snapshot) => snapshot.tradableOpportunity,
);
const highCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "high",
).length;
const mediumCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "medium",
).length;
const lowCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "low",
).length;
const strongAgreement = snapshots.filter(
(snapshot) => Number(snapshot.city.deb_recent_hit_rate ?? 0) >= 0.66,
).length;
const mediumAgreement = snapshots.filter((snapshot) => {
const rate = Number(snapshot.city.deb_recent_hit_rate ?? 0);
return rate >= 0.4 && rate < 0.66;
}).length;
const weakAgreement = Math.max(
snapshots.length - strongAgreement - mediumAgreement,
0,
);
const avgEdge = topTradable.length
? topTradable.reduce((sum, snapshot) => {
const edgeValue = Number(getMarketEdgeValue(snapshot.detail));
const normalized =
Number.isFinite(edgeValue) && Math.abs(edgeValue) <= 1
? edgeValue * 100
: edgeValue;
return sum + (Number.isFinite(normalized) ? normalized : 0);
}, 0) / topTradable.length
: 0;
return [
{
key: "opportunities",
title: locale === "en-US" ? "Current Opportunities" : "当前机会分布",
items: [
{
label: locale === "en-US" ? "High risk" : "高风险",
value: String(highCount),
accent: "red",
},
{
label: locale === "en-US" ? "Medium risk" : "中风险",
value: String(mediumCount),
accent: "amber",
},
{
label: locale === "en-US" ? "Low risk" : "低风险",
value: String(lowCount),
accent: "green",
},
],
},
{
key: "market-summary",
title: locale === "en-US" ? "Market Summary" : "市场概览",
items: [
{
label: locale === "en-US" ? "Total markets" : "总市场数",
value: String(snapshots.length),
},
{
label: locale === "en-US" ? "Active" : "活跃市场",
value: String(topTradable.length),
accent: "cyan",
},
{
label: locale === "en-US" ? "Avg. edge" : "平均优势",
value: formatEdge(avgEdge),
accent: "green",
},
],
},
{
key: "model-agreement",
title: locale === "en-US" ? "Model Agreement" : "模型一致性",
items: [
{
label: locale === "en-US" ? "High" : "高",
value: `${strongAgreement}`,
accent: "green",
},
{
label: locale === "en-US" ? "Medium" : "中",
value: `${mediumAgreement}`,
accent: "amber",
},
{
label: locale === "en-US" ? "Low" : "低",
value: `${weakAgreement}`,
accent: "red",
},
],
},
{
key: "impact-window",
title: locale === "en-US" ? "High Impact Window" : "高影响窗口",
items: [
{
label: locale === "en-US" ? "Next 6 hours" : "未来 6 小时",
value:
locale === "en-US"
? `${Math.min(topTradable.length, 12)} markets`
: `${Math.min(topTradable.length, 12)} 个市场`,
},
{
label: locale === "en-US" ? "Focus city" : "焦点城市",
value: topTradable[0]?.city.display_name || "--",
accent: "cyan",
},
{
label: locale === "en-US" ? "Best edge" : "最佳优势",
value:
topTradable.length > 0
? formatEdge(getMarketEdgeValue(topTradable[0]?.detail))
: "--",
accent: "green",
},
],
},
];
}
function projectHomeTrendPoint(
x: number,
y: number,
xMin: number,
xMax: number,
yMin: number,
yMax: number,
) {
const width = 296;
const height = 78;
const left = 10;
const right = 10;
const top = 8;
const bottom = 12;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const normalizedX =
xMax === xMin ? 0.5 : Math.min(1, Math.max(0, (x - xMin) / (xMax - xMin)));
const normalizedY =
yMax === yMin ? 0.5 : Math.min(1, Math.max(0, (y - yMin) / (yMax - yMin)));
return {
cx: Number((left + normalizedX * plotWidth).toFixed(1)),
cy: Number((top + (1 - normalizedY) * plotHeight).toFixed(1)),
};
}
function getHomeWeatherIconKind(
detail?: CityDetail | null,
locale = "zh-CN",
): HomeWeatherIconKind {
if (!detail) return "cloudy";
const summary = getWeatherSummary(
detail,
locale === "en-US" ? "en-US" : "zh-CN",
);
const weatherText =
`${summary.weatherIcon} ${summary.weatherText} ${detail.current?.wx_desc || ""} ${detail.current?.cloud_desc || ""}`.toLowerCase();
const cloudCover = Number(detail.hourly_next_48h?.cloud_cover?.[0]);
const windSpeed = Number(
detail.current?.wind_speed_kt ?? detail.airport_current?.wind_speed_kt,
);
if (/⛈|雷|storm|thunder/.test(weatherText)) return "storm";
if (/🌧|🌦|雨|drizzle|shower|rain/.test(weatherText)) return "rain";
if (/🌫|雾|mist|fog|haze/.test(weatherText)) return "mist";
if (/💨|飑|squall/.test(weatherText) || windSpeed >= 22) return "wind";
if (/☀|晴|clear|sunny/.test(weatherText)) return "clear";
if (/🌤|⛅|partly|few|scattered|少云|散云/.test(weatherText)) return "partly";
if (/☁|云|cloud|overcast|阴/.test(weatherText)) return "cloudy";
if (Number.isFinite(cloudCover) && cloudCover <= 15) return "clear";
if (Number.isFinite(cloudCover) && cloudCover <= 55) return "partly";
return "cloudy";
}
function buildHomeTrendChart(
detail?: CityDetail | null,
locale = "zh-CN",
): HomeTrendChart | null {
if (!detail) return null;
const chartData = getTemperatureChartData(
detail,
locale === "en-US" ? "en-US" : "zh-CN",
);
if (!chartData) return null;
const forecastSeries = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlySeries
: [
...chartData.datasets.debPastSeries,
...chartData.datasets.debFutureSeries,
];
const observationSeries =
chartData.datasets.metarSeries.length > 0
? chartData.datasets.metarSeries
: chartData.datasets.airportMetarSeries;
if (!forecastSeries.length && !observationSeries.length) return null;
const forecastPath = forecastSeries
.map((point) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return `${projected.cx},${projected.cy}`;
})
.join(" ");
const observationDots = observationSeries.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
cx: projected.cx,
cy: projected.cy,
key: `${point.labelTime}-${index}`,
};
});
const hoverSource =
observationSeries.length > 0 ? observationSeries : forecastSeries;
const hourlyReports = hoverSource
.filter((point) => {
const label = String(point.labelTime || "");
return /(^|\D)\d{1,2}:00($|\D)/.test(label) || hoverSource.length <= 8;
})
.slice(-6)
.map((point, index) => ({
key: `hourly-${point.labelTime}-${index}`,
label: point.labelTime,
temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
}));
const yAxisLabels = [
{
key: "max",
label: formatTemperature(chartData.max, detail.temp_symbol || "°C"),
y: 14,
},
{
key: "mid",
label: formatTemperature(
(chartData.max + chartData.min) / 2,
detail.temp_symbol || "°C",
),
y: 36,
},
{
key: "min",
label: formatTemperature(chartData.min, detail.temp_symbol || "°C"),
y: 58,
},
];
const xAxisSource =
hoverSource.length <= 4
? hoverSource
: [
hoverSource[0],
hoverSource[Math.floor((hoverSource.length - 1) / 3)],
hoverSource[Math.floor(((hoverSource.length - 1) * 2) / 3)],
hoverSource[hoverSource.length - 1],
];
const xAxisLabels = xAxisSource.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
key: `axis-${point.labelTime}-${index}`,
label: point.labelTime,
x: Number(((projected.cx / 296) * 100).toFixed(2)),
};
});
return {
forecastPath,
legendText: chartData.legendText,
observationDots,
hourlyReports,
yAxisLabels,
xAxisLabels,
};
}
function buildHomeForecastDays(
detail?: CityDetail | null,
locale = "zh-CN",
): HomeForecastDay[] {
if (!detail) return [];
const todayLabel = locale === "en-US" ? "Today" : "今天";
const tomorrowLabel = locale === "en-US" ? "Tomorrow" : "明天";
const formatter = new Intl.DateTimeFormat(
locale === "en-US" ? "en-US" : "zh-CN",
{
day: "2-digit",
month: "2-digit",
},
);
const rows = Array.isArray(detail.forecast?.daily)
? detail.forecast.daily
: [];
const byDate = new Map<string, number>();
if (
detail.local_date &&
Number.isFinite(Number(detail.forecast?.today_high))
) {
byDate.set(detail.local_date, Number(detail.forecast?.today_high));
}
rows.forEach((row) => {
if (!row?.date || !Number.isFinite(Number(row.max_temp))) return;
if (!byDate.has(row.date)) {
byDate.set(row.date, Number(row.max_temp));
}
});
return [...byDate.entries()].slice(0, 4).map(([date, maxTemp], index) => {
const parsed = new Date(`${date}T00:00:00`);
const label =
index === 0
? todayLabel
: index === 1
? tomorrowLabel
: Number.isNaN(parsed.getTime())
? date
: formatter.format(parsed);
return {
key: date,
label,
maxTemp,
};
});
}
function readNumericField(source: unknown, key: string) {
if (!source || typeof source !== "object") return undefined;
const value = (source as Record<string, unknown>)[key];
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function normalizePercentValue(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function isBoundaryMarketPrice(value: number | null | undefined) {
const percent = normalizePercentValue(value);
return percent != null && (percent <= 2 || percent >= 98);
}
function getMarketEdgeValue(detail?: CityDetail | null) {
return getExecutableEdgePercent(detail);
}
function isTradableMarketOpportunity(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
if (!marketScan) return false;
if (marketScan.available === false) return false;
if (marketScan.primary_market?.closed === true) return false;
if (marketScan.primary_market?.active === false) return false;
const selectedDate = marketScan.selected_date;
const localDate = detail?.local_date;
if (selectedDate && localDate && selectedDate < localDate) return false;
const endDateMs = marketScan.primary_market?.end_date
? Date.parse(marketScan.primary_market.end_date)
: Number.NaN;
if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
const marketBucket =
marketScan.temperature_bucket || marketScan.top_buckets?.[0] || null;
const yesPrice =
marketScan.yes_buy ??
readNumericField(marketBucket, "yes_buy") ??
marketScan.yes_token?.buy_price ??
marketScan.yes_token?.midpoint ??
marketScan.price_analysis?.yes?.ask;
const noPrice =
marketScan.no_buy ??
readNumericField(marketBucket, "no_buy") ??
marketScan.no_token?.buy_price ??
marketScan.no_token?.midpoint ??
marketScan.price_analysis?.no?.ask;
const marketPrice =
marketScan.market_price ??
readNumericField(marketBucket, "market_price") ??
yesPrice;
if (isBoundaryMarketPrice(yesPrice) || isBoundaryMarketPrice(noPrice)) {
return false;
}
if (isBoundaryMarketPrice(marketPrice)) return false;
const executableEdge = getExecutableEdgePercent(detail);
return executableEdge != null && executableEdge > 0;
}
function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const store = useDashboardStore();
const { locale } = useI18n();
const spotlight = useMemo(
() =>
store.selectedCity
? snapshots.find(
(snapshot) => snapshot.city.name === store.selectedCity,
) || null
: null,
[snapshots, store.selectedCity],
);
const spotlightView = useMemo(() => {
if (!spotlight) return null;
const { city, detail, summary } = spotlight;
const symbol = getTempSymbol(city, summary, detail);
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
const maxSoFar =
detail?.current?.max_so_far ?? detail?.airport_current?.max_so_far;
const maxTime =
detail?.current?.max_temp_time ||
detail?.airport_current?.max_temp_time ||
"--";
const localTime = summary?.local_time || detail?.local_time || "--";
const riskLevel =
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level;
const probabilityBuckets =
detail?.probabilities?.distribution ||
(detail?.local_date
? detail?.multi_model_daily?.[detail.local_date]?.probabilities
: undefined) ||
[];
const marketScan = detail?.market_scan;
const displayedProbabilities = buildMarketAlignedProbabilities(
marketScan,
probabilityBuckets,
symbol,
);
const marketBucket =
marketScan?.temperature_bucket || marketScan?.top_buckets?.[0] || null;
const yesPrice =
marketScan?.yes_buy ??
readNumericField(marketBucket, "yes_buy") ??
marketScan?.yes_token?.buy_price ??
marketScan?.yes_token?.midpoint ??
marketScan?.price_analysis?.yes?.ask;
const noPrice =
marketScan?.no_buy ??
readNumericField(marketBucket, "no_buy") ??
marketScan?.no_token?.buy_price ??
marketScan?.no_token?.midpoint ??
marketScan?.price_analysis?.no?.ask;
const marketEdge =
marketScan?.edge_percent ??
marketScan?.price_analysis?.yes?.edge_percent ??
marketScan?.price_analysis?.yes?.edge ??
marketScan?.price_analysis?.no?.edge_percent ??
marketScan?.price_analysis?.no?.edge;
return {
city,
detail,
tradableOpportunity: spotlight.tradableOpportunity,
symbol,
currentTemp,
debPrediction,
maxSoFar,
maxTime,
localTime,
riskLevel,
displayedProbabilities,
marketScan,
showOpportunityLabel: !marketScan || spotlight.tradableOpportunity,
marketLabel: marketBucket
? getProbabilityLabel(marketBucket, symbol)
: debPrediction != null
? `> ${formatTemperature(debPrediction, symbol)}`
: "--",
yesPrice,
noPrice,
marketEdge,
marketProbability:
marketScan?.market_price ??
readNumericField(marketBucket, "market_price") ??
readNumericField(marketBucket, "yes_buy") ??
yesPrice ??
marketScan?.model_probability,
marketModelProbability:
marketScan?.model_probability ?? marketBucket?.probability,
sparklinePoints: buildSparklinePoints(marketScan?.sparkline),
isLoading:
store.loadingState.cityDetail && store.selectedCity === city.name,
isPro: store.proAccess.subscriptionActive,
cityCode: city.icao || detail?.risk?.icao || city.airport,
localizedCityName: getLocalizedCityDisplay(city, locale, summary, detail),
localizedAirportName: getLocalizedAirportDisplay(city, locale, detail),
highRiskLabel:
riskLevel === "high"
? locale === "en-US"
? "High risk"
: "高风险"
: getRiskCopy(riskLevel, locale),
weatherIconKind: getHomeWeatherIconKind(detail, locale),
trendChart: buildHomeTrendChart(detail, locale),
debLabel: locale === "en-US" ? "DEB forecast" : "DEB 预测",
dayMaxLabel: locale === "en-US" ? "24h max" : "24 小时最高",
probabilityTitle: locale === "en-US" ? "EMOS probability" : "EMOS 概率",
marketTitle: locale === "en-US" ? "Market edge" : "市场优势",
marketEdgeLabel: locale === "en-US" ? "Edge" : "优势",
marketImpliedLabel: locale === "en-US" ? "Implied" : "市场隐含",
marketModelLabel: locale === "en-US" ? "Model prob" : "模型概率",
proLabel: store.proAccess.subscriptionActive
? locale === "en-US"
? "Pro signal"
: "PRO 信号"
: locale === "en-US"
? "Pro locked"
: "PRO 锁定",
forecastDays: buildHomeForecastDays(detail, locale),
};
}, [
locale,
spotlight,
store.loadingState.cityDetail,
store.proAccess.subscriptionActive,
store.selectedCity,
]);
if (!spotlightView) return null;
const {
city,
detail,
symbol,
currentTemp,
debPrediction,
maxSoFar,
maxTime,
localTime,
riskLevel,
displayedProbabilities,
marketScan,
tradableOpportunity,
showOpportunityLabel,
marketLabel,
yesPrice,
noPrice,
marketEdge,
marketProbability,
marketModelProbability,
sparklinePoints,
isLoading,
isPro,
cityCode,
localizedCityName,
localizedAirportName,
highRiskLabel,
weatherIconKind,
trendChart,
debLabel,
dayMaxLabel,
probabilityTitle,
marketTitle,
marketEdgeLabel,
marketImpliedLabel,
marketModelLabel,
proLabel,
forecastDays,
} = spotlightView;
const subtitle = `${cityCode} · ${localizedAirportName}`;
const proCard = (
<div className={clsx("home-pro-card", isPro && "active")}>
<div>
<span>{proLabel}</span>
{!isPro ? (
<strong>
{locale === "en-US"
? "History review and future dates stay paid."
: "历史复盘和未来日期保持付费。"}
</strong>
) : null}
</div>
{isPro ? (
<button type="button" onClick={() => void store.openTodayModal()}>
{locale === "en-US" ? "Today intraday" : "今日日内分析"}
</button>
) : (
<Link href="/account">{locale === "en-US" ? "Upgrade" : "升级"}</Link>
)}
</div>
);
return (
<aside
className="home-intelligence-panel full"
aria-label={localizedCityName}
>
<div className="home-panel-glow" aria-hidden="true" />
<div className="home-panel-header">
<div className="home-panel-header-left">
<span className="home-panel-live-indicator" aria-hidden="true" />
<span className="home-focus-title">
{showOpportunityLabel
? locale === "en-US"
? "TOP OPPORTUNITY"
: "最佳机会"
: locale === "en-US"
? "FOCUS CITY"
: "焦点城市"}
</span>
</div>
</div>
<div className="home-card-titlebar">
<div>
<h2>{localizedCityName}</h2>
<p>{subtitle}</p>
</div>
<span className={clsx("home-risk-badge", String(riskLevel || "other"))}>
{highRiskLabel}
</span>
</div>
<div className="home-card-meta-row">
<span>
{locale === "en-US" ? "Local time" : "当地时间"} {localTime}
</span>
<span className="home-card-live-dot" />
<span>
{isLoading
? locale === "en-US"
? "Updating..."
: "更新中..."
: locale === "en-US"
? "Updated now"
: "刚刚更新"}
</span>
</div>
{proCard}
<div className="home-weather-hero">
<div className="home-weather-main">
<span className="home-weather-label">
{locale === "en-US" ? "Observed now" : "当前实况"}
</span>
<strong>{formatTemperature(currentTemp, symbol)}</strong>
</div>
<div className="home-weather-side">
<div
className={clsx("home-weather-icon", `weather-${weatherIconKind}`)}
aria-hidden="true"
>
<span className="sun" />
<span className="cloud cloud-a" />
<span className="cloud cloud-b" />
<span className="mist mist-a" />
<span className="mist mist-b" />
<span className="wind wind-a" />
<span className="wind wind-b" />
<span className="rain rain-a" />
<span className="rain rain-b" />
<span className="rain rain-c" />
<span className="bolt" />
</div>
<div className="home-weather-stat">
<span>{locale === "en-US" ? "Day high" : "日内高点"}</span>
<strong>{formatTemperature(maxSoFar, symbol)}</strong>
<small>{maxTime}</small>
</div>
</div>
</div>
<div className="home-deb-card">
<div>
<span>
{debLabel} <small>({dayMaxLabel})</small>
</span>
<strong>{formatTemperature(debPrediction, symbol)}</strong>
<em>{formatDelta(debPrediction, currentTemp, symbol)}</em>
</div>
</div>
{trendChart ? (
<div className="home-card-section intraday">
<h3>
{locale === "en-US" ? "Hourly reports" : "小时准点报"}{" "}
<small>{locale === "en-US" ? "1h cadence" : "1h 级别"}</small>
</h3>
<div className="home-intraday-chart">
<div className="home-intraday-y-axis" aria-hidden="true">
{trendChart.yAxisLabels.map((axisLabel) => (
<span
key={axisLabel.key}
className="home-intraday-y-label"
style={{ top: `${axisLabel.y}px` }}
>
{axisLabel.label}
</span>
))}
</div>
<svg viewBox="0 0 296 78" aria-hidden="true">
<line x1="10" y1="14" x2="286" y2="14" />
<line x1="10" y1="36" x2="286" y2="36" />
<line x1="10" y1="58" x2="286" y2="58" />
{trendChart.forecastPath ? (
<polyline points={trendChart.forecastPath} />
) : null}
{trendChart.observationDots.map((point) => (
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
))}
</svg>
<div className="home-intraday-x-axis" aria-hidden="true">
{trendChart.xAxisLabels.map((axisLabel) => (
<span
key={axisLabel.key}
className="home-intraday-x-label"
style={{ left: `${axisLabel.x}%` }}
>
{axisLabel.label}
</span>
))}
</div>
</div>
{trendChart.hourlyReports.length ? (
<div className="home-intraday-reports">
{trendChart.hourlyReports.map((report) => (
<span key={report.key}>
<b>{report.label}</b>
<strong>{report.temperatureText}</strong>
</span>
))}
</div>
) : null}
</div>
) : null}
{forecastDays.length ? (
<div className="home-card-section forecast">
<h3>{locale === "en-US" ? "Multi-day forecast" : "多日预报"}</h3>
<div className="home-forecast-grid">
{forecastDays.map((day) => (
<div key={day.key} className="home-forecast-item">
<span>{day.label}</span>
<strong>{formatTemperature(day.maxTemp, symbol)}</strong>
</div>
))}
</div>
</div>
) : null}
<div className="home-card-section probability">
<h3>
{probabilityTitle} <small>{dayMaxLabel}</small>
</h3>
{displayedProbabilities.length ? (
<div className="home-probability-ladder">
{displayedProbabilities.map((bucket, index) => {
const probability = normalizeProbabilityValue(bucket.probability);
const width =
probability == null
? 6
: Math.max(6, Math.min(100, probability * 100));
return (
<div
key={`${getProbabilityLabel(bucket, symbol)}-${index}`}
className="home-probability-ladder-row"
>
<span className="home-probability-threshold">
{getProbabilityLabel(bucket, symbol)}
</span>
<div className="home-probability-track">
<i style={{ width: `${width}%` }} />
</div>
<strong>{formatProbability(probability)}</strong>
</div>
);
})}
</div>
) : (
<div className="home-card-empty">
{isLoading
? locale === "en-US"
? "Loading probabilities..."
: "正在加载概率..."
: locale === "en-US"
? "Probability layer pending."
: "概率层待加载。"}
</div>
)}
</div>
{tradableOpportunity ? (
<div className={clsx("home-card-section market", !isPro && "locked")}>
<div className="home-market-header">
<h3>
{marketTitle} <small></small>
</h3>
<span>
{marketScan && !tradableOpportunity
? locale === "en-US"
? "Market closed"
: "市场已结束"
: locale === "en-US"
? "Updated now"
: "刚刚更新"}
</span>
</div>
<div className="home-market-ticket">
<div className="home-market-question">
<strong>{marketLabel}</strong>
<span>
{marketScan?.selected_date || detail?.local_date || ""}
</span>
</div>
<div className="home-market-prices">
<span className="yes">YES {formatCents(yesPrice)}</span>
<span className="no">NO {formatCents(noPrice)}</span>
</div>
</div>
<div className="home-market-metrics">
<span>
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
</span>
<span>
{marketImpliedLabel}{" "}
<strong>{formatProbability(marketProbability)}</strong>
</span>
<span>
{marketModelLabel}{" "}
<strong>{formatProbability(marketModelProbability)}</strong>
</span>
<svg viewBox="0 0 96 32" aria-hidden="true">
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
</svg>
</div>
{!isPro ? (
<Link href="/account" className="home-market-lock">
{locale === "en-US" ? "Unlock market layer" : "解锁市场层"}
</Link>
) : null}
</div>
) : null}
</aside>
);
}
function OpportunityStrip({
snapshots,
marketScanStatusByCity,
scanTargetNames,
}: {
snapshots: CitySnapshot[];
marketScanStatusByCity: Record<string, OpportunityScanState>;
scanTargetNames: string[];
}) {
const { locale } = useI18n();
const store = useDashboardStore();
const stripState = useMemo(() => {
const targetSet = new Set(scanTargetNames);
const totalCount = scanTargetNames.length || snapshots.length;
const targetedSnapshots = (
scanTargetNames.length
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
: snapshots
).slice(0, Math.max(scanTargetNames.length, 1));
const effectiveStatusByCity = Object.fromEntries(
targetedSnapshots.map((snapshot) => [
snapshot.city.name,
marketScanStatusByCity[snapshot.city.name] ||
(snapshot.detail?.market_scan ? "complete" : undefined),
]),
) as Record<string, OpportunityScanState | undefined>;
const completedCount = scanTargetNames.filter((cityName) => {
const status = effectiveStatusByCity[cityName];
return status === "complete" || status === "empty";
}).length;
const pendingCount = scanTargetNames.filter(
(cityName) => effectiveStatusByCity[cityName] === "pending",
).length;
const errorCount = scanTargetNames.filter(
(cityName) => effectiveStatusByCity[cityName] === "error",
).length;
const liveSnapshots = targetedSnapshots.filter((snapshot) =>
isLiveMarketScan(snapshot.detail),
);
const tradableSnapshots = targetedSnapshots
.filter((snapshot) => snapshot.tradableOpportunity)
.sort((left, right) => {
const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
const rightEdge = normalizeEdgePercent(
getMarketEdgeValue(right.detail),
);
return (
Number(rightEdge ?? Number.NEGATIVE_INFINITY) -
Number(leftEdge ?? Number.NEGATIVE_INFINITY) ||
right.score - left.score
);
});
const yesCount = liveSnapshots.filter(
(snapshot) => getBestSide(snapshot.detail) === "yes",
).length;
const noCount = liveSnapshots.filter(
(snapshot) => getBestSide(snapshot.detail) === "no",
).length;
const avgTradableEdge = tradableSnapshots.length
? tradableSnapshots.reduce((sum, snapshot) => {
const edge = normalizeEdgePercent(
getMarketEdgeValue(snapshot.detail),
);
return sum + (edge ?? 0);
}, 0) / tradableSnapshots.length
: null;
const bestTradableSnapshot = tradableSnapshots[0] || null;
const bestTradableEdge = bestTradableSnapshot
? normalizeEdgePercent(getMarketEdgeValue(bestTradableSnapshot.detail))
: null;
const bestTradableCityName = bestTradableSnapshot
? getLocalizedCityDisplay(
bestTradableSnapshot.city,
locale,
bestTradableSnapshot.summary,
bestTradableSnapshot.detail,
)
: "--";
const quoteReadyCount = liveSnapshots.filter((snapshot) => {
const marketScan = snapshot.detail?.market_scan;
return Boolean(
marketScan?.price_analysis?.available ||
marketScan?.yes_buy != null ||
marketScan?.no_buy != null,
);
}).length;
const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
return {
items: tradableSnapshots.slice(0, 5),
totalCount,
completedCount,
pendingCount,
errorCount,
liveCount: liveSnapshots.length,
tradableCount: tradableSnapshots.length,
noEdgeCount,
yesCount,
noCount,
avgTradableEdge,
bestTradableSnapshot,
bestTradableEdge,
bestTradableCityName,
summaryCards: [
{
key: "data-chain",
title: locale === "en-US" ? "Data Chain" : "数据链路",
items: [
{
label: locale === "en-US" ? "Markets" : "市场列表",
value: "Gamma",
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "Quotes" : "盘口价格",
value: "CLOB",
accent: "green" as const,
},
{
label: locale === "en-US" ? "Model" : "概率模型",
value: "EMOS",
accent: "amber" as const,
},
],
},
{
key: "refresh-cadence",
title: locale === "en-US" ? "Refresh Cadence" : "刷新节奏",
items: [
{
label: locale === "en-US" ? "Market list" : "市场列表",
value: "60s",
accent: "green" as const,
},
{
label: locale === "en-US" ? "YES/NO ask" : "YES/NO 买价",
value: "30s",
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "Mode" : "模式",
value: "REST",
},
],
},
{
key: "quote-filter",
title: locale === "en-US" ? "Quote Filter" : "报价筛选",
items: [
{
label: locale === "en-US" ? "Live" : "在线",
value: String(liveSnapshots.length),
accent: "green" as const,
},
{
label: locale === "en-US" ? "Quoted" : "已报价",
value: String(quoteReadyCount),
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "No edge" : "无正 edge",
value: String(noEdgeCount),
accent: "amber" as const,
},
],
},
{
key: "edge-decision",
title: locale === "en-US" ? "Decision" : "交易决策",
items: [
{
label: locale === "en-US" ? "Actionable" : "可买",
value: String(tradableSnapshots.length),
accent: "green" as const,
},
{
label: locale === "en-US" ? "Avg edge" : "平均 edge",
value: formatEdge(avgTradableEdge),
accent: avgTradableEdge != null ? ("cyan" as const) : undefined,
},
{
label: locale === "en-US" ? "Focus" : "当前标的",
value: bestTradableCityName,
accent: "amber" as const,
},
],
},
],
overviewKicker:
locale === "en-US"
? "EMOS / CLOB Decision Layer"
: "EMOS / CLOB 交易决策层",
headingTitle:
tradableSnapshots.length > 0
? locale === "en-US"
? `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: pendingCount > 0
? locale === "en-US"
? "Waiting for executable CLOB quotes"
: "等待可执行 CLOB 报价"
: locale === "en-US"
? "No positive EMOS edge right now"
: "当前没有正 EMOS edge",
overviewBody:
tradableSnapshots.length > 0
? locale === "en-US"
? "Decision rule: compare the EMOS probability for the exact market bucket with the executable YES/NO ask from CLOB REST."
: "决策规则:把具体温度桶的 EMOS 概率和 CLOB REST 可买 YES/NO 价格相减,只展示正 edge 的买入方向。"
: pendingCount > 0
? locale === "en-US"
? "Gamma active markets are cached separately; the faster loop waits for CLOB YES/NO asks and recalculates edge locally."
: "Gamma 活跃市场单独缓存;更快的价格层等待 CLOB YES/NO 买价后在本地重算 edge。"
: locale === "en-US"
? "Markets can still be live, but the current asks are not cheaper than the EMOS distribution."
: "盘口可以在线,但当前 ask 没有低于 EMOS 概率分布,先不提示买入。",
tapeStats: [
{
key: "coverage",
label: locale === "en-US" ? "Gamma" : "Gamma 市场",
value: "60s",
accent: "cyan" as const,
},
{
key: "live",
label: locale === "en-US" ? "CLOB asks" : "CLOB 报价",
value: "30s",
accent: "green" as const,
},
{
key: "tradable",
label: locale === "en-US" ? "Actionable" : "可买机会",
value: String(tradableSnapshots.length),
accent: "cyan" as const,
},
{
key: "avg-edge",
label: locale === "en-US" ? "Best edge" : "最佳 edge",
value: formatEdge(bestTradableEdge),
accent:
bestTradableEdge != null ? ("green" as const) : ("slate" as const),
},
],
yesCountLabel:
locale === "en-US" ? `BUY YES ${yesCount}` : `买 YES ${yesCount}`,
noCountLabel:
locale === "en-US" ? `BUY NO ${noCount}` : `买 NO ${noCount}`,
};
}, [locale, marketScanStatusByCity, scanTargetNames, snapshots]);
if (!snapshots.length) return null;
const heroSnapshot = stripState.bestTradableSnapshot;
const heroSide = getBestSide(heroSnapshot?.detail);
const heroSignalLabel = getTradeSideLabel(heroSide, locale);
const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes")
? "yes"
: heroSignalLabel.toLowerCase().includes("no")
? "no"
: "neutral";
const heroBestAsk = getBestSideAsk(heroSnapshot?.detail);
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
const heroSymbol = heroSnapshot
? getTempSymbol(
heroSnapshot.city,
heroSnapshot.summary,
heroSnapshot.detail,
)
: "°C";
const heroTier =
heroSnapshot?.city.deb_recent_tier ||
heroSnapshot?.city.risk_level ||
heroSnapshot?.summary?.risk?.level ||
heroSnapshot?.detail?.risk?.level;
const heroCityName = heroSnapshot
? getLocalizedCityDisplay(
heroSnapshot.city,
locale,
heroSnapshot.summary,
heroSnapshot.detail,
)
: "--";
const heroMarketBucket =
heroSnapshot?.detail?.market_scan?.temperature_bucket;
const heroQuestion =
heroSnapshot?.detail?.market_scan?.primary_market?.question ||
`${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${
heroSnapshot?.detail?.market_scan?.selected_date ||
heroSnapshot?.detail?.local_date ||
""
}`;
const heroEngine = (() => {
const engine = String(
heroSnapshot?.detail?.market_scan?.probability_engine || "EMOS",
).toLowerCase();
if (engine.includes("emos")) return "EMOS";
return engine.toUpperCase();
})();
const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null;
const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source
? String(heroSnapshot.detail.market_scan.quote_source)
.replaceAll("_", " ")
.toUpperCase()
: "CLOB REST";
const heroDateLabel =
heroSnapshot?.detail?.market_scan?.selected_date ||
heroSnapshot?.detail?.local_date ||
"--";
const heroSparkline = buildSparklinePoints(
heroSnapshot?.detail?.market_scan?.sparkline?.length
? heroSnapshot.detail.market_scan.sparkline
: [
Number(
heroSnapshot?.summary?.current?.temp ??
heroSnapshot?.detail?.current?.temp ??
0,
),
Number(
heroSnapshot?.summary?.deb?.prediction ??
heroSnapshot?.detail?.deb?.prediction ??
0,
),
Number(
heroSnapshot?.detail?.forecast?.today_high ??
heroSnapshot?.summary?.deb?.prediction ??
0,
),
],
);
const secondaryItems = heroSnapshot
? stripState.items.filter(
(snapshot) => snapshot.city.name !== heroSnapshot.city.name,
)
: [];
return (
<section
className="home-opportunity-strip"
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
>
<div className="opportunity-strip-topline">
<div className="opportunity-strip-copy">
<span className="opportunity-strip-kicker">
{stripState.overviewKicker}
</span>
<strong>{stripState.headingTitle}</strong>
<p>{stripState.overviewBody}</p>
</div>
<div className="opportunity-strip-tape" aria-live="polite">
{stripState.tapeStats.map((item) => (
<div
key={item.key}
className={clsx(
"opportunity-tape-pill",
item.accent ? `accent-${item.accent}` : undefined,
)}
>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
<div className="opportunity-tape-pill accent-outline">
<span>{stripState.yesCountLabel}</span>
</div>
<div className="opportunity-tape-pill accent-outline">
<span>{stripState.noCountLabel}</span>
</div>
</div>
</div>
{heroSnapshot ? (
<>
<div className="opportunity-strip-main">
<button
type="button"
className="opportunity-hero-card"
onClick={() => void store.focusCity(heroSnapshot.city.name)}
>
<div className="opportunity-hero-header">
<div className="opportunity-hero-copy">
<span className="opportunity-hero-kicker">
{locale === "en-US" ? "Current action" : "当前交易建议"}
</span>
<div className="opportunity-hero-title-row">
<strong>{heroCityName}</strong>
<span className="opportunity-hero-date">
{heroDateLabel}
</span>
</div>
<p>{heroQuestion}</p>
</div>
<div className="opportunity-hero-tags">
<span className="opportunity-hero-tag engine">
{heroEngine}
</span>
<span
className={clsx(
"opportunity-hero-tag",
`signal-${heroSignalTone}`,
)}
>
{heroSignalLabel}
</span>
<span
className={clsx(
"opportunity-hero-tag",
"risk",
String(heroTier || "other"),
)}
>
{getRiskCopy(heroTier as RiskLevel | undefined, locale)}
</span>
</div>
</div>
<div className="opportunity-hero-body">
<div className="opportunity-hero-edgeblock">
<span>
{locale === "en-US" ? "Trade now" : "现在值得买什么"}
</span>
<strong>
{heroSide
? `${getTradeSideLabel(heroSide, locale)} ${formatCents(heroBestAsk)}`
: locale === "en-US"
? "WATCH"
: "观察"}
</strong>
<em>
{getTradeFormulaLabel(heroSide, locale)} ·{" "}
{locale === "en-US" ? "edge" : "优势"}{" "}
{formatEdge(heroBestEdge)}
</em>
</div>
<div className="opportunity-hero-metrics">
<div className="opportunity-hero-metric">
<span>
{heroSide === "no"
? locale === "en-US"
? "EMOS P(NO)"
: "EMOS P(NO)"
: locale === "en-US"
? "EMOS P(YES)"
: "EMOS P(YES)"}
</span>
<strong>{formatProbability(heroBestProbability)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "CLOB ask" : "CLOB 买价"}</span>
<strong>{formatCents(heroBestAsk)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Edge" : "Edge"}</span>
<strong>{formatEdge(heroBestEdge)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Spread" : "盘口点差"}</span>
<strong>{formatCents(heroSpread)}</strong>
</div>
</div>
<div className="opportunity-hero-book">
<div className="opportunity-book-side yes">
<span>YES</span>
<strong>
{formatCents(heroSnapshot.detail?.market_scan?.yes_buy)}
</strong>
</div>
<div className="opportunity-book-side no">
<span>NO</span>
<strong>
{formatCents(heroSnapshot.detail?.market_scan?.no_buy)}
</strong>
</div>
<div className="opportunity-hero-sparkline-wrap">
<span>
{locale === "en-US" ? "Market path" : "盘口走势"}
</span>
<svg
className="opportunity-hero-sparkline"
viewBox="0 0 132 44"
aria-hidden="true"
>
{heroSparkline ? (
<polyline points={heroSparkline} />
) : null}
</svg>
</div>
</div>
</div>
<div className="opportunity-hero-footer">
<span>
{heroQuoteSource} ·{" "}
{locale === "en-US"
? "click for city detail"
: "点击查看城市详情"}
</span>
<span>
{locale === "en-US"
? `Avg edge ${formatEdge(stripState.avgTradableEdge)}`
: `平均优势 ${formatEdge(stripState.avgTradableEdge)}`}
</span>
</div>
</button>
<div className="opportunity-side-grid">
{stripState.summaryCards.map((card) => (
<div key={card.key} className="opportunity-side-tile">
<div className="opportunity-side-tile-head">
<span>{card.title}</span>
</div>
<div className="opportunity-side-tile-body">
{card.items.map((item) => (
<div
key={`${card.key}-${item.label}`}
className="opportunity-side-stat"
>
<b
className={
item.accent ? `accent-${item.accent}` : undefined
}
>
{item.value}
</b>
<span>{item.label}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
{secondaryItems.length > 0 ? (
<div className="opportunity-mini-grid">
{secondaryItems.map(({ city, detail, summary }, index) => {
const symbol = getTempSymbol(city, summary, detail);
const debPrediction =
summary?.deb?.prediction ?? detail?.deb?.prediction;
const localizedCityName = getLocalizedCityDisplay(
city,
locale,
summary,
detail,
);
const tier =
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level;
const marketBucket = detail?.market_scan?.temperature_bucket;
const marketQuestion =
detail?.market_scan?.primary_market?.question ||
`${getProbabilityLabel(marketBucket || {}, symbol)} ${
detail?.market_scan?.selected_date ||
detail?.local_date ||
""
}`;
const opportunitySparkline = buildSparklinePoints(
detail?.market_scan?.sparkline?.length
? detail.market_scan.sparkline
: [
Number(
summary?.current?.temp ?? detail?.current?.temp ?? 0,
),
Number(debPrediction ?? 0),
Number(
detail?.forecast?.today_high ?? debPrediction ?? 0,
),
],
);
return (
<button
key={city.name}
type="button"
className="opportunity-mini-card"
onClick={() => void store.focusCity(city.name)}
>
<div className="opportunity-mini-head">
<div className="opportunity-mini-rank">
<span>{index + 2}</span>
</div>
<div className="opportunity-mini-copy">
<strong>{localizedCityName}</strong>
<p>{marketQuestion}</p>
</div>
<span
className={clsx(
"opportunity-pill",
String(tier || "other"),
)}
>
{getRiskCopy(tier as RiskLevel | undefined, locale)}
</span>
</div>
<div className="opportunity-mini-metrics">
<span className="opportunity-yes">
YES {formatCents(detail?.market_scan?.yes_buy)}
</span>
<span className="opportunity-no">
NO {formatCents(detail?.market_scan?.no_buy)}
</span>
<span className="opportunity-edge">
{formatEdge(getMarketEdgeValue(detail))}
</span>
</div>
<svg
className="opportunity-mini-sparkline"
viewBox="0 0 96 28"
aria-hidden="true"
>
{opportunitySparkline ? (
<polyline points={opportunitySparkline} />
) : null}
</svg>
</button>
);
})}
</div>
) : null}
</>
) : (
<div className="opportunity-empty-state">
<div className="opportunity-empty-copy">
<strong>
{stripState.pendingCount > 0
? locale === "en-US"
? "Scanning current market layers..."
: "正在扫描当前市场层..."
: locale === "en-US"
? "No tradable market at the moment"
: "当前没有满足条件的可交易市场"}
</strong>
<span>
{locale === "en-US"
? `Completed ${stripState.completedCount}/${stripState.totalCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.`
: `已完成 ${stripState.completedCount}/${stripState.totalCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`}
</span>
</div>
</div>
)}
</section>
);
}
function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
const store = useDashboardStore();
const { locale } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [showPaywall, setShowPaywall] = useState(false);
const [loading, setLoading] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<AssistantMessage[]>([]);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dockPosition, setDockPosition] =
useState<AssistantDockPosition | null>(null);
const dockRef = useRef<HTMLDivElement | null>(null);
const dockPositionRef = useRef<AssistantDockPosition | null>(null);
const dragStateRef = useRef<AssistantDockDragState | null>(null);
const suppressLauncherClickRef = useRef(false);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const selectedSnapshot = useMemo(
() =>
store.selectedCity
? snapshots.find(
(snapshot) => snapshot.city.name === store.selectedCity,
) || null
: null,
[snapshots, store.selectedCity],
);
const selectedCityName = selectedSnapshot
? getLocalizedCityDisplay(
selectedSnapshot.city,
locale,
selectedSnapshot.summary,
selectedSnapshot.detail,
)
: null;
const assistantContext = useMemo(
() => buildAssistantContextPayload(snapshots, store.selectedCity, locale),
[locale, snapshots, store.selectedCity],
);
const starterPrompts = useMemo(() => {
if (locale === "en-US") {
return [
selectedCityName
? `What is today's forecast high for ${selectedCityName}?`
: "What is today's forecast high for the focus city?",
"Which market is worth buying now?",
"Rank current opportunities by edge",
];
}
return [
selectedCityName
? `${selectedCityName} 今天预测最高温是多少?`
: "当前焦点城市今天预测最高温是多少?",
"当前有哪些值得参与的市场?",
"按 edge 排序",
];
}, [locale, selectedCityName]);
useEffect(() => {
if (messages.length) return;
setMessages([
{
id: "assistant-greeting",
role: "assistant",
content: buildAssistantGreeting(locale, selectedCityName),
},
]);
}, [locale, messages.length, selectedCityName]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({
behavior: loading ? "auto" : "smooth",
block: "end",
});
}, [loading, messages]);
useEffect(() => {
dockPositionRef.current = dockPosition;
}, [dockPosition]);
useEffect(() => {
if (typeof window === "undefined") return;
const syncDockPosition = () => {
if (window.innerWidth <= 960) {
setDockPosition(null);
return;
}
setDockPosition((current) =>
clampAssistantDockPosition(
current ||
readAssistantDockPosition() ||
getDefaultAssistantDockPosition(),
dockRef.current,
),
);
};
syncDockPosition();
window.addEventListener("resize", syncDockPosition);
return () => {
window.removeEventListener("resize", syncDockPosition);
};
}, [isOpen]);
const openAssistant = () => {
if (suppressLauncherClickRef.current) {
suppressLauncherClickRef.current = false;
return;
}
if (!store.proAccess.subscriptionActive) {
setShowPaywall(true);
return;
}
setIsOpen(true);
};
const beginDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (typeof window === "undefined" || window.innerWidth <= 960) return;
const basePosition = clampAssistantDockPosition(
dockPositionRef.current ||
readAssistantDockPosition() ||
getDefaultAssistantDockPosition(),
dockRef.current,
);
if (!basePosition) return;
event.preventDefault();
event.stopPropagation();
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
right: basePosition.right,
bottom: basePosition.bottom,
hasMoved: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
dockPositionRef.current = basePosition;
setDockPosition(basePosition);
setIsDragging(true);
};
const updateDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (!dragState.hasMoved && Math.abs(deltaX) + Math.abs(deltaY) >= 6) {
dragState.hasMoved = true;
}
const nextPosition = clampAssistantDockPosition(
{
right: dragState.right - deltaX,
bottom: dragState.bottom - deltaY,
},
dockRef.current,
);
if (!nextPosition) return;
dockPositionRef.current = nextPosition;
setDockPosition(nextPosition);
};
const endDockDrag = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
try {
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
} catch {
// Pointer capture may already be released by the browser.
}
const finalPosition = clampAssistantDockPosition(
dockPositionRef.current || {
right: dragState.right,
bottom: dragState.bottom,
},
dockRef.current,
);
if (dragState.hasMoved) {
suppressLauncherClickRef.current = true;
}
dockPositionRef.current = finalPosition;
setDockPosition(finalPosition);
writeAssistantDockPosition(finalPosition);
dragStateRef.current = null;
setIsDragging(false);
};
const sendQuestion = async (rawQuestion?: string) => {
const question = String(rawQuestion ?? input).trim();
if (!question || loading) return;
if (!store.proAccess.subscriptionActive) {
setShowPaywall(true);
return;
}
setIsOpen(true);
setError(null);
setLoading(true);
setMessages((current) => [
...current,
{
id: `user-${Date.now()}`,
role: "user",
content: question,
},
]);
setInput("");
try {
const reply = await dashboardClient.askAssistant({
question,
locale,
snapshotId: assistantContext.snapshot_id,
context: assistantContext,
});
setMessages((current) => [
...current,
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: reply.answer,
cached: reply.cached,
},
]);
} catch (submitError) {
const message = String(submitError);
if (message.includes("HTTP 402")) {
setShowPaywall(true);
} else {
setError(
locale === "en-US"
? "Assistant is temporarily unavailable."
: "AI 助手暂时不可用。",
);
}
} finally {
setLoading(false);
}
};
const submitForm = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await sendQuestion();
};
return (
<>
<div
ref={dockRef}
className={clsx(
"home-ai-assistant",
!isOpen && "collapsed",
isDragging && "dragging",
)}
style={
dockPosition
? {
right: `${dockPosition.right}px`,
bottom: `${dockPosition.bottom}px`,
}
: undefined
}
>
{!isOpen ? (
<button
type="button"
className="home-ai-launcher"
onClick={openAssistant}
onPointerDown={beginDockDrag}
onPointerMove={updateDockDrag}
onPointerUp={endDockDrag}
onPointerCancel={endDockDrag}
aria-label={
locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"
}
>
<Image
src="/favicon-32x32.png"
alt=""
width={22}
height={22}
className="home-ai-launcher-icon"
/>
</button>
) : (
<section
className="home-ai-panel"
aria-label={locale === "en-US" ? "AI assistant" : "AI 助手"}
>
<div className="home-ai-header">
<div>
<strong>
{locale === "en-US" ? "AI assistant" : "AI 对话助手"}
</strong>
<span>
{locale === "en-US"
? "Ask about cities, forecast highs, edge, and live opportunities"
: "可直接问城市、最高温预测、edge 和实时市场机会"}
</span>
</div>
<div className="home-ai-header-actions">
<span
className="home-ai-drag-handle"
title={locale === "en-US" ? "Move assistant" : "拖动助手"}
aria-hidden="true"
onPointerDown={beginDockDrag}
onPointerMove={updateDockDrag}
onPointerUp={endDockDrag}
onPointerCancel={endDockDrag}
>
⋮⋮
</span>
<button
type="button"
className="home-ai-close"
onClick={() => setIsOpen(false)}
aria-label={
locale === "en-US" ? "Close assistant" : "关闭 AI 助手"
}
>
×
</button>
</div>
</div>
<div className="home-ai-disclaimer">
{locale === "en-US"
? "You can ask about current temperature, today's forecast high, market opportunities, edge, and risk reasons."
: "可直接问当前温度、今日最高温、市场机会、edge 和风险原因。"}
</div>
<div className="home-ai-messages">
{messages.map((message) => (
<div
key={message.id}
className={clsx(
"home-ai-message",
message.role === "user" ? "user" : "assistant",
)}
>
<p>{message.content}</p>
{message.cached ? (
<small>
{locale === "en-US" ? "Cache hit" : "命中缓存"}
</small>
) : null}
</div>
))}
{loading ? (
<div className="home-ai-message assistant loading">
<p>
{locale === "en-US" ? "Thinking..." : "正在整理答案..."}
</p>
</div>
) : null}
<div ref={messagesEndRef} />
</div>
<div className="home-ai-starters">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="home-ai-starter"
onClick={() => void sendQuestion(prompt)}
>
{prompt}
</button>
))}
</div>
<form className="home-ai-composer" onSubmit={submitForm}>
<textarea
className="home-ai-input"
rows={3}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
locale === "en-US"
? "Ask about a city's current temperature, today's forecast high, or market edge..."
: "可以问某个城市当前温度、今日最高温,或者市场 edge..."
}
/>
<div className="home-ai-composer-actions">
{error ? (
<span className="home-ai-error">{error}</span>
) : (
<span />
)}
<button
type="submit"
className="home-ai-send"
disabled={loading || !input.trim()}
>
{locale === "en-US" ? "Ask" : "发送"}
</button>
</div>
</form>
</section>
)}
</div>
{showPaywall ? (
<div
className="home-ai-paywall-backdrop"
onClick={() => setShowPaywall(false)}
>
<div
className="home-ai-paywall-shell"
onClick={(event) => event.stopPropagation()}
>
<ProFeaturePaywall
feature="assistant"
onClose={() => setShowPaywall(false)}
/>
</div>
</div>
) : null}
</>
);
}
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
const didAutoFocusRef = useRef(false);
const marketScanInflightRef = useRef<Set<string>>(new Set());
const marketScanPollingRef = useRef(false);
const [marketScanStatusByCity, setMarketScanStatusByCity] = useState<
Record<string, OpportunityScanState>
>({});
const activeSummary = store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null;
const activeCityName =
store.selectedDetail?.display_name ||
activeSummary?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)
?.display_name ||
store.selectedCity ||
"";
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (store.futureModalDate) {
store.closeFutureModal();
return;
}
if (store.historyState.isOpen) {
store.closeHistory();
return;
}
if (store.isPanelOpen) {
store.closePanel();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [store]);
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
const showLoading = store.loadingState.cities || store.loadingState.refresh;
const showCitySyncToast =
store.loadingState.cityDetail &&
activeCityName &&
!store.selectedDetail &&
!activeSummary;
const homepageSnapshots = useMemo(
() =>
store.cities
.map((city) =>
buildSnapshot(
city,
store.citySummariesByName[city.name] || null,
store.cityDetailsByName[city.name] || null,
),
)
.sort(
(a, b) =>
b.score - a.score ||
a.city.display_name.localeCompare(b.city.display_name),
),
[store.cities, store.cityDetailsByName, store.citySummariesByName],
);
const marketScanTargetNames = useMemo(
() => homepageSnapshots.map((snapshot) => snapshot.city.name),
[homepageSnapshots],
);
const showHomepageChrome =
!store.historyState.isOpen && !store.futureModalDate;
useEffect(() => {
if (didAutoFocusRef.current) return;
if (!showHomepageChrome) return;
if (store.selectedCity) return;
const topOpportunity = homepageSnapshots[0]?.city.name;
if (!topOpportunity) return;
didAutoFocusRef.current = true;
void store.focusCity(topOpportunity);
}, [homepageSnapshots, showHomepageChrome, store]);
useEffect(() => {
if (store.loadingState.refresh) {
marketScanInflightRef.current.clear();
setMarketScanStatusByCity({});
}
}, [store.loadingState.refresh]);
useEffect(() => {
if (store.proAccess.loading) return;
if (store.proAccess.authenticated) return;
marketScanInflightRef.current.clear();
setMarketScanStatusByCity({});
}, [store.proAccess.authenticated, store.proAccess.loading]);
useEffect(() => {
if (!showHomepageChrome) return;
if (store.proAccess.loading || !store.proAccess.authenticated) return;
if (!marketScanTargetNames.length) return;
let cancelled = false;
const queue = marketScanTargetNames.filter((cityName) => {
const status = marketScanStatusByCity[cityName];
const existingMarketScan = store.cityDetailsByName[cityName]?.market_scan;
return (
!status &&
!existingMarketScan &&
!marketScanInflightRef.current.has(cityName)
);
});
if (!queue.length) return;
const runWorker = async () => {
while (!cancelled) {
const cityName = queue.shift();
if (!cityName) return;
marketScanInflightRef.current.add(cityName);
setMarketScanStatusByCity((current) => ({
...current,
[cityName]: "pending",
}));
try {
const existingDetail = store.cityDetailsByName[cityName];
const marketScan =
existingDetail?.market_scan ||
(await store.ensureCityMarketScan(cityName, false, { lite: true }));
if (cancelled) return;
setMarketScanStatusByCity((current) => ({
...current,
[cityName]: marketScan ? "complete" : "empty",
}));
} catch {
if (cancelled) return;
setMarketScanStatusByCity((current) => ({
...current,
[cityName]: "error",
}));
} finally {
marketScanInflightRef.current.delete(cityName);
}
}
};
void Promise.allSettled(
Array.from({ length: Math.min(2, queue.length) }, () => runWorker()),
);
return () => {
cancelled = true;
};
}, [
marketScanStatusByCity,
marketScanTargetNames,
showHomepageChrome,
store.cityDetailsByName,
store.ensureCityMarketScan,
store.proAccess.authenticated,
store.proAccess.loading,
]);
useEffect(() => {
if (!showHomepageChrome) return;
if (store.proAccess.loading || !store.proAccess.authenticated) return;
if (!marketScanTargetNames.length) return;
let cancelled = false;
let intervalId: number | null = null;
const refreshAllMarketScans = async () => {
if (cancelled || marketScanPollingRef.current) return;
marketScanPollingRef.current = true;
const queue = [...marketScanTargetNames];
try {
const runWorker = async () => {
while (!cancelled) {
const cityName = queue.shift();
if (!cityName) return;
await store.ensureCityMarketScan(cityName, false, { lite: true });
}
};
await Promise.allSettled(
Array.from({ length: Math.min(3, queue.length) }, () => runWorker()),
);
} finally {
marketScanPollingRef.current = false;
}
};
void refreshAllMarketScans();
intervalId = window.setInterval(() => {
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
void refreshAllMarketScans();
}, HOME_OPPORTUNITY_REFRESH_MS);
return () => {
cancelled = true;
marketScanPollingRef.current = false;
if (intervalId != null) {
window.clearInterval(intervalId);
}
};
}, [
marketScanTargetNames,
showHomepageChrome,
store.ensureCityMarketScan,
store.proAccess.authenticated,
store.proAccess.loading,
]);
return (
<div
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
>
<HeaderBar />
<div className="scan-terminal">
{/* Left — Filter Panel */}
<ScanFilterPanel />
{/* Center — Data Grid */}
<div className="scan-data-grid">
<div className="scan-data-grid-header">
<div>
<div className="scan-data-grid-title">
{t("dashboard.opportunityTitle") || "可交易机会"}
</div>
<div className="scan-data-grid-subtitle">
{t("dashboard.opportunitySubtitle") ||
"基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场"}
</div>
</div>
</div>
<ScanKPIBar
data={{
recommendedCount: homepageSnapshots.filter(
(s) => s.tradableOpportunity,
).length,
avgEdge: (() => {
const tradable = homepageSnapshots.filter(
(s) => s.tradableOpportunity,
);
if (!tradable.length) return null;
const sum = tradable.reduce((acc, s) => {
const edge = normalizeEdgePercent(
getMarketEdgeValue(s.detail),
);
return acc + (edge ?? 0);
}, 0);
return sum / tradable.length;
})(),
totalWinRate: (() => {
const tradable = homepageSnapshots.filter(
(s) => s.tradableOpportunity,
);
if (!tradable.length) return null;
const modelProbs = tradable
.map((s) => s.detail?.market_scan?.model_probability)
.filter((v): v is number => v != null);
if (!modelProbs.length) return null;
return (
(modelProbs.reduce((a, b) => a + b, 0) / modelProbs.length) *
100
);
})(),
tradableMarkets: homepageSnapshots.filter(
(s) => s.tradableOpportunity,
).length,
filteredTotal: homepageSnapshots.length,
totalVolume: homepageSnapshots.reduce((sum, s) => {
const vol =
s.detail?.market_scan?.volume ??
s.detail?.market_scan?.primary_market?.volume ??
0;
return sum + vol;
}, 0),
}}
/>
<div className="scan-view-tabs">
<button className="scan-view-tab active">
{t("dashboard.opportunityList")}
</button>
<button className="scan-view-tab">
{t("dashboard.distributionView")}
</button>
<button className="scan-view-tab">
{t("dashboard.calendarView")}
</button>
</div>
<OpportunityTable
rows={homepageSnapshots.map((snapshot, index) => ({
rank: index + 1,
city: snapshot.city,
summary: snapshot.summary,
detail: snapshot.detail,
score: snapshot.score,
tradable: snapshot.tradableOpportunity,
}))}
/>
</div>
{/* Right — Scan Detail Panel */}
{store.selectedDetail ? (
<aside className="scan-detail-panel">
{/* City Header */}
<div className="scan-detail-header">
<span className="scan-detail-hero-placeholder" />
<div className="scan-detail-city-info">
<div className="scan-detail-city-name">{activeCityName}</div>
<div className="scan-detail-city-sub">
{getScanStatusLabel(store.selectedDetail)}
{" · "}
{store.selectedDetail.temp_symbol === "°F" ? "≥ " : "≥ "}
{store.selectedDetail.forecast?.today_high != null
? `${store.selectedDetail.forecast.today_high}${store.selectedDetail.temp_symbol}`
: "--"}
</div>
<div className="scan-detail-volume">
{formatVolume(store.selectedDetail.market_scan?.volume)} · 24h{" "}
{store.selectedDetail.market_scan?.primary_market
?.liquidity != null
? `$${store.selectedDetail.market_scan.primary_market.liquidity.toLocaleString()}`
: ""}
</div>
</div>
</div>
{/* Current Conditions */}
<div className="scan-detail-section">
<div className="scan-detail-section-title">
{t("section.currentConditions") || "当前概况"}
</div>
<div className="scan-conditions-table">
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.localTime") || "当前时间"}
</span>
<span className="scan-condition-value">
{store.selectedDetail.local_time}
</span>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.currentTemp") || "当前温度"}
</span>
<span className="scan-condition-value accent-green">
{store.selectedDetail.current?.temp != null
? `${store.selectedDetail.current.temp.toFixed(1)}${store.selectedDetail.temp_symbol}`
: "--"}
</span>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.todayHigh") || "今日最高"}
</span>
<span className="scan-condition-value">
{store.selectedDetail.current?.max_so_far != null
? `${store.selectedDetail.current.max_so_far.toFixed(1)}${store.selectedDetail.temp_symbol}`
: "--"}
</span>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.targetTemp") || "目标温度"}
</span>
<span className="scan-condition-value">
{store.selectedDetail.deb?.prediction != null
? `${store.selectedDetail.deb.prediction.toFixed(1)}${store.selectedDetail.temp_symbol}`
: "--"}
</span>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.gap") || "距目标差距"}
</span>
<span
className={`scan-condition-value ${
store.selectedDetail.current?.temp != null &&
store.selectedDetail.deb?.prediction != null
? store.selectedDetail.deb.prediction -
(store.selectedDetail.current.max_so_far ??
store.selectedDetail.current.temp) >
0
? "accent-red"
: "accent-green"
: ""
}`}
>
{store.selectedDetail.current?.temp != null &&
store.selectedDetail.deb?.prediction != null
? `${(store.selectedDetail.deb.prediction - (store.selectedDetail.current.max_so_far ?? store.selectedDetail.current.temp)).toFixed(1)}${store.selectedDetail.temp_symbol}`
: "--"}
</span>
</div>
<div className="scan-condition-item">
<span className="scan-condition-label">
{t("detail.peakWindow") || "预计峰值"}
</span>
<span className="scan-condition-value">
{store.selectedDetail.peak?.hours?.join(" - ") || "--"}
</span>
</div>
</div>
</div>
{/* Trade Recommendation */}
{store.selectedDetail.market_scan?.price_analysis && (
<div className="scan-detail-section">
<div className="scan-detail-section-title">
{t("section.tradeRecommendation") || "推荐交易"}
</div>
<div className="scan-trade-cards">
<div className="scan-trade-card yes">
<div className="scan-trade-card-title">
买入 Yes{" "}
{store.selectedDetail.market_scan?.temperature_bucket
?.label || ""}
</div>
<div className="scan-trade-card-price">
{store.selectedDetail.market_scan?.price_analysis?.yes
?.ask != null
? `${(store.selectedDetail.market_scan.price_analysis.yes.ask * 100).toFixed(0)}%`
: "--"}
{" → "}
{store.selectedDetail.market_scan?.price_analysis?.yes
?.model_probability != null
? `${(store.selectedDetail.market_scan.price_analysis.yes.model_probability * 100).toFixed(0)}%`
: "--"}
</div>
<div
className={`scan-trade-card-edge ${
(store.selectedDetail.market_scan?.price_analysis?.yes
?.edge ?? 0) > 0
? "positive"
: "negative"
}`}
>
{store.selectedDetail.market_scan?.price_analysis?.yes
?.edge != null
? `${store.selectedDetail.market_scan.price_analysis.yes.edge > 0 ? "+" : ""}${(store.selectedDetail.market_scan.price_analysis.yes.edge * 100).toFixed(1)}% 边际优势`
: "--"}
</div>
</div>
<div className="scan-trade-card no">
<div className="scan-trade-card-title">
卖出 Yes{" "}
{store.selectedDetail.market_scan?.temperature_bucket
?.label || ""}
</div>
<div className="scan-trade-card-price">
{store.selectedDetail.market_scan?.price_analysis?.no
?.ask != null
? `${(store.selectedDetail.market_scan.price_analysis.no.ask * 100).toFixed(0)}%`
: "--"}
{" → "}
{store.selectedDetail.market_scan?.price_analysis?.no
?.model_probability != null
? `${(1 - store.selectedDetail.market_scan.price_analysis.no.model_probability) * 100 > 0 ? `${((1 - store.selectedDetail.market_scan.price_analysis.no.model_probability) * 100).toFixed(0)}%` : "--"}`
: "--"}
</div>
<div
className={`scan-trade-card-edge ${
(store.selectedDetail.market_scan?.price_analysis?.no
?.edge ?? 0) > 0
? "positive"
: "negative"
}`}
>
{store.selectedDetail.market_scan?.price_analysis?.no
?.edge != null
? `${store.selectedDetail.market_scan.price_analysis.no.edge > 0 ? "+" : ""}${(store.selectedDetail.market_scan.price_analysis.no.edge * 100).toFixed(1)}% 边际优势`
: "--"}
</div>
</div>
</div>
</div>
)}
{/* Score */}
<div className="scan-detail-score">
<div>
<span
className="scan-detail-score-big"
style={{
color: (() => {
const snap = homepageSnapshots.find(
(s) => s.city.name === store.selectedCity,
);
const displayScore = getDisplayScore(snap?.score ?? 0);
return displayScore >= 85
? "#00E0A4"
: displayScore >= 70
? "#FFB020"
: "#FF4D6A";
})(),
}}
>
{(() => {
const snap = homepageSnapshots.find(
(s) => s.city.name === store.selectedCity,
);
return snap ? getDisplayScore(snap.score).toFixed(1) : "--";
})()}
</span>
<span className="scan-detail-score-suffix"> /100</span>
</div>
<div>
<div className="scan-detail-score-label">
{t("detail.compositeScore") || "综合得分"}
</div>
<div className="scan-confidence-dots">
{[1, 2, 3, 4, 5].map((i) => {
const snap = homepageSnapshots.find(
(s) => s.city.name === store.selectedCity,
);
const displayScore = getDisplayScore(snap?.score ?? 0);
const filled = displayScore >= i * 20;
return (
<span
key={i}
className={`scan-confidence-dot ${filled ? "filled" : ""} ${displayScore < 70 ? "red" : displayScore < 85 ? "amber" : ""}`}
/>
);
})}
</div>
</div>
</div>
</aside>
) : (
<aside className="scan-detail-panel scan-detail-empty">
<div className="scan-empty-state">
<Search size={48} />
<p>{t("detail.selectCity") || "← 点击左侧行查看详情"}</p>
</div>
</aside>
)}
</div>
{store.historyState.isOpen && <HistoryModal />}
{store.futureModalDate && <FutureForecastModal />}
{showLoading && (
<div className="loading-overlay">
<div className="loading-card">
<div className="loading-clouds" aria-hidden="true">
<span className="loading-cloud loading-cloud-1" />
<span className="loading-cloud loading-cloud-2" />
</div>
<div className="loading-radar" aria-hidden="true">
<div className="loading-radar-core" />
<div className="loading-radar-ring loading-radar-ring-1" />
<div className="loading-radar-ring loading-radar-ring-2" />
<div className="loading-radar-sweep" />
</div>
<div className="loading-copy">
<strong>PolyWeather</strong>
<span>{t("dashboard.loading")}</span>
</div>
</div>
</div>
)}
</div>
);
}
export function PolyWeatherDashboard() {
return (
<I18nProvider>
<DashboardStoreProvider>
<DashboardScreen />
</DashboardStoreProvider>
</I18nProvider>
);
}