Files
PolyWeather/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts
T
2026-07-02 20:25:40 +08:00

444 lines
14 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
scanTerminalClient,
shouldSkipManualTerminalRefresh,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import { REGIONS } from "@/components/dashboard/scan-terminal/continent-grouping";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import {
getLatestPatchesSnapshot,
useSsePatchVersion,
type CityPatch,
} from "@/hooks/use-sse-patches";
import type {
ScanOpportunityRow,
ScanTerminalResponse,
} from "@/lib/dashboard-types";
const SCAN_CACHE_PREFIX = "polyweather_scan_v3";
const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows;
const FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS = 60_000;
const MAX_STALE_SCAN_CACHE_MS = 6 * 60 * 60 * 1000;
const DERIVED_SCAN_PATCH_NUMBER_FIELDS = [
"signed_gap",
"gap_to_target",
"touch_distance",
"current_reference",
"edge",
"edge_percent",
"deb_prediction",
] as const;
function scanCacheKey(tradingRegion: string, cacheScope: string): string {
return `${SCAN_CACHE_PREFIX}:${cacheScope || "terminal"}:${tradingRegion || "all"}`;
}
export function readScanCache(
tradingRegion: string,
cacheScope: string,
options?: { allowStale?: boolean },
): ScanTerminalResponse | null {
try {
const raw = localStorage.getItem(scanCacheKey(tradingRegion, cacheScope));
if (!raw) return null;
const cached = JSON.parse(raw);
const age = Date.now() - Number(cached.ts || 0);
const maxAge = options?.allowStale ? MAX_STALE_SCAN_CACHE_MS : SCAN_CACHE_TTL_MS;
if (
cached.ts &&
age >= 0 &&
age < maxAge &&
Array.isArray(cached.data?.rows) &&
cached.data.rows.length > 0
) {
return cached.data;
}
} catch { /* ignore */ }
return null;
}
export function writeScanCache(data: ScanTerminalResponse, tradingRegion: string, cacheScope: string) {
try {
if (!Array.isArray(data.rows) || data.rows.length <= 0) {
localStorage.removeItem(scanCacheKey(tradingRegion, cacheScope));
return;
}
localStorage.setItem(scanCacheKey(tradingRegion, cacheScope), JSON.stringify({ ts: Date.now(), data }));
} catch { /* ignore */ }
}
function normalizeCityKey(city: string | null | undefined) {
return String(city || "").trim().toLowerCase();
}
function finiteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function firstFiniteNumber(...values: unknown[]) {
for (const value of values) {
const number = finiteNumber(value);
if (number !== null) return number;
}
return null;
}
function isBelowTargetRow(row: any) {
const text = String(
row?.market_direction ||
row?.temperature_direction ||
row?.market_question ||
row?.target_label ||
row?.action ||
"",
).toLowerCase();
return /\b(below|under|lte|less)\b|<=|≤|以下/.test(text);
}
function deriveTargetGapFields(row: any, maxSoFar: number | null) {
const target = firstFiniteNumber(
row?.target_threshold,
row?.target_value,
row?.target_lower,
row?.target_upper,
);
if (target === null || maxSoFar === null) return null;
const belowTargetRow = isBelowTargetRow(row);
const signedGap = belowTargetRow ? target - maxSoFar : maxSoFar - target;
const touchDistance = belowTargetRow
? Math.max(0, maxSoFar - target)
: Math.max(0, target - maxSoFar);
return {
signed_gap: Number(signedGap.toFixed(2)),
gap_to_target: Number((-signedGap).toFixed(2)),
touch_distance: Number(touchDistance.toFixed(2)),
};
}
function applyPatchToScanRow(row: any, patch: CityPatch) {
const temp = finiteNumber(patch.changes.temp);
const patchMaxSoFar = finiteNumber(patch.changes.max_so_far);
if (temp === null) return row;
const currentMaxSoFar = firstFiniteNumber(row.current_max_so_far, temp) ?? temp;
const maxSoFar = patchMaxSoFar ?? Math.max(temp, currentMaxSoFar);
const derivedGapFields = deriveTargetGapFields(row, maxSoFar);
const nextRow: any = {
...row,
current_temp: temp,
current_max_so_far: maxSoFar,
local_time: typeof patch.changes.obs_time === "string" ? patch.changes.obs_time : row.local_time,
sse_revision: patch.revision,
};
for (const key of DERIVED_SCAN_PATCH_NUMBER_FIELDS) {
const value = finiteNumber(patch.changes[key]);
if (value !== null) {
nextRow[key] = value;
}
}
if (derivedGapFields) {
for (const key of ["signed_gap", "gap_to_target", "touch_distance"] as const) {
if (finiteNumber(patch.changes[key]) === null) {
nextRow[key] = derivedGapFields[key];
}
}
}
return {
...nextRow,
};
}
function applyTerminalPatches(
data: ScanTerminalResponse | null | undefined,
patches: Map<string, CityPatch>,
): ScanTerminalResponse | null | undefined {
if (!data?.rows?.length || !patches.size) return data;
let changed = false;
const rows = data.rows.map((row: any) => {
const patch = patches.get(normalizeCityKey(row.city));
if (!patch) return row;
changed = true;
return applyPatchToScanRow(row, patch);
});
return changed ? { ...data, rows } : data;
}
function scanRowId(row: ScanOpportunityRow | null | undefined) {
const id = row?.id;
return typeof id === "string" && id ? id : null;
}
function sortScanRowsByRank(rows: ScanOpportunityRow[]) {
return [...rows].sort((a, b) => {
const rankA = finiteNumber(a.rank);
const rankB = finiteNumber(b.rank);
if (rankA !== null && rankB !== null && rankA !== rankB) {
return rankA - rankB;
}
if (rankA !== null && rankB === null) return -1;
if (rankA === null && rankB !== null) return 1;
return 0;
});
}
function mergeScanTerminalIncrementalResponse(
previous: ScanTerminalResponse | null | undefined,
next: ScanTerminalResponse,
): ScanTerminalResponse {
const diff = next.diff;
if (!diff || diff.mode === "full") return next;
if (!previous?.snapshot_id) return next;
const baseSnapshotId = diff.base_snapshot_id || next.snapshot_id;
if (previous.snapshot_id !== baseSnapshotId) return next;
if (diff.mode === "not_modified") {
return {
...previous,
...next,
status: "ready",
stale: false,
rows: previous.rows,
};
}
if (diff.mode !== "row_delta") return next;
const removedIds = new Set((diff.removed_row_ids || []).map((id) => String(id)));
const changedRows = new Map<string, ScanOpportunityRow>();
for (const row of diff.rows_changed || []) {
const id = scanRowId(row);
if (id) changedRows.set(id, row);
}
const seenIds = new Set<string>();
const mergedRows: ScanOpportunityRow[] = [];
for (const row of previous.rows || []) {
const id = scanRowId(row);
if (!id || removedIds.has(id)) continue;
const replacement = changedRows.get(id);
mergedRows.push(replacement || row);
seenIds.add(id);
}
for (const [id, row] of changedRows.entries()) {
if (!seenIds.has(id) && !removedIds.has(id)) {
mergedRows.push(row);
}
}
return {
...previous,
...next,
rows: sortScanRowsByRank(mergedRows),
};
}
export function useScanTerminalQuery({
cacheScope = "terminal",
isPro,
modelSummary = false,
proAccessLoading,
terminalActivationRefreshKey = 0,
timezoneOffsetSeconds,
tradingRegion,
}: {
cacheScope?: string;
isPro: boolean;
modelSummary?: boolean;
proAccessLoading: boolean;
terminalActivationRefreshKey?: number;
timezoneOffsetSeconds?: number | null;
tradingRegion?: string;
}) {
const {
data: terminalData,
error: scanError,
loading: scanLoading,
remote: scanRemote,
reset,
run,
} = useRemoteDataQuery<ScanTerminalResponse>();
const lastForcedScanRefreshAtRef = useRef(0);
const lastForegroundScanRefreshAtRef = useRef(0);
const lastScanSuccessAtRef = useRef(0);
const patchVersion = useSsePatchVersion();
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
if (typeof window !== "undefined") {
return readScanCache(tradingRegion || "", cacheScope, { allowStale: true });
}
return null;
});
const latestScanDataRef = useRef<ScanTerminalResponse | null>(null);
useEffect(() => {
latestScanDataRef.current = terminalData || cachedRows;
}, [terminalData, cachedRows]);
useEffect(() => {
if (typeof window === "undefined") return;
setCachedRows(readScanCache(tradingRegion || "", cacheScope, { allowStale: true }));
}, [cacheScope, tradingRegion]);
const fetchScanTerminal = useCallback(
async ({
forceRefresh = false,
showLoading = false,
}: {
forceRefresh?: boolean;
showLoading?: boolean;
} = {}) => {
if (proAccessLoading || !isPro) return;
if (typeof fetch !== "function" || typeof AbortController === "undefined") {
return;
}
if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now();
}
await run({
request: async (signal) => {
const previous = latestScanDataRef.current;
const next = await scanTerminalClient.getTerminal({
forceRefresh,
modelSummary,
signal,
sinceSnapshotId: !forceRefresh ? previous?.snapshot_id : null,
timezoneOffsetSeconds,
tradingRegion,
});
return mergeScanTerminalIncrementalResponse(previous, next);
},
showLoading,
onSuccess: (data) => {
lastScanSuccessAtRef.current = Date.now();
writeScanCache(data, tradingRegion || "", cacheScope);
setCachedRows(data);
},
});
},
[cacheScope, isPro, modelSummary, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion],
);
useEffect(() => {
if (proAccessLoading) return;
if (!isPro) {
reset();
return;
}
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
}, [fetchScanTerminal, isPro, proAccessLoading, reset, timezoneOffsetSeconds, tradingRegion]);
const effectiveData = useMemo(
() => applyTerminalPatches(terminalData || cachedRows, getLatestPatchesSnapshot()),
[terminalData, cachedRows, patchVersion],
);
const refreshScanTerminalManually = useCallback(() => {
if (
shouldSkipManualTerminalRefresh({
hasCurrentData: Boolean(terminalData),
lastForcedRefreshAt: lastForcedScanRefreshAtRef.current,
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
}, [fetchScanTerminal, terminalData]);
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") return;
if (proAccessLoading || !isPro) return;
const handleForegroundScanRefresh = () => {
if (document.visibilityState !== "visible") return;
if (scanRemote.status === "loading") return;
const now = Date.now();
if (now - lastForegroundScanRefreshAtRef.current < 30_000) return;
if (
lastScanSuccessAtRef.current > 0 &&
now - lastScanSuccessAtRef.current < FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS
) {
return;
}
lastForegroundScanRefreshAtRef.current = now;
void fetchScanTerminal({ forceRefresh: false, showLoading: false });
};
document.addEventListener("visibilitychange", handleForegroundScanRefresh);
window.addEventListener("focus", handleForegroundScanRefresh);
return () => {
document.removeEventListener("visibilitychange", handleForegroundScanRefresh);
window.removeEventListener("focus", handleForegroundScanRefresh);
};
}, [fetchScanTerminal, isPro, proAccessLoading, scanRemote.status]);
useEffect(() => {
if (!terminalActivationRefreshKey) return;
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
if (proAccessLoading || !isPro || scanRemote.status === "loading") return;
const handleTerminalActivationRefresh = () => {
const now = Date.now();
if (now - lastForegroundScanRefreshAtRef.current < 10_000) return;
lastForegroundScanRefreshAtRef.current = now;
void fetchScanTerminal({ forceRefresh: false, showLoading: false });
};
handleTerminalActivationRefresh();
}, [
fetchScanTerminal,
isPro,
proAccessLoading,
scanRemote.status,
terminalActivationRefreshKey,
]);
// Preload adjacent regions in idle time for instant tab switches
useEffect(() => {
if (typeof window === "undefined" || !tradingRegion || !isPro) return;
const sorted = [...REGIONS].sort((a, b) => a.sort - b.sort);
const idx = sorted.findIndex((r) => r.key === tradingRegion);
if (idx < 0) return;
const neighbors = [idx - 1, idx + 1]
.filter((i) => i >= 0 && i < sorted.length)
.map((i) => sorted[i].key);
if (!neighbors.length) return;
const preloadOne = (region: string) => {
if (readScanCache(region, cacheScope)) return; // already cached
const idleFn = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 5000));
const handle = idleFn(() => {
void scanTerminalClient.getTerminal({
forceRefresh: false,
signal: new AbortController().signal,
tradingRegion: region,
}).then((data) => { writeScanCache(data, region, cacheScope); });
});
return handle;
};
const handles = neighbors.map(preloadOne).filter(Boolean);
return () => {
const cancelFn = (window as any).cancelIdleCallback || clearTimeout;
handles.forEach((h) => { try { cancelFn(h); } catch { /* */ } });
};
}, [cacheScope, tradingRegion, isPro]);
return {
refreshScanTerminalManually,
scanError,
scanLoading,
scanRemote,
terminalData: effectiveData,
};
}
export const __applyPatchToScanRowForTest = applyPatchToScanRow;
export const __mergeScanTerminalIncrementalResponseForTest =
mergeScanTerminalIncrementalResponse;