深度清理 Polymarket 代码:删除10个文件+3个API路由+客户端引用
删除 MarketOverviewView、MarketDecisionLine、market-scan-state、use-city-market-scan、AnalyticsPanel、TerminalDashboard、polymarket-market-links、lib/types、market-scan 路由、use-ai-city-card-data。scan-terminal-client 移除 getMarketScan 和 skip_polymarket。
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyBackendJsonGet } from "@/lib/api-proxy";
|
||||
import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const { name } = await context.params;
|
||||
const params = new URLSearchParams();
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
params.set("force_refresh", forceRefresh);
|
||||
const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20);
|
||||
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
if (targetDate) {
|
||||
params.set("target_date", targetDate);
|
||||
}
|
||||
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
if (marketSlug) {
|
||||
params.set("market_slug", marketSlug);
|
||||
}
|
||||
|
||||
const lite = req.nextUrl.searchParams.get("lite");
|
||||
if (lite) {
|
||||
params.set("lite", lite);
|
||||
}
|
||||
|
||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`;
|
||||
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: cachePolicy.responseCacheControl,
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
fetchCache:
|
||||
cachePolicy.fetchMode === "no-store" ? "no-store" : undefined,
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
revalidateSeconds: cachePolicy.revalidateSeconds,
|
||||
statusOnException: 502,
|
||||
url,
|
||||
});
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
|
||||
import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
|
||||
import { MarketOverviewView } from "@/components/dashboard/scan-terminal/MarketOverviewView";
|
||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
function createEmptyAccess(loading = true): ProAccessState {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||
|
||||
export function MarketDecisionLine({
|
||||
isEn,
|
||||
marketDecisionView,
|
||||
marketLineText,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
marketDecisionView: MarketDecisionView;
|
||||
marketLineText: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx("scan-ai-market-mobile-line", marketDecisionView.tone)}>
|
||||
<span>{isEn ? "Market price" : "市场价格"}</span>
|
||||
<b>{marketLineText}</b>
|
||||
</div>
|
||||
<div className={clsx("scan-ai-market-decision", marketDecisionView.tone)}>
|
||||
<div>
|
||||
<span>{isEn ? "Polymarket price layer" : "Polymarket 价格层"}</span>
|
||||
<strong>{marketDecisionView.title}</strong>
|
||||
<p>{marketDecisionView.reason}</p>
|
||||
</div>
|
||||
<div className="scan-ai-market-decision-stats">
|
||||
<small>
|
||||
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "YES buy" : "YES 买价"} <b>{marketDecisionView.priceText}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
|
||||
</small>
|
||||
</div>
|
||||
{marketDecisionView.marketUrl ? (
|
||||
<a
|
||||
className="scan-ai-market-link"
|
||||
href={marketDecisionView.marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{isEn ? "Open market" : "打开市场"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
import {
|
||||
resolveTradingRegionKey,
|
||||
TRADING_REGIONS,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { edgeClass, money, pct, rowName } from "@/components/dashboard/scan-terminal/utils";
|
||||
|
||||
type Holder = {
|
||||
amount?: number;
|
||||
name?: string;
|
||||
outcomeIndex?: number;
|
||||
proxyWallet?: string;
|
||||
pseudonym?: string;
|
||||
};
|
||||
|
||||
type HolderInfo = {
|
||||
holders: Holder[] | null;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const MARKET_OVERVIEW_REFRESH_MS = 10 * 60_000;
|
||||
|
||||
function numeric(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function rowLiquidity(row: ScanOpportunityRow) {
|
||||
return numeric(row.book_liquidity || row.market_liquidity || row.volume);
|
||||
}
|
||||
|
||||
function rowOpportunityScore(row: ScanOpportunityRow) {
|
||||
return (
|
||||
numeric(row.final_score) * 0.5 +
|
||||
numeric(row.edge_percent ?? row.signed_gap ?? row.gap) * 2 +
|
||||
Math.log10(rowLiquidity(row) + 1)
|
||||
);
|
||||
}
|
||||
|
||||
function rowSpread(row: ScanOpportunityRow) {
|
||||
return numeric(row.spread);
|
||||
}
|
||||
|
||||
function isStale(row: ScanOpportunityRow) {
|
||||
return Boolean(row.metar_context?.stale_for_today || row.metar_status?.stale_for_today);
|
||||
}
|
||||
|
||||
function isWatch(row: ScanOpportunityRow) {
|
||||
const value = String(row.ai_decision || row.v4_metar_decision || row.signal_status || row.action || "").toLowerCase();
|
||||
return value.includes("watch") || !row.tradable;
|
||||
}
|
||||
|
||||
function holderLabel(holder: Holder) {
|
||||
return (
|
||||
holder.name ||
|
||||
holder.pseudonym ||
|
||||
(holder.proxyWallet ? `${String(holder.proxyWallet).slice(0, 6)}...${String(holder.proxyWallet).slice(-4)}` : "--")
|
||||
);
|
||||
}
|
||||
|
||||
function CompactRowsTable({
|
||||
empty,
|
||||
isEn,
|
||||
onSelectRow,
|
||||
rows,
|
||||
showSpread = false,
|
||||
}: {
|
||||
empty: string;
|
||||
isEn: boolean;
|
||||
onSelectRow: (row: ScanOpportunityRow) => void;
|
||||
rows: ScanOpportunityRow[];
|
||||
showSpread?: boolean;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return <div className="px-3 py-8 text-center text-[11px] text-slate-400">{empty}</div>;
|
||||
}
|
||||
return (
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-[11px] uppercase text-slate-500">
|
||||
<th className="px-3 py-1.5 text-left font-black">{isEn ? "City / Contract" : "城市 / 合约"}</th>
|
||||
<th className="px-2 py-1.5 text-right font-black">{isEn ? "Model" : "模型"}</th>
|
||||
<th className="px-2 py-1.5 text-right font-black">{isEn ? "Market" : "市场"}</th>
|
||||
<th className="px-2 py-1.5 text-right font-black">{showSpread ? (isEn ? "Spread" : "价差") : "Edge"}</th>
|
||||
<th className="px-3 py-1.5 text-right font-black">{isEn ? "Liq" : "流动性"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const edge = numeric(row.edge_percent ?? row.signed_gap ?? row.gap);
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onSelectRow(row)}
|
||||
className="cursor-pointer border-b border-slate-100 hover:bg-blue-50"
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<div className="truncate font-bold text-slate-800">{rowName(row)}</div>
|
||||
<div className="truncate text-[10px] font-medium text-slate-400">{row.target_label || row.market_question || "--"}</div>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{pct(row.model_probability ?? row.model_event_probability)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{pct(row.market_probability ?? row.market_event_probability)}</td>
|
||||
<td className={clsx("px-2 py-1.5 text-right font-mono font-bold", showSpread ? "text-slate-700" : edgeClass(edge))}>
|
||||
{showSpread ? pct(row.spread) : pct(edge)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right font-mono">{money(rowLiquidity(row))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketOverviewView({
|
||||
isEn,
|
||||
onSelectRow,
|
||||
rows,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
onSelectRow: (row: ScanOpportunityRow) => void;
|
||||
rows: ScanOpportunityRow[];
|
||||
}) {
|
||||
const [overviewRows, setOverviewRows] = useState(rows);
|
||||
const [lastScanAt, setLastScanAt] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOverviewRows(rows);
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const refreshOverview = async () => {
|
||||
if (typeof fetch !== "function" || typeof AbortController === "undefined") return;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
try {
|
||||
const response = await fetch("/api/scan/terminal?force_refresh=false", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json() as ScanTerminalResponse;
|
||||
if (!cancelled && Array.isArray(payload.rows)) {
|
||||
setOverviewRows(payload.rows);
|
||||
setLastScanAt(new Date());
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name !== "AbortError") {
|
||||
// Keep the existing snapshot; overview refresh should never blank the terminal.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshOverview();
|
||||
}, MARKET_OVERVIEW_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const primaryRows = useMemo(
|
||||
() => overviewRows.filter((row) => row.is_primary_signal !== false),
|
||||
[overviewRows],
|
||||
);
|
||||
const regionStats = useMemo(
|
||||
() =>
|
||||
TRADING_REGIONS.map((region) => {
|
||||
const list = primaryRows.filter((row) => resolveTradingRegionKey(row) === region.key);
|
||||
const avgEdge = list.reduce((sum, row) => sum + numeric(row.edge_percent ?? row.signed_gap ?? row.gap), 0) / Math.max(list.length, 1);
|
||||
return {
|
||||
key: region.key,
|
||||
label: isEn ? region.labelEn : region.labelZh,
|
||||
contracts: list.length,
|
||||
tradable: list.filter((row) => row.tradable).length,
|
||||
heat: list.filter((row) => row.risk_level === "high" || numeric(row.current_temp) >= 30).length,
|
||||
avgEdge,
|
||||
liquidity: list.reduce((sum, row) => sum + rowLiquidity(row), 0),
|
||||
};
|
||||
}).filter((item) => item.contracts > 0),
|
||||
[isEn, primaryRows],
|
||||
);
|
||||
|
||||
const topOpportunities = useMemo(
|
||||
() => [...primaryRows].sort((a, b) => rowOpportunityScore(b) - rowOpportunityScore(a)).slice(0, 12),
|
||||
[primaryRows],
|
||||
);
|
||||
const riskRows = useMemo(
|
||||
() =>
|
||||
primaryRows
|
||||
.filter((row) => isWatch(row) || numeric(row.edge_percent ?? row.signed_gap ?? row.gap) < 0 || row.closed || isStale(row))
|
||||
.sort((a, b) => numeric(a.edge_percent ?? a.signed_gap ?? a.gap) - numeric(b.edge_percent ?? b.signed_gap ?? b.gap))
|
||||
.slice(0, 12),
|
||||
[primaryRows],
|
||||
);
|
||||
const liquidityRows = useMemo(
|
||||
() => [...primaryRows].sort((a, b) => rowLiquidity(b) - rowLiquidity(a)).slice(0, 8),
|
||||
[primaryRows],
|
||||
);
|
||||
const tightSpreadRows = useMemo(
|
||||
() =>
|
||||
[...primaryRows]
|
||||
.filter((row) => rowSpread(row) > 0)
|
||||
.sort((a, b) => rowSpread(a) - rowSpread(b))
|
||||
.slice(0, 8),
|
||||
[primaryRows],
|
||||
);
|
||||
const whaleRows = useMemo(
|
||||
() => liquidityRows.slice(0, 6),
|
||||
[liquidityRows],
|
||||
);
|
||||
|
||||
const [holderMap, setHolderMap] = useState<Record<string, HolderInfo>>({});
|
||||
useEffect(() => {
|
||||
whaleRows.forEach((row) => {
|
||||
const city = String(row.city || "").toLowerCase();
|
||||
if (!city || holderMap[city]?.loading || holderMap[city]?.holders) return;
|
||||
setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: true } }));
|
||||
fetch(`/api/city/${encodeURIComponent(city)}/holders?limit=6`, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then(async (res) => {
|
||||
const json = await res.json() as { holders?: Holder[] };
|
||||
setHolderMap((prev) => ({ ...prev, [city]: { holders: json.holders || [], loading: false } }));
|
||||
})
|
||||
.catch(() => setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: false } })));
|
||||
});
|
||||
}, [holderMap, whaleRows]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-cols-[1.1fr_1fr] grid-rows-[auto_1fr] gap-2">
|
||||
<Panel
|
||||
title={isEn ? "Regional Market Heat" : "区域市场热度"}
|
||||
className="col-span-2"
|
||||
actions={
|
||||
<span className="font-mono text-[10px] font-bold text-slate-400">
|
||||
{isEn ? "Scan: 10m" : "扫描:10分钟"}
|
||||
{lastScanAt ? ` · ${lastScanAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2 p-3">
|
||||
{regionStats.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="rounded border border-slate-200 bg-slate-50 p-3 text-left hover:bg-blue-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-black text-slate-800">{item.label}</div>
|
||||
<div className={clsx("font-mono text-xs font-black", edgeClass(item.avgEdge))}>{pct(item.avgEdge)}</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||
{[
|
||||
[isEn ? "Contracts" : "合约", item.contracts],
|
||||
[isEn ? "Tradable" : "可交易", item.tradable],
|
||||
[isEn ? "Heat" : "高温", item.heat],
|
||||
[isEn ? "Liq" : "流动性", money(item.liquidity)],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded border border-slate-200 bg-white px-1 py-1">
|
||||
<div className="truncate text-[10px] font-black uppercase text-slate-400">{label}</div>
|
||||
<div className="truncate font-mono font-black text-slate-800">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={isEn ? "Top Opportunities" : "最佳机会"}>
|
||||
<CompactRowsTable
|
||||
empty={isEn ? "No opportunities" : "暂无机会"}
|
||||
isEn={isEn}
|
||||
onSelectRow={onSelectRow}
|
||||
rows={topOpportunities}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title={isEn ? "Risk / Watchlist" : "风险 / 观察列表"}>
|
||||
<CompactRowsTable
|
||||
empty={isEn ? "No risk rows" : "暂无风险项"}
|
||||
isEn={isEn}
|
||||
onSelectRow={onSelectRow}
|
||||
rows={riskRows}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title={isEn ? "Liquidity Leaderboard" : "流动性排行"}>
|
||||
<CompactRowsTable
|
||||
empty={isEn ? "No liquidity rows" : "暂无流动性数据"}
|
||||
isEn={isEn}
|
||||
onSelectRow={onSelectRow}
|
||||
rows={liquidityRows}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<div className="grid min-h-0 grid-rows-2 gap-2">
|
||||
<Panel title={isEn ? "Tightest Spreads" : "最低价差"}>
|
||||
<CompactRowsTable
|
||||
empty={isEn ? "No spread rows" : "暂无价差数据"}
|
||||
isEn={isEn}
|
||||
onSelectRow={onSelectRow}
|
||||
rows={tightSpreadRows}
|
||||
showSpread
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title={isEn ? "Whale Signals" : "大户持仓信号"}>
|
||||
<div className="divide-y divide-slate-100 text-[11px]">
|
||||
{whaleRows.map((row) => {
|
||||
const city = String(row.city || "").toLowerCase();
|
||||
const info = holderMap[city];
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
onClick={() => onSelectRow(row)}
|
||||
className="block w-full px-3 py-2 text-left hover:bg-blue-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-black text-slate-800">{rowName(row)}</span>
|
||||
<span className="font-mono font-black text-blue-700">{money(rowLiquidity(row))}</span>
|
||||
</div>
|
||||
{info?.loading ? (
|
||||
<div className="mt-1 text-[10px] text-slate-400">{isEn ? "Loading holders..." : "加载持仓..."}</div>
|
||||
) : info?.holders?.length ? (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{info.holders.slice(0, 2).map((holder, index) => (
|
||||
<div key={`${city}-${index}`} className="flex items-center justify-between gap-2 text-[10px]">
|
||||
<span className="truncate text-slate-500">{holderLabel(holder)}</span>
|
||||
<span className="font-mono text-slate-700">
|
||||
{holder.outcomeIndex === 0 ? "YES" : holder.outcomeIndex === 1 ? "NO" : ""}{" "}
|
||||
{holder.amount != null ? Number(holder.amount).toFixed(0) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[10px] text-slate-400">{isEn ? "No holder data" : "无持仓数据"}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildCityMarketScanCacheKey,
|
||||
deriveCityMarketScanView,
|
||||
normalizeCityMarketScanPayload,
|
||||
resolveCityMarketScanSnapshot,
|
||||
writeCachedCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/market-scan-state";
|
||||
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { localStorage },
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
function marketScan(label = "cached"): MarketScan {
|
||||
return {
|
||||
generated_at: "2026-04-28T00:00:00Z",
|
||||
label,
|
||||
} as unknown as MarketScan;
|
||||
}
|
||||
|
||||
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
|
||||
return {
|
||||
local_date: "2026-04-28",
|
||||
name: "Test City",
|
||||
...extra,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const storage = installLocalStorageMock();
|
||||
storage.clear();
|
||||
|
||||
const embeddedScan = marketScan("embedded");
|
||||
const embeddedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail({ market_scan: embeddedScan }),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
});
|
||||
assert.equal(embeddedSnapshot.action, "success");
|
||||
if (embeddedSnapshot.action === "success") {
|
||||
assert.equal(embeddedSnapshot.payload, embeddedScan);
|
||||
assert.equal(embeddedSnapshot.shouldWriteCache, true);
|
||||
}
|
||||
|
||||
const cacheKey = buildCityMarketScanCacheKey({
|
||||
detailCityName: "Test City",
|
||||
localDate: "2026-04-28",
|
||||
});
|
||||
const cachedScan = marketScan("cached");
|
||||
writeCachedCityMarketScan(cacheKey, cachedScan);
|
||||
const cachedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(cachedSnapshot.action, "success");
|
||||
if (cachedSnapshot.action === "success") {
|
||||
assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached");
|
||||
}
|
||||
|
||||
storage.clear();
|
||||
writeCachedCityMarketScan(cacheKey, {
|
||||
market_scan: {
|
||||
available: true,
|
||||
market_price: 0.41,
|
||||
selected_slug: "highest-temperature-in-nyc-on-april-30-2026-60-61f",
|
||||
},
|
||||
selected_date: "2026-04-30",
|
||||
} as unknown as MarketScan);
|
||||
const wrappedCachedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
});
|
||||
assert.equal(wrappedCachedSnapshot.action, "success");
|
||||
if (wrappedCachedSnapshot.action === "success") {
|
||||
assert.equal(wrappedCachedSnapshot.payload.available, true);
|
||||
assert.equal(wrappedCachedSnapshot.payload.market_price, 0.41);
|
||||
assert.equal(wrappedCachedSnapshot.payload.selected_date, "2026-04-30");
|
||||
}
|
||||
|
||||
const wrappedResponse = normalizeCityMarketScanPayload({
|
||||
market_scan: { available: true, yes_buy: 0.42 } as MarketScan,
|
||||
selected_date: "2026-04-30",
|
||||
});
|
||||
assert.equal(wrappedResponse?.available, true);
|
||||
assert.equal(wrappedResponse?.yes_buy, 0.42);
|
||||
assert.equal(wrappedResponse?.selected_date, "2026-04-30");
|
||||
|
||||
storage.clear();
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
}).action,
|
||||
"reset",
|
||||
);
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
}).action,
|
||||
"fetch",
|
||||
);
|
||||
|
||||
const previous = marketScan("previous");
|
||||
const loadingRemote: RemoteData<MarketScan> = {
|
||||
previous,
|
||||
status: "loading",
|
||||
};
|
||||
const loadingView = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: loadingRemote,
|
||||
});
|
||||
assert.equal(loadingView.marketStatus, "loading");
|
||||
assert.equal(loadingView.marketScan, previous);
|
||||
|
||||
const errorWithPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
previous,
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithPrevious.marketStatus, "ready");
|
||||
assert.equal(errorWithPrevious.marketScan, previous);
|
||||
|
||||
const errorWithoutPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithoutPrevious.marketStatus, "failed");
|
||||
assert.equal(errorWithoutPrevious.marketScan, null);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
buildStorageKey,
|
||||
readCachedPayload,
|
||||
writeCachedPayload,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
|
||||
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v4";
|
||||
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
|
||||
|
||||
export type CityMarketScanApiPayload =
|
||||
| MarketScan
|
||||
| {
|
||||
fetched_at?: string | null;
|
||||
market_scan?: MarketScan | null;
|
||||
selected_date?: string | null;
|
||||
};
|
||||
|
||||
export type CityMarketScanSnapshot =
|
||||
| { action: "reset"; cacheKey?: string }
|
||||
| { action: "success"; cacheKey: string; payload: MarketScan; shouldWriteCache?: boolean }
|
||||
| { action: "fetch"; cacheKey: string };
|
||||
|
||||
export function buildCityMarketScanCacheKey({
|
||||
detailCityName,
|
||||
localDate,
|
||||
}: {
|
||||
detailCityName: string;
|
||||
localDate?: string | null;
|
||||
}) {
|
||||
return buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
|
||||
normalizeCityKey(detailCityName),
|
||||
localDate || "",
|
||||
"full",
|
||||
]);
|
||||
}
|
||||
|
||||
export function readCachedCityMarketScan(cacheKey: string) {
|
||||
return normalizeCityMarketScanPayload(
|
||||
readCachedPayload<CityMarketScanApiPayload>(cacheKey, CITY_MARKET_SCAN_CACHE_TTL_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCachedCityMarketScan(cacheKey: string, payload: MarketScan) {
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
}
|
||||
|
||||
export function normalizeCityMarketScanPayload(
|
||||
payload: CityMarketScanApiPayload | null | undefined,
|
||||
): MarketScan | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
if ("market_scan" in payload) {
|
||||
const scan = payload.market_scan;
|
||||
if (!scan || typeof scan !== "object") return null;
|
||||
return {
|
||||
...scan,
|
||||
selected_date: scan.selected_date ?? payload.selected_date ?? null,
|
||||
};
|
||||
}
|
||||
return payload as MarketScan;
|
||||
}
|
||||
|
||||
export function resolveCityMarketScanSnapshot({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled: boolean;
|
||||
}): CityMarketScanSnapshot {
|
||||
if (!detail) return { action: "reset" };
|
||||
const cacheKey = buildCityMarketScanCacheKey({
|
||||
detailCityName,
|
||||
localDate: detail.local_date || "",
|
||||
});
|
||||
if (detail.market_scan) {
|
||||
return {
|
||||
action: "success",
|
||||
cacheKey,
|
||||
payload: detail.market_scan,
|
||||
shouldWriteCache: true,
|
||||
};
|
||||
}
|
||||
const cached = readCachedCityMarketScan(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
action: "success",
|
||||
cacheKey,
|
||||
payload: cached,
|
||||
};
|
||||
}
|
||||
return enabled ? { action: "fetch", cacheKey } : { action: "reset", cacheKey };
|
||||
}
|
||||
|
||||
export function deriveCityMarketScanView({
|
||||
detailMarketScan,
|
||||
marketRemote,
|
||||
}: {
|
||||
detailMarketScan?: MarketScan | null;
|
||||
marketRemote: RemoteData<MarketScan>;
|
||||
}) {
|
||||
const previousMarketScan =
|
||||
marketRemote.status === "loading" || marketRemote.status === "error"
|
||||
? marketRemote.previous ?? null
|
||||
: null;
|
||||
const marketScan =
|
||||
marketRemote.status === "success"
|
||||
? marketRemote.data
|
||||
: previousMarketScan ?? detailMarketScan ?? null;
|
||||
const marketStatus: CityMarketScanStatus =
|
||||
marketRemote.status === "success"
|
||||
? "ready"
|
||||
: marketRemote.status === "loading"
|
||||
? "loading"
|
||||
: marketRemote.status === "error"
|
||||
? marketScan
|
||||
? "ready"
|
||||
: "failed"
|
||||
: "idle";
|
||||
|
||||
return { marketScan, marketStatus };
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { extractStreamingAirportRead } from "@/components/dashboard/scan-terminal/ai-city-stream";
|
||||
import {
|
||||
normalizeCityMarketScanPayload,
|
||||
type CityMarketScanApiPayload,
|
||||
} from "@/components/dashboard/scan-terminal/market-scan-state";
|
||||
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
||||
import {
|
||||
buildBrowserBackendHeaders,
|
||||
@@ -65,10 +61,6 @@ type CityDetailQueryOptions = {
|
||||
targetDate?: string | null;
|
||||
};
|
||||
|
||||
type MarketScanQueryOptions = CityDetailQueryOptions & {
|
||||
lite?: boolean;
|
||||
};
|
||||
|
||||
type AiCityReadOptions = {
|
||||
city: string;
|
||||
forceRefresh?: boolean;
|
||||
@@ -308,30 +300,6 @@ async function getCityDetail(city: string, options: CityDetailQueryOptions = {})
|
||||
);
|
||||
}
|
||||
|
||||
async function getMarketScan(city: string, options: MarketScanQueryOptions = {}) {
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: String(options.forceRefresh ?? false),
|
||||
});
|
||||
if (options.targetDate) params.set("target_date", options.targetDate);
|
||||
if (options.marketSlug) params.set("market_slug", options.marketSlug);
|
||||
if (options.lite != null) params.set("lite", String(options.lite));
|
||||
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
|
||||
const payload = await readJsonOrThrow<CityMarketScanApiPayload>(
|
||||
`/api/city/${encodeURIComponent(city)}/market-scan?${params.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return (
|
||||
normalizeCityMarketScanPayload(payload) ?? {
|
||||
available: false,
|
||||
reason: "No market scan payload returned.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function runQueuedAiCityReadTask<T>(task: () => Promise<T>, onQueued?: () => void) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const run = () => {
|
||||
@@ -432,7 +400,6 @@ function streamAiCityRead(options: AiCityReadOptions) {
|
||||
|
||||
export const scanTerminalClient = {
|
||||
getCityDetail,
|
||||
getMarketScan,
|
||||
getTerminal,
|
||||
streamAiCityRead,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export { useAiCityForecast } from "./use-ai-city-forecast";
|
||||
export { useCityMarketScan } from "./use-city-market-scan";
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import {
|
||||
deriveCityMarketScanView,
|
||||
resolveCityMarketScanSnapshot,
|
||||
writeCachedCityMarketScan,
|
||||
} from "./market-scan-state";
|
||||
|
||||
export function useCityMarketScan({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled = true,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const {
|
||||
remote: marketRemote,
|
||||
reset: resetMarketRemote,
|
||||
run: runMarketScanQuery,
|
||||
setSuccess: setMarketScanSuccess,
|
||||
} = useRemoteDataQuery<MarketScan>();
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = resolveCityMarketScanSnapshot({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
});
|
||||
if (snapshot.action === "reset") {
|
||||
resetMarketRemote();
|
||||
return;
|
||||
}
|
||||
if (snapshot.action === "success") {
|
||||
setMarketScanSuccess(snapshot.payload);
|
||||
if (snapshot.shouldWriteCache) {
|
||||
writeCachedCityMarketScan(snapshot.cacheKey, snapshot.payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void runMarketScanQuery({
|
||||
request: (signal) =>
|
||||
scanTerminalClient.getMarketScan(detailCityName, {
|
||||
lite: false,
|
||||
signal,
|
||||
targetDate: detail?.local_date || null,
|
||||
}),
|
||||
showLoading: true,
|
||||
onSuccess: (payload) => {
|
||||
if (payload) {
|
||||
writeCachedCityMarketScan(snapshot.cacheKey, payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
resetMarketRemote,
|
||||
runMarketScanQuery,
|
||||
setMarketScanSuccess,
|
||||
]);
|
||||
|
||||
const { marketScan, marketStatus } = deriveCityMarketScanView({
|
||||
detailMarketScan: detail?.market_scan,
|
||||
marketRemote,
|
||||
});
|
||||
|
||||
return { marketRemote, marketScan, marketStatus };
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart2,
|
||||
Target,
|
||||
Zap,
|
||||
Info,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
import { CityDetail } from "../../lib/types";
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
data: CityDetail;
|
||||
t?: any; // Optional translations
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
data,
|
||||
t = {}, // Default empty for now, can be expanded via context or props
|
||||
}: AnalyticsPanelProps) {
|
||||
const { overview, market_scan, models } = data;
|
||||
|
||||
const modelEntries = Object.entries(models)
|
||||
.filter(([_, v]) => v !== undefined && v !== null)
|
||||
.map(([label, value]) => ({ label, value: value as number }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const signalColor =
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "border-rose-500/30 bg-rose-500/5 text-rose-400"
|
||||
: "border-zinc-800 bg-zinc-900/50 text-zinc-400";
|
||||
|
||||
const signalIconColor =
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "text-emerald-500"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "text-rose-500"
|
||||
: "text-zinc-500";
|
||||
|
||||
return (
|
||||
<div className="flex relative h-full w-[400px] flex-col bg-zinc-950/20 backdrop-blur-sm border-l border-zinc-800/50 overflow-hidden">
|
||||
{/* Header Stat Area */}
|
||||
<div className="p-4 border-b border-zinc-800 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||
{t.marketAnalysis || "MARKET ANALYSIS"}
|
||||
</h2>
|
||||
<Badge className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border-cyan-500/30 text-[9px] h-5">
|
||||
{t.realTime || "LIVE UPLINK"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.liveMetar || "REAL-TIME METAR"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-white">
|
||||
{overview.current_temp?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3 ring-1 ring-inset ring-white/5">
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.ensembleDeb || "ENSEMBLE DEB"}
|
||||
</span>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-black text-cyan-400">
|
||||
{overview.deb_prediction?.toFixed(1) || "--"}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-cyan-500">
|
||||
{overview.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Execution Signal */}
|
||||
<div
|
||||
className={`rounded-xl border p-4 transition-all duration-500 ${signalColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.signalStrategy || "SIGNAL STRATEGY"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${market_scan.available ? "animate-pulse" : ""} ${signalIconColor}`}
|
||||
/>
|
||||
<span className="text-sm font-black text-white italic uppercase tracking-wider">
|
||||
{market_scan.signal_label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
|
||||
{t.confidence || "CONFIDENCE"}
|
||||
</span>
|
||||
<div className="mt-1 text-xs font-black text-white opacity-80 uppercase tracking-widest">
|
||||
{market_scan.confidence}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 mt-2 flex items-end">
|
||||
<Sparkline
|
||||
data={
|
||||
market_scan.sparkline.length > 0
|
||||
? market_scan.sparkline
|
||||
: [1, 1, 1]
|
||||
}
|
||||
width={360}
|
||||
height={35}
|
||||
color={
|
||||
market_scan.signal_label === "BUY YES"
|
||||
? "#10b981"
|
||||
: market_scan.signal_label === "BUY NO"
|
||||
? "#f43f5e"
|
||||
: "#52525b"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probability & Market Pricing */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar pb-20">
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Target className="h-3 w-3 text-violet-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.yieldArbitrage || "YIELD ARBITRAGE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase font-bold text-zinc-500">
|
||||
<span>{t.probComparison || "PROB COMPARISON"}</span>
|
||||
<span className="text-cyan-400">
|
||||
{t.predictionGap || "GAP"}:{" "}
|
||||
{market_scan.edge_percent?.toFixed(1) || "0.0"}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Visual Probability Bar */}
|
||||
<div className="relative h-4 w-full bg-zinc-900 rounded-sm border border-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-zinc-800 border-r border-zinc-700 transition-all duration-700"
|
||||
style={{ width: `${(market_scan.market_price || 0) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-full bg-violet-500/40 border-r border-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.5)] transition-all duration-1000"
|
||||
style={{
|
||||
width: `${(market_scan.model_probability || 0) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-zinc-500 uppercase font-black">
|
||||
{t.marketPriceLabel || "MIDPOINT"}
|
||||
</span>
|
||||
<span className="font-mono text-zinc-100 font-bold text-sm">
|
||||
${market_scan.market_price?.toFixed(2) || "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-md bg-zinc-900/40 px-3 py-2 border border-zinc-800">
|
||||
<span className="text-[9px] text-violet-500 uppercase font-black">
|
||||
{t.modelProbLabel || "MODEL PROB"}
|
||||
</span>
|
||||
<span className="font-mono text-violet-400 font-bold text-sm">
|
||||
{((market_scan.model_probability || 0) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart2 className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-zinc-400">
|
||||
{t.multiModelDivergence || "MULTI-MODEL DIVERGENCE"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{modelEntries.map((m) => (
|
||||
<div key={m.label} className="group flex flex-col gap-1 px-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-zinc-500 group-hover:text-zinc-300 transition-colors uppercase">
|
||||
{m.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-white text-right">
|
||||
{m.value.toFixed(1)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-1 w-full bg-zinc-800/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
m.value > 25
|
||||
? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]"
|
||||
: m.value > 15
|
||||
? "bg-amber-500"
|
||||
: "bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.5)]"
|
||||
}`}
|
||||
style={{ width: `${(m.value / 40) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Execute Scan Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-zinc-950/80 backdrop-blur-md border-t border-zinc-800/50">
|
||||
<button className="w-full rounded-lg bg-cyan-500 py-3 text-[11px] font-black uppercase tracking-widest text-black shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-all hover:bg-cyan-400 hover:scale-[0.98] active:scale-95 disabled:opacity-50 disabled:pointer-events-none">
|
||||
EXECUTE MARKET RE-SCAN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import React from "react";
|
||||
import { Search, Globe, ChevronRight, Activity } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CityInfo } from "@/lib/types";
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
interface CitySidebarProps {
|
||||
cities: CityInfo[];
|
||||
activeCity: string;
|
||||
onSelectCity: (name: string) => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export function CitySidebar({
|
||||
cities,
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
t,
|
||||
}: CitySidebarProps) {
|
||||
return (
|
||||
<aside className="flex h-full w-[300px] flex-col border-r border-terminal-border bg-terminal/95 transition-all">
|
||||
{/* Search Header */}
|
||||
<div className="p-4 border-b border-terminal-border">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 transition-colors group-focus-within:text-neon-cyan" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.lookupPlaceholder || "Search Terminal..."}
|
||||
className="w-full rounded-lg border border-terminal-border bg-slate-900/50 py-2 pl-9 pr-4 text-xs text-white placeholder:text-slate-600 focus:border-neon-cyan/50 focus:outline-none focus:ring-1 focus:ring-neon-cyan/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monitor List */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-4 custom-scrollbar">
|
||||
<div className="mb-4 flex items-center justify-between px-3">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">
|
||||
{t.cityMonitor || "MONITORING NODES"}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 rounded-md border-terminal-border text-[10px] text-slate-400"
|
||||
>
|
||||
{cities.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{cities.map((city) => {
|
||||
const isActive =
|
||||
activeCity.toLowerCase() === city.name.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={city.name}
|
||||
onClick={() => onSelectCity(city.name)}
|
||||
className={`group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? "bg-neon-cyan/10 ring-1 ring-neon-cyan/30"
|
||||
: "hover:bg-slate-900/50"
|
||||
}`}
|
||||
>
|
||||
{/* Active Indicator Bar */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-4 w-1 -translate-y-1/2 rounded-r bg-neon-cyan shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span
|
||||
className={`truncate text-[11px] font-bold uppercase tracking-wider ${isActive ? "text-neon-cyan" : "text-slate-200"}`}
|
||||
>
|
||||
{city.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-slate-500">
|
||||
{city.icao}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
city.risk_level === "high"
|
||||
? "bg-rose-500 shadow-[0_0_4px_rgba(244,63,94,0.6)]"
|
||||
: city.risk_level === "medium"
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate text-[10px] text-slate-500 font-medium">
|
||||
{city.airport.replace("Airport", "Intl")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 px-1">
|
||||
<Sparkline
|
||||
data={[10, 12, 11, 13, 12]}
|
||||
width={40}
|
||||
height={15}
|
||||
strokeWidth={1.5}
|
||||
color="#475569"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${isActive ? "translate-x-0.5 text-neon-cyan" : "text-slate-700 opacity-0 group-hover:opacity-100"}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Status */}
|
||||
<div className="border-t border-terminal-border bg-slate-950 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||
UPLINK STATUS
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.8)]" />
|
||||
<span className="text-[10px] font-bold text-emerald-500">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-terminal-border bg-slate-900/50 p-2">
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-slate-400">
|
||||
<Activity className="h-3 w-3 text-neon-cyan" />
|
||||
<span>LATENCY: 12ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { CitySidebar } from "./Sidebar";
|
||||
import { AnalyticsPanel } from "./AnalyticsPanel";
|
||||
import {
|
||||
RefreshCw,
|
||||
LayoutGrid,
|
||||
Maximize2,
|
||||
Layers,
|
||||
Languages,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TerminalDashboardProps {
|
||||
cities: any[];
|
||||
activeCity: string;
|
||||
onSelectCity: (name: string) => void;
|
||||
cityData: any;
|
||||
marketData: any;
|
||||
officialData: any;
|
||||
isLoading: boolean;
|
||||
refresh: () => void;
|
||||
lang: any;
|
||||
setLang: (lang: any) => void;
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
t: any;
|
||||
children: React.ReactNode; // For the Map
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
label,
|
||||
active = false,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`text-[10px] font-black uppercase tracking-widest transition-colors ${
|
||||
active
|
||||
? "text-neon-cyan shadow-[0_0_10px_rgba(34,211,238,0.3)]"
|
||||
: "text-slate-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalDashboard({
|
||||
cities,
|
||||
activeCity,
|
||||
onSelectCity,
|
||||
cityData,
|
||||
marketData,
|
||||
officialData,
|
||||
isLoading,
|
||||
refresh,
|
||||
lang,
|
||||
setLang,
|
||||
theme,
|
||||
toggleTheme,
|
||||
t,
|
||||
children,
|
||||
}: TerminalDashboardProps) {
|
||||
const currentTemp = cityData?.current?.temp ?? null;
|
||||
const debPrediction = cityData?.deb?.prediction ?? null;
|
||||
const marketPrice = marketData?.markets?.[0]?.price ?? 0.62; // Placeholder
|
||||
|
||||
const modelEntries = Object.entries(cityData?.multi_model ?? {})
|
||||
.map(([label, value]) => ({ label, value: value as number }))
|
||||
.filter((e) => typeof e.value === "number");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-hidden bg-terminal font-sans text-slate-200 antialiased">
|
||||
{/* Top Global Ticker / Nav */}
|
||||
<div className="flex h-14 items-center justify-between border-b border-terminal-border bg-slate-950/50 px-6 backdrop-blur-md">
|
||||
<div className="flex items-center gap-6">
|
||||
<NavLink href="#" label={t.marketScan} active />
|
||||
<NavLink href="#" label="FORENSICS" />
|
||||
<NavLink href="#" label="ARBITRAGE" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-slate-900 px-3 py-1 border border-terminal-border">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">
|
||||
{t.node || "Node"}:
|
||||
</span>
|
||||
<span className="text-[10px] font-mono font-bold text-neon-cyan tracking-widest">
|
||||
CLOUD-ALPHA-01
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-[10px] font-bold text-slate-400 hover:bg-white/5 hover:text-white transition-all border border-transparent hover:border-terminal-border"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="uppercase tracking-widest">{t.refresh}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border-r border-white/10 pr-3 mr-1 gap-1">
|
||||
<button
|
||||
onClick={() => setLang(lang === "en" ? "zh" : "en")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
title={lang === "en" ? "Switch to Chinese" : "切换为英文"}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-white/5 hover:text-neon-cyan transition-all"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 border-terminal-border bg-slate-900/50 text-[10px] font-mono text-slate-400"
|
||||
>
|
||||
NODE: CLOUD-ALPHA-01
|
||||
</Badge>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-terminal-border bg-slate-900/50 px-3 transition-all hover:bg-slate-800"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-slate-400 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
REFRESH
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Component List */}
|
||||
<CitySidebar
|
||||
cities={cities}
|
||||
activeCity={activeCity}
|
||||
onSelectCity={onSelectCity}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Center: Interactive Map Area */}
|
||||
<div className="relative flex-1 bg-slate-950 overflow-hidden flex flex-col">
|
||||
{/* Map UI Overlay */}
|
||||
<div className="absolute left-4 top-4 z-10 flex flex-col gap-2">
|
||||
<div className="rounded-lg border border-terminal-border bg-terminal/80 p-2 backdrop-blur-md shadow-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase">
|
||||
Current Focus
|
||||
</span>
|
||||
<span className="text-xs font-black text-white uppercase tracking-widest">
|
||||
{activeCity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-4 z-10 flex flex-col gap-2">
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Layers className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="rounded-md border border-terminal-border bg-terminal/80 p-2 text-slate-400 hover:text-white backdrop-blur-md">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The Map */}
|
||||
<div className="flex-1 h-full min-h-0 relative bg-zinc-900/10">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Bottom Map Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10 flex items-center justify-between pointer-events-none">
|
||||
<div className="rounded-md border border-terminal-border bg-terminal/80 px-3 py-1.5 backdrop-blur-md pointer-events-auto">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
Projection: Mercator · Layer: Dynamic Thermal Ensemble
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">
|
||||
STABLE VIBE
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Intelligence Panel */}
|
||||
<AnalyticsPanel
|
||||
data={
|
||||
{
|
||||
city: activeCity,
|
||||
fetched_at: cityData?.updated_at || "",
|
||||
overview: {
|
||||
name: activeCity,
|
||||
display_name: cityData?.display_name || activeCity,
|
||||
icao: officialData?.aviation_weather?.icao || "",
|
||||
airport: cityData?.risk?.airport || "",
|
||||
lat: cityData?.lat || 0,
|
||||
lon: cityData?.lon || 0,
|
||||
local_time: cityData?.local_time || "",
|
||||
local_date: cityData?.local_date || "",
|
||||
temp_symbol: cityData?.temp_symbol || "°C",
|
||||
current_temp: currentTemp,
|
||||
deb_prediction: debPrediction,
|
||||
risk_level: cityData?.risk?.level || "low",
|
||||
risk_warning: cityData?.risk?.warning || "",
|
||||
updated_at: cityData?.updated_at || "",
|
||||
},
|
||||
official: {
|
||||
available: !!officialData?.aviation_weather?.available,
|
||||
metar: officialData?.aviation_weather?.observation,
|
||||
weather_gov: officialData?.weather_gov || {},
|
||||
mgm: cityData?.mgm || {},
|
||||
mgm_nearby: [],
|
||||
},
|
||||
timeseries: {
|
||||
metar_recent_obs: [],
|
||||
metar_today_obs: [],
|
||||
hourly: {},
|
||||
mgm_hourly: [],
|
||||
forecast_daily: cityData?.forecast?.multi_day || [],
|
||||
},
|
||||
models: cityData?.multi_model || {},
|
||||
probabilities: cityData?.probabilities || {
|
||||
mu: null,
|
||||
distribution: [],
|
||||
},
|
||||
market_scan: {
|
||||
available: !!marketData?.markets?.length,
|
||||
reason: null,
|
||||
primary_market: marketData?.markets?.[0] || null,
|
||||
selected_date: marketData?.target_date || null,
|
||||
selected_condition_id: null,
|
||||
selected_slug: null,
|
||||
temperature_bucket: null,
|
||||
model_probability: null,
|
||||
market_price: marketPrice,
|
||||
edge_percent: null,
|
||||
signal_label: "MONITOR",
|
||||
confidence: "low",
|
||||
yes_token: null,
|
||||
no_token: null,
|
||||
yes_buy: null,
|
||||
yes_sell: null,
|
||||
no_buy: null,
|
||||
no_sell: null,
|
||||
last_trade_price: null,
|
||||
liquidity: null,
|
||||
volume: null,
|
||||
sparkline: [],
|
||||
recent_trades: [],
|
||||
websocket: marketData?.websocket || {},
|
||||
},
|
||||
risk: cityData?.risk,
|
||||
ai_analysis: cityData?.ai_analysis || "",
|
||||
errors: {},
|
||||
} as any
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Ticker */}
|
||||
<footer className="h-8 border-t border-terminal-border bg-terminal-bg flex items-center px-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-neon-cyan uppercase flex-shrink-0">
|
||||
TICKER:
|
||||
</span>
|
||||
<div className="flex items-center gap-6 animate-marquee whitespace-nowrap">
|
||||
{cities.slice(0, 10).map((c) => (
|
||||
<div key={c.name} className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-300">
|
||||
{c.display_name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-emerald-500">
|
||||
24.5°
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-600">LTAC</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
const CITY_TO_MARKET_SLUG: Record<string, string> = {
|
||||
ankara: "ankara",
|
||||
atlanta: "atlanta",
|
||||
austin: "austin",
|
||||
beijing: "beijing",
|
||||
"buenos aires": "buenos-aires",
|
||||
chengdu: "chengdu",
|
||||
chicago: "chicago",
|
||||
chongqing: "chongqing",
|
||||
dallas: "dallas",
|
||||
houston: "houston",
|
||||
"hong kong": "hong-kong",
|
||||
istanbul: "istanbul",
|
||||
london: "london",
|
||||
"los angeles": "los-angeles",
|
||||
lucknow: "lucknow",
|
||||
madrid: "madrid",
|
||||
mexico: "mexico-city",
|
||||
"mexico city": "mexico-city",
|
||||
miami: "miami",
|
||||
milan: "milan",
|
||||
munich: "munich",
|
||||
"new york": "nyc",
|
||||
paris: "paris",
|
||||
qingdao: "qingdao",
|
||||
"san francisco": "san-francisco",
|
||||
"sao paulo": "sao-paulo",
|
||||
seattle: "seattle",
|
||||
seoul: "seoul",
|
||||
shanghai: "shanghai",
|
||||
shenzhen: "shenzhen",
|
||||
singapore: "singapore",
|
||||
taipei: "taipei",
|
||||
"tel aviv": "tel-aviv",
|
||||
tokyo: "tokyo",
|
||||
toronto: "toronto",
|
||||
warsaw: "warsaw",
|
||||
wellington: "wellington",
|
||||
wuhan: "wuhan",
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"january",
|
||||
"february",
|
||||
"march",
|
||||
"april",
|
||||
"may",
|
||||
"june",
|
||||
"july",
|
||||
"august",
|
||||
"september",
|
||||
"october",
|
||||
"november",
|
||||
"december",
|
||||
];
|
||||
|
||||
function normalizeCityKey(detail?: CityDetail | null) {
|
||||
return String(detail?.name || detail?.display_name || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function slugifyCityName(cityKey: string) {
|
||||
return cityKey
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['’.]/g, "")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function normalizeDateParts(localDate?: string | null) {
|
||||
const value = String(localDate || "").trim();
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const monthIndex = Number(match[2]) - 1;
|
||||
const day = Number(match[3]);
|
||||
if (
|
||||
!Number.isFinite(year) ||
|
||||
!Number.isFinite(monthIndex) ||
|
||||
!Number.isFinite(day) ||
|
||||
monthIndex < 0 ||
|
||||
monthIndex > 11
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
year,
|
||||
month: MONTHS[monthIndex],
|
||||
day,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTodayPolymarketUrl(
|
||||
detail?: CityDetail | null,
|
||||
locale: Locale = "en-US",
|
||||
) {
|
||||
const directMarketUrl = String(
|
||||
detail?.market_scan?.market_url ||
|
||||
detail?.market_scan?.primary_market_url ||
|
||||
detail?.market_scan?.primary_market?.market_url ||
|
||||
"",
|
||||
).trim();
|
||||
if (directMarketUrl) {
|
||||
return directMarketUrl;
|
||||
}
|
||||
|
||||
const cityKey = normalizeCityKey(detail);
|
||||
const citySlug = CITY_TO_MARKET_SLUG[cityKey] || slugifyCityName(cityKey);
|
||||
const dateParts = normalizeDateParts(detail?.local_date);
|
||||
if (!citySlug || !dateParts) return null;
|
||||
|
||||
const prefix =
|
||||
locale === "zh-CN"
|
||||
? "https://polymarket.com/zh/event/"
|
||||
: "https://polymarket.com/event/";
|
||||
|
||||
return `${prefix}highest-temperature-in-${citySlug}-on-${dateParts.month}-${dateParts.day}-${dateParts.year}`;
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
// --- Polymarket Market Types ---
|
||||
|
||||
export interface MarketBook {
|
||||
best_bid: number;
|
||||
best_ask: number;
|
||||
bid_levels: [number, number][]; // [price, size]
|
||||
ask_levels: [number, number][]; // [price, size]
|
||||
}
|
||||
|
||||
export interface MarketToken {
|
||||
outcome: string;
|
||||
token_id: string;
|
||||
implied_probability: number;
|
||||
buy_price: number;
|
||||
sell_price: number;
|
||||
midpoint: number;
|
||||
last_trade_price: number;
|
||||
book?: MarketBook;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
price: number;
|
||||
size: number;
|
||||
side: "buy" | "sell";
|
||||
timestamp: string;
|
||||
timestamp_iso?: string | null;
|
||||
outcome?: string | null;
|
||||
asset?: string | null;
|
||||
transaction_hash?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketSnapshot {
|
||||
id: string;
|
||||
question: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
event_slug: string;
|
||||
condition_id: string;
|
||||
target_date: string;
|
||||
active: boolean;
|
||||
closed: boolean;
|
||||
archived: boolean;
|
||||
enable_order_book: boolean;
|
||||
liquidity: number;
|
||||
volume: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
tokens: MarketToken[];
|
||||
recent_trades: Trade[];
|
||||
}
|
||||
|
||||
export interface MarketPlatformData {
|
||||
city: string;
|
||||
target_date: string;
|
||||
fetched_at: string;
|
||||
market_count: number;
|
||||
markets: MarketSnapshot[];
|
||||
websocket: {
|
||||
market_url: string;
|
||||
asset_ids: string[];
|
||||
condition_ids: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// --- Official Weather Types (Aviation) ---
|
||||
|
||||
export interface METARObservation {
|
||||
source: string;
|
||||
icao: string;
|
||||
station_name: string;
|
||||
timestamp: string;
|
||||
observation_time: string;
|
||||
current: {
|
||||
temp: number;
|
||||
max_temp_so_far: number;
|
||||
max_temp_time: string;
|
||||
dewpoint: number;
|
||||
wind_speed_kt: number;
|
||||
wind_dir: number;
|
||||
visibility_mi: string | number;
|
||||
wx_desc: string | null;
|
||||
altimeter: number;
|
||||
clouds: any[];
|
||||
};
|
||||
recent_temps: number[];
|
||||
today_obs: any[];
|
||||
recent_obs: any[];
|
||||
unit: "celsius" | "fahrenheit";
|
||||
}
|
||||
|
||||
export interface AviationWeatherData {
|
||||
available: boolean;
|
||||
source: string;
|
||||
icao: string;
|
||||
observation: METARObservation;
|
||||
}
|
||||
|
||||
// --- Official Weather Types (Weather.gov) ---
|
||||
|
||||
export interface WeatherGovPeriod {
|
||||
name: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
is_daytime?: boolean;
|
||||
temperature: number;
|
||||
temperature_unit: string;
|
||||
wind_speed?: string;
|
||||
wind_direction?: string;
|
||||
short_forecast?: string;
|
||||
detailed_forecast?: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovAlert {
|
||||
id: string;
|
||||
event: string;
|
||||
severity: string;
|
||||
certainty: string;
|
||||
urgency: string;
|
||||
headline: string;
|
||||
onset: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovData {
|
||||
available: boolean;
|
||||
source: string;
|
||||
city: string;
|
||||
grid: any;
|
||||
nearest_station: {
|
||||
station_identifier?: string;
|
||||
name?: string;
|
||||
timezone?: string;
|
||||
elevation_m?: number | null;
|
||||
} | null;
|
||||
latest_observation: {
|
||||
station_identifier?: string;
|
||||
temperature_c?: number | null;
|
||||
dewpoint_c?: number | null;
|
||||
wind_direction_deg?: number | null;
|
||||
wind_speed_kmh?: number | null;
|
||||
text_description?: string | null;
|
||||
timestamp?: string;
|
||||
} | null;
|
||||
stations: any[];
|
||||
forecast_periods: WeatherGovPeriod[];
|
||||
hourly_periods: WeatherGovPeriod[];
|
||||
active_alerts: WeatherGovAlert[];
|
||||
}
|
||||
|
||||
export interface OfficialWeatherData {
|
||||
city: string;
|
||||
fetched_at: string;
|
||||
aviation_weather?: AviationWeatherData;
|
||||
weather_gov?: WeatherGovData;
|
||||
}
|
||||
|
||||
// --- City List Types ---
|
||||
|
||||
export interface CityInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk_level: "low" | "medium" | "high";
|
||||
risk_emoji: string;
|
||||
airport: string;
|
||||
icao: string;
|
||||
temp_unit: "celsius" | "fahrenheit";
|
||||
is_major: boolean;
|
||||
settlement_source?: string;
|
||||
settlement_source_label?: string;
|
||||
}
|
||||
|
||||
export interface CitiesResponse {
|
||||
cities: CityInfo[];
|
||||
}
|
||||
|
||||
// --- Full City Analysis Types ---
|
||||
|
||||
export interface ModelComparison {
|
||||
[key: string]: number | undefined;
|
||||
"Open-Meteo"?: number;
|
||||
ECMWF?: number;
|
||||
GFS?: number;
|
||||
ICON?: number;
|
||||
GEM?: number;
|
||||
JMA?: number;
|
||||
LGBM?: number;
|
||||
MGM?: number;
|
||||
NWS?: number;
|
||||
}
|
||||
|
||||
export interface DEBAnalysis {
|
||||
prediction: number | null;
|
||||
weights_info?: string;
|
||||
}
|
||||
|
||||
export interface TrendInfo {
|
||||
direction: string;
|
||||
recent: Array<{ time: string; temp: number }>;
|
||||
is_cooling: boolean;
|
||||
is_dead_market: boolean;
|
||||
}
|
||||
|
||||
export interface PeakInfo {
|
||||
hours: string[];
|
||||
first_h: number;
|
||||
last_h: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CityAnalysis {
|
||||
name: string;
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp_symbol: string;
|
||||
local_time: string;
|
||||
local_date: string;
|
||||
risk: {
|
||||
level: string;
|
||||
emoji: string;
|
||||
airport: string;
|
||||
icao: string;
|
||||
distance_km: number;
|
||||
warning: string;
|
||||
};
|
||||
current: {
|
||||
temp: number | null;
|
||||
max_so_far: number | null;
|
||||
max_temp_time: string;
|
||||
wu_settlement: number | null;
|
||||
settlement_source?: string | null;
|
||||
settlement_source_label?: string | null;
|
||||
obs_time: string;
|
||||
obs_age_min: number | null;
|
||||
wind_speed_kt: number | null;
|
||||
wind_dir: number | null;
|
||||
humidity: number | null;
|
||||
cloud_desc: string;
|
||||
clouds_raw: Array<{ cover: string; base: number | null }>;
|
||||
visibility_mi: number | null;
|
||||
wx_desc: string | null;
|
||||
raw_metar?: string | null;
|
||||
report_time?: string | null;
|
||||
receipt_time?: string | null;
|
||||
obs_time_epoch?: number | null;
|
||||
};
|
||||
mgm?: {
|
||||
temp?: number | null;
|
||||
time?: string;
|
||||
today_high?: number | null;
|
||||
today_low?: number | null;
|
||||
};
|
||||
forecast: {
|
||||
today_high: number | null;
|
||||
daily: any[];
|
||||
};
|
||||
multi_model: ModelComparison;
|
||||
deb: DEBAnalysis;
|
||||
probabilities: {
|
||||
mu?: number | null;
|
||||
distribution?: any[];
|
||||
};
|
||||
hourly?: any;
|
||||
metar_recent_obs?: any[];
|
||||
trend?: TrendInfo;
|
||||
peak?: PeakInfo;
|
||||
ai_analysis: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// --- Aggregated Detail Types ---
|
||||
|
||||
export interface MarketScan {
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
primary_market: any | null;
|
||||
selected_date: string | null;
|
||||
selected_condition_id: string | null;
|
||||
selected_slug: string | null;
|
||||
temperature_bucket: any | null;
|
||||
model_probability: number | null;
|
||||
market_price: number | null;
|
||||
midpoint?: number | null;
|
||||
spread?: number | null;
|
||||
edge_percent: number | null;
|
||||
signal_label: "BUY YES" | "BUY NO" | "MONITOR";
|
||||
confidence: "low" | "medium" | "high";
|
||||
probability_engine?: string | null;
|
||||
probability_calibration_mode?: string | null;
|
||||
yes_token: MarketToken | null;
|
||||
no_token: MarketToken | null;
|
||||
yes_buy: number | null;
|
||||
yes_sell: number | null;
|
||||
yes_midpoint?: number | null;
|
||||
yes_spread?: number | null;
|
||||
no_buy: number | null;
|
||||
no_sell: number | null;
|
||||
no_midpoint?: number | null;
|
||||
no_spread?: number | null;
|
||||
last_trade_price: number | null;
|
||||
liquidity: number | null;
|
||||
volume: number | null;
|
||||
quote_source?: string | null;
|
||||
quote_age_ms?: number | null;
|
||||
sparkline: number[];
|
||||
top_buckets?: Array<{
|
||||
label?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
probability?: number | null;
|
||||
market_price?: number | null;
|
||||
yes_buy?: number | null;
|
||||
yes_sell?: number | null;
|
||||
no_buy?: number | null;
|
||||
no_sell?: number | null;
|
||||
slug?: string | null;
|
||||
question?: string | null;
|
||||
is_primary?: boolean;
|
||||
}>;
|
||||
recent_trades: Trade[];
|
||||
scan_scope?: "lite" | "full" | string | null;
|
||||
websocket: any;
|
||||
}
|
||||
|
||||
export interface CityDetail {
|
||||
city: string;
|
||||
fetched_at: string;
|
||||
overview: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icao: string;
|
||||
airport: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
local_time: string;
|
||||
local_date: string;
|
||||
temp_symbol: string;
|
||||
current_temp: number | null;
|
||||
settlement_station?: {
|
||||
provider_code?: string | null;
|
||||
settlement_source?: string | null;
|
||||
settlement_station_code?: string | null;
|
||||
settlement_station_label?: string | null;
|
||||
airport_code?: string | null;
|
||||
airport_name?: string | null;
|
||||
is_airport_anchor?: boolean;
|
||||
is_official_station_anchor?: boolean;
|
||||
};
|
||||
deb_prediction: number | null;
|
||||
risk_level: string;
|
||||
risk_warning: string;
|
||||
updated_at: string;
|
||||
};
|
||||
official: {
|
||||
available: boolean;
|
||||
metar: any;
|
||||
weather_gov: WeatherGovData;
|
||||
mgm: any;
|
||||
mgm_nearby: any[];
|
||||
nearby_source?: string;
|
||||
airport_primary?: any;
|
||||
airport_primary_today_obs?: any[];
|
||||
official_nearby?: any[];
|
||||
official_network_source?: string;
|
||||
official_network_status?: any;
|
||||
network_lead_signal?: any;
|
||||
network_spread_signal?: any;
|
||||
center_station_candidate?: any;
|
||||
airport_vs_network_delta?: number | null;
|
||||
};
|
||||
timeseries: {
|
||||
metar_recent_obs: any[];
|
||||
metar_today_obs: any[];
|
||||
settlement_today_obs?: any[];
|
||||
hourly: any;
|
||||
mgm_hourly: any[];
|
||||
forecast_daily: any[];
|
||||
};
|
||||
models: ModelComparison;
|
||||
probabilities: {
|
||||
mu: number | null;
|
||||
distribution: any[];
|
||||
};
|
||||
market_scan: MarketScan;
|
||||
risk: any;
|
||||
settlement_station?: any;
|
||||
airport_primary?: any;
|
||||
official_nearby?: any[];
|
||||
official_network_source?: string;
|
||||
official_network_status?: any;
|
||||
network_lead_signal?: any;
|
||||
network_spread_signal?: any;
|
||||
center_station_candidate?: any;
|
||||
airport_vs_network_delta?: number | null;
|
||||
ai_analysis: string;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CitySummary {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icao: string;
|
||||
local_time: string;
|
||||
temp_symbol: string;
|
||||
current: {
|
||||
temp: number | null;
|
||||
obs_time: string;
|
||||
};
|
||||
deb: {
|
||||
prediction: number | null;
|
||||
};
|
||||
risk: {
|
||||
level: string;
|
||||
warning: string;
|
||||
};
|
||||
updated_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user