深度清理 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:
2569718930@qq.com
2026-05-25 19:50:28 +08:00
parent a3c4fd522b
commit 2d9e99760f
14 changed files with 0 additions and 2081 deletions
@@ -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 };
}