Reduce dashboard orchestration coupling
ScanTerminalDashboard had accumulated terminal fetching, theme persistence, local clock updates, and AI pinned-city hydration in one component. Moving those responsibilities into focused hooks keeps the screen component as the composition layer while preserving the existing UI and data flow. Constraint: Refactor must not change the current decision-card or scan-terminal behavior. Rejected: Split visual card components in the same commit | too much surface area for one safe refactor pass. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Browser manual regression across map, calendar, and pinned-card interactions.
This commit is contained in:
@@ -31,37 +31,27 @@ import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
|||||||
import type {
|
import type {
|
||||||
CityDetail,
|
CityDetail,
|
||||||
ScanOpportunityRow,
|
ScanOpportunityRow,
|
||||||
ScanTerminalResponse,
|
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import {
|
|
||||||
buildBrowserBackendHeaders,
|
|
||||||
fetchBackendApi,
|
|
||||||
} from "@/lib/backend-api";
|
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
|
||||||
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
||||||
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
||||||
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
|
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
|
||||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||||
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
|
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
|
||||||
import {
|
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||||
findDetailForCity,
|
|
||||||
isFullEnoughForDeepAnalysis,
|
|
||||||
waitForDeepAnalysisQueue,
|
|
||||||
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
|
||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
import {
|
import {
|
||||||
findRowForCity,
|
findRowForCity,
|
||||||
formatUserLocalTime,
|
|
||||||
normalizeCityKey,
|
normalizeCityKey,
|
||||||
prettifyCityName,
|
|
||||||
rowMatchesCity,
|
rowMatchesCity,
|
||||||
sortRowsByUserTime,
|
sortRowsByUserTime,
|
||||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
|
import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace";
|
||||||
|
import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
|
||||||
|
import {
|
||||||
|
useScanTerminalTheme,
|
||||||
|
useUserLocalClock,
|
||||||
|
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
||||||
|
|
||||||
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
|
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
|
||||||
type ThemeMode = "dark" | "light";
|
|
||||||
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
|
|
||||||
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
|
|
||||||
|
|
||||||
function ScanTerminalScreen() {
|
function ScanTerminalScreen() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
@@ -71,40 +61,37 @@ function ScanTerminalScreen() {
|
|||||||
const accountHref = store.proAccess.authenticated
|
const accountHref = store.proAccess.authenticated
|
||||||
? "/account"
|
? "/account"
|
||||||
: "/auth/login?next=%2Faccount";
|
: "/auth/login?next=%2Faccount";
|
||||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
const {
|
||||||
const [scanLoading, setScanLoading] = useState(false);
|
refreshScanTerminalManually,
|
||||||
const [scanError, setScanError] = useState<string | null>(null);
|
scanError,
|
||||||
|
scanLoading,
|
||||||
|
terminalData,
|
||||||
|
} = useScanTerminalQuery({
|
||||||
|
isPro,
|
||||||
|
proAccessLoading: store.proAccess.loading,
|
||||||
|
});
|
||||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||||
const [activeView, setActiveView] = useState<ContentView>("opportunities");
|
const [activeView, setActiveView] = useState<ContentView>("opportunities");
|
||||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||||
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
|
||||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
const userLocalTime = useUserLocalClock();
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
const { setThemeMode, themeMode } = useScanTerminalTheme();
|
||||||
const lastMapSelectedCityRef = useRef<string>("");
|
const lastMapSelectedCityRef = useRef<string>("");
|
||||||
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
|
||||||
const aiHydrationQueueRef = useRef<string[]>([]);
|
|
||||||
const aiHydrationRunningRef = useRef(false);
|
|
||||||
const scanRequestSeqRef = useRef(0);
|
|
||||||
const scanLoadingRef = useRef(false);
|
|
||||||
const lastForcedScanRefreshAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const hadLight = root.classList.contains("light");
|
|
||||||
const hadDark = root.classList.contains("dark");
|
|
||||||
root.classList.toggle("light", themeMode === "light");
|
|
||||||
root.classList.toggle("dark", themeMode === "dark");
|
|
||||||
return () => {
|
|
||||||
root.classList.toggle("light", hadLight);
|
|
||||||
root.classList.toggle("dark", hadDark);
|
|
||||||
};
|
|
||||||
}, [themeMode]);
|
|
||||||
|
|
||||||
const timeSortedRows = useMemo(
|
const timeSortedRows = useMemo(
|
||||||
() => sortRowsByUserTime(terminalData?.rows || []),
|
() => sortRowsByUserTime(terminalData?.rows || []),
|
||||||
[terminalData?.rows],
|
[terminalData?.rows],
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
addAiPinnedCity,
|
||||||
|
aiPinnedCities,
|
||||||
|
refreshAiPinnedCityDetail,
|
||||||
|
removeAiPinnedCity,
|
||||||
|
} = useAiPinnedCityWorkspace({
|
||||||
|
locale,
|
||||||
|
store,
|
||||||
|
timeSortedRows,
|
||||||
|
});
|
||||||
const selectedRow = useMemo(() => {
|
const selectedRow = useMemo(() => {
|
||||||
if (!timeSortedRows.length) return null;
|
if (!timeSortedRows.length) return null;
|
||||||
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
||||||
@@ -229,130 +216,6 @@ function ScanTerminalScreen() {
|
|||||||
}
|
}
|
||||||
}, [activeView, isPro, store.proAccess.loading]);
|
}, [activeView, isPro, store.proAccess.loading]);
|
||||||
|
|
||||||
const fetchScanTerminal = useCallback(
|
|
||||||
async ({
|
|
||||||
forceRefresh = false,
|
|
||||||
showLoading = false,
|
|
||||||
}: {
|
|
||||||
forceRefresh?: boolean;
|
|
||||||
showLoading?: boolean;
|
|
||||||
} = {}) => {
|
|
||||||
if (store.proAccess.loading || !isPro) return;
|
|
||||||
const requestSeq = ++scanRequestSeqRef.current;
|
|
||||||
const controller = new AbortController();
|
|
||||||
if (forceRefresh) {
|
|
||||||
lastForcedScanRefreshAtRef.current = Date.now();
|
|
||||||
}
|
|
||||||
if (showLoading) {
|
|
||||||
scanLoadingRef.current = true;
|
|
||||||
setScanLoading(true);
|
|
||||||
}
|
|
||||||
setScanError(null);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
scan_mode: "tradable",
|
|
||||||
min_price: "0.05",
|
|
||||||
max_price: "0.95",
|
|
||||||
min_edge_pct: "2",
|
|
||||||
min_liquidity: "500",
|
|
||||||
market_type: "maxtemp",
|
|
||||||
time_range: "today",
|
|
||||||
limit: "36",
|
|
||||||
force_refresh: String(forceRefresh),
|
|
||||||
});
|
|
||||||
if (forceRefresh) {
|
|
||||||
params.set("_ts", String(Date.now()));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const headers = await buildBrowserBackendHeaders({
|
|
||||||
Accept: "application/json",
|
|
||||||
});
|
|
||||||
const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
headers,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
let message = `HTTP ${response.status}`;
|
|
||||||
try {
|
|
||||||
const payload = await response.json();
|
|
||||||
message = String(payload?.error || payload?.detail || message);
|
|
||||||
} catch {
|
|
||||||
// Keep HTTP status message.
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as ScanTerminalResponse;
|
|
||||||
if (requestSeq !== scanRequestSeqRef.current) return;
|
|
||||||
setTerminalData(payload);
|
|
||||||
setScanError(null);
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
|
|
||||||
setScanError(error instanceof Error ? error.message : String(error));
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
scanLoadingRef.current = false;
|
|
||||||
setScanLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isPro, store.proAccess.loading],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.proAccess.loading) return;
|
|
||||||
if (!isPro) {
|
|
||||||
scanLoadingRef.current = false;
|
|
||||||
setScanLoading(false);
|
|
||||||
setScanError(null);
|
|
||||||
setTerminalData(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
|
||||||
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
|
|
||||||
|
|
||||||
const refreshScanTerminalManually = useCallback(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const lastForced = lastForcedScanRefreshAtRef.current;
|
|
||||||
const withinCooldown =
|
|
||||||
lastForced > 0 &&
|
|
||||||
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
|
|
||||||
terminalData;
|
|
||||||
if (withinCooldown) {
|
|
||||||
setScanError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
|
||||||
}, [fetchScanTerminal, terminalData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.proAccess.loading || !isPro) return;
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
if (document.hidden) return;
|
|
||||||
if (scanLoadingRef.current) return;
|
|
||||||
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
|
|
||||||
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUserLocalTime(formatUserLocalTime());
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
setUserLocalTime(formatUserLocalTime());
|
|
||||||
}, 10_000);
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
|
||||||
if (stored === "light") {
|
|
||||||
setThemeMode("light");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
|
||||||
}, [themeMode]);
|
|
||||||
|
|
||||||
const resolvedView: ContentView = activeView;
|
const resolvedView: ContentView = activeView;
|
||||||
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
||||||
const activeDetailRow =
|
const activeDetailRow =
|
||||||
@@ -369,115 +232,6 @@ function ScanTerminalScreen() {
|
|||||||
}
|
}
|
||||||
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||||
|
|
||||||
const runAiHydrationQueue = useCallback(async () => {
|
|
||||||
if (aiHydrationRunningRef.current) return;
|
|
||||||
aiHydrationRunningRef.current = true;
|
|
||||||
try {
|
|
||||||
while (aiHydrationQueueRef.current.length > 0) {
|
|
||||||
const nextCity = aiHydrationQueueRef.current.shift();
|
|
||||||
const key = normalizeCityKey(nextCity || "");
|
|
||||||
if (!nextCity || !key) continue;
|
|
||||||
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
|
|
||||||
try {
|
|
||||||
const detail = await store.ensureCityDetail(
|
|
||||||
nextCity,
|
|
||||||
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
|
||||||
"full",
|
|
||||||
);
|
|
||||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
}
|
|
||||||
await waitForDeepAnalysisQueue(1200);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
aiHydrationRunningRef.current = false;
|
|
||||||
if (aiHydrationQueueRef.current.length > 0) {
|
|
||||||
void runAiHydrationQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [store.cityDetailsByName, store.ensureCityDetail]);
|
|
||||||
|
|
||||||
const queueAiFullHydration = useCallback(
|
|
||||||
(cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
||||||
aiFullHydrationRef.current.add(key);
|
|
||||||
aiHydrationQueueRef.current.push(cityName);
|
|
||||||
void runAiHydrationQueue();
|
|
||||||
},
|
|
||||||
[runAiHydrationQueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addAiPinnedCity = useCallback((cityName: string) => {
|
|
||||||
const cleanName = String(cityName || "").trim();
|
|
||||||
const key = normalizeCityKey(cleanName);
|
|
||||||
if (!key) return;
|
|
||||||
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
|
||||||
const prettyName = prettifyCityName(cleanName);
|
|
||||||
const displayName =
|
|
||||||
matchedRow?.city_display_name ||
|
|
||||||
matchedRow?.display_name ||
|
|
||||||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
|
||||||
prettyName ||
|
|
||||||
cleanName;
|
|
||||||
setAiPinnedCities((current) => {
|
|
||||||
const existing = current.findIndex(
|
|
||||||
(item) => normalizeCityKey(item.cityName) === key,
|
|
||||||
);
|
|
||||||
const nextItem = {
|
|
||||||
cityName: matchedRow?.city || cleanName,
|
|
||||||
displayName,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
};
|
|
||||||
if (existing >= 0) {
|
|
||||||
const next = [...current];
|
|
||||||
next[existing] = { ...next[existing], ...nextItem };
|
|
||||||
return [
|
|
||||||
next[existing],
|
|
||||||
...next.filter((_, index) => index !== existing),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [nextItem, ...current].slice(0, 8);
|
|
||||||
});
|
|
||||||
queueAiFullHydration(matchedRow?.city || cleanName);
|
|
||||||
}, [locale, queueAiFullHydration, timeSortedRows]);
|
|
||||||
|
|
||||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
|
||||||
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
|
||||||
);
|
|
||||||
setAiPinnedCities((current) =>
|
|
||||||
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshAiPinnedCityDetail = useCallback(
|
|
||||||
async (cityName: string) => {
|
|
||||||
const key = normalizeCityKey(cityName);
|
|
||||||
if (key) {
|
|
||||||
aiFullHydrationRef.current.delete(key);
|
|
||||||
}
|
|
||||||
await store.ensureCityDetail(cityName, true, "full");
|
|
||||||
},
|
|
||||||
[store.ensureCityDetail],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aiPinnedCities.forEach((item) => {
|
|
||||||
const key = normalizeCityKey(item.cityName);
|
|
||||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
||||||
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
|
||||||
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
|
||||||
if (!needsFullHydration) return;
|
|
||||||
queueAiFullHydration(item.cityName);
|
|
||||||
});
|
|
||||||
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
|
||||||
|
|
||||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||||
setMapSelectedCityName(cityName);
|
setMapSelectedCityName(cityName);
|
||||||
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
|
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import {
|
||||||
|
findDetailForCity,
|
||||||
|
isFullEnoughForDeepAnalysis,
|
||||||
|
waitForDeepAnalysisQueue,
|
||||||
|
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||||
|
import {
|
||||||
|
findRowForCity,
|
||||||
|
normalizeCityKey,
|
||||||
|
prettifyCityName,
|
||||||
|
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
|
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||||
|
|
||||||
|
type DashboardStore = ReturnType<typeof useDashboardStore>;
|
||||||
|
|
||||||
|
export function useAiPinnedCityWorkspace({
|
||||||
|
locale,
|
||||||
|
store,
|
||||||
|
timeSortedRows,
|
||||||
|
}: {
|
||||||
|
locale: string;
|
||||||
|
store: DashboardStore;
|
||||||
|
timeSortedRows: ScanOpportunityRow[];
|
||||||
|
}) {
|
||||||
|
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
||||||
|
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
||||||
|
const aiHydrationQueueRef = useRef<string[]>([]);
|
||||||
|
const aiHydrationRunningRef = useRef(false);
|
||||||
|
|
||||||
|
const runAiHydrationQueue = useCallback(async () => {
|
||||||
|
if (aiHydrationRunningRef.current) return;
|
||||||
|
aiHydrationRunningRef.current = true;
|
||||||
|
try {
|
||||||
|
while (aiHydrationQueueRef.current.length > 0) {
|
||||||
|
const nextCity = aiHydrationQueueRef.current.shift();
|
||||||
|
const key = normalizeCityKey(nextCity || "");
|
||||||
|
if (!nextCity || !key) continue;
|
||||||
|
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
|
||||||
|
try {
|
||||||
|
const detail = await store.ensureCityDetail(
|
||||||
|
nextCity,
|
||||||
|
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
}
|
||||||
|
await waitForDeepAnalysisQueue(1200);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
aiHydrationRunningRef.current = false;
|
||||||
|
if (aiHydrationQueueRef.current.length > 0) {
|
||||||
|
void runAiHydrationQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [store.cityDetailsByName, store.ensureCityDetail]);
|
||||||
|
|
||||||
|
const queueAiFullHydration = useCallback(
|
||||||
|
(cityName: string) => {
|
||||||
|
const key = normalizeCityKey(cityName);
|
||||||
|
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||||
|
aiFullHydrationRef.current.add(key);
|
||||||
|
aiHydrationQueueRef.current.push(cityName);
|
||||||
|
void runAiHydrationQueue();
|
||||||
|
},
|
||||||
|
[runAiHydrationQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAiPinnedCity = useCallback((cityName: string) => {
|
||||||
|
const cleanName = String(cityName || "").trim();
|
||||||
|
const key = normalizeCityKey(cleanName);
|
||||||
|
if (!key) return;
|
||||||
|
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
||||||
|
const prettyName = prettifyCityName(cleanName);
|
||||||
|
const displayName =
|
||||||
|
matchedRow?.city_display_name ||
|
||||||
|
matchedRow?.display_name ||
|
||||||
|
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
||||||
|
prettyName ||
|
||||||
|
cleanName;
|
||||||
|
setAiPinnedCities((current) => {
|
||||||
|
const existing = current.findIndex(
|
||||||
|
(item) => normalizeCityKey(item.cityName) === key,
|
||||||
|
);
|
||||||
|
const nextItem = {
|
||||||
|
cityName: matchedRow?.city || cleanName,
|
||||||
|
displayName,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (existing >= 0) {
|
||||||
|
const next = [...current];
|
||||||
|
next[existing] = { ...next[existing], ...nextItem };
|
||||||
|
return [
|
||||||
|
next[existing],
|
||||||
|
...next.filter((_, index) => index !== existing),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [nextItem, ...current].slice(0, 8);
|
||||||
|
});
|
||||||
|
queueAiFullHydration(matchedRow?.city || cleanName);
|
||||||
|
}, [locale, queueAiFullHydration, timeSortedRows]);
|
||||||
|
|
||||||
|
const removeAiPinnedCity = useCallback((cityName: string) => {
|
||||||
|
const key = normalizeCityKey(cityName);
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
||||||
|
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
||||||
|
);
|
||||||
|
setAiPinnedCities((current) =>
|
||||||
|
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAiPinnedCityDetail = useCallback(
|
||||||
|
async (cityName: string) => {
|
||||||
|
const key = normalizeCityKey(cityName);
|
||||||
|
if (key) {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
}
|
||||||
|
await store.ensureCityDetail(cityName, true, "full");
|
||||||
|
},
|
||||||
|
[store.ensureCityDetail],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aiPinnedCities.forEach((item) => {
|
||||||
|
const key = normalizeCityKey(item.cityName);
|
||||||
|
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||||
|
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
||||||
|
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
||||||
|
if (!needsFullHydration) return;
|
||||||
|
queueAiFullHydration(item.cityName);
|
||||||
|
});
|
||||||
|
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addAiPinnedCity,
|
||||||
|
aiPinnedCities,
|
||||||
|
refreshAiPinnedCityDetail,
|
||||||
|
removeAiPinnedCity,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildBrowserBackendHeaders,
|
||||||
|
fetchBackendApi,
|
||||||
|
} from "@/lib/backend-api";
|
||||||
|
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
|
||||||
|
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
|
||||||
|
|
||||||
|
export function useScanTerminalQuery({
|
||||||
|
isPro,
|
||||||
|
proAccessLoading,
|
||||||
|
}: {
|
||||||
|
isPro: boolean;
|
||||||
|
proAccessLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||||
|
const [scanLoading, setScanLoading] = useState(false);
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
const scanRequestSeqRef = useRef(0);
|
||||||
|
const scanLoadingRef = useRef(false);
|
||||||
|
const lastForcedScanRefreshAtRef = useRef(0);
|
||||||
|
|
||||||
|
const fetchScanTerminal = useCallback(
|
||||||
|
async ({
|
||||||
|
forceRefresh = false,
|
||||||
|
showLoading = false,
|
||||||
|
}: {
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
showLoading?: boolean;
|
||||||
|
} = {}) => {
|
||||||
|
if (proAccessLoading || !isPro) return;
|
||||||
|
const requestSeq = ++scanRequestSeqRef.current;
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (forceRefresh) {
|
||||||
|
lastForcedScanRefreshAtRef.current = Date.now();
|
||||||
|
}
|
||||||
|
if (showLoading) {
|
||||||
|
scanLoadingRef.current = true;
|
||||||
|
setScanLoading(true);
|
||||||
|
}
|
||||||
|
setScanError(null);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
scan_mode: "tradable",
|
||||||
|
min_price: "0.05",
|
||||||
|
max_price: "0.95",
|
||||||
|
min_edge_pct: "2",
|
||||||
|
min_liquidity: "500",
|
||||||
|
market_type: "maxtemp",
|
||||||
|
time_range: "today",
|
||||||
|
limit: "36",
|
||||||
|
force_refresh: String(forceRefresh),
|
||||||
|
});
|
||||||
|
if (forceRefresh) {
|
||||||
|
params.set("_ts", String(Date.now()));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const headers = await buildBrowserBackendHeaders({
|
||||||
|
Accept: "application/json",
|
||||||
|
});
|
||||||
|
const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
message = String(payload?.error || payload?.detail || message);
|
||||||
|
} catch {
|
||||||
|
// Keep HTTP status message.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as ScanTerminalResponse;
|
||||||
|
if (requestSeq !== scanRequestSeqRef.current) return;
|
||||||
|
setTerminalData(payload);
|
||||||
|
setScanError(null);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
|
||||||
|
setScanError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
scanLoadingRef.current = false;
|
||||||
|
setScanLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPro, proAccessLoading],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (proAccessLoading) return;
|
||||||
|
if (!isPro) {
|
||||||
|
scanLoadingRef.current = false;
|
||||||
|
setScanLoading(false);
|
||||||
|
setScanError(null);
|
||||||
|
setTerminalData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
||||||
|
}, [fetchScanTerminal, isPro, proAccessLoading]);
|
||||||
|
|
||||||
|
const refreshScanTerminalManually = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastForced = lastForcedScanRefreshAtRef.current;
|
||||||
|
const withinCooldown =
|
||||||
|
lastForced > 0 &&
|
||||||
|
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
|
||||||
|
terminalData;
|
||||||
|
if (withinCooldown) {
|
||||||
|
setScanError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
||||||
|
}, [fetchScanTerminal, terminalData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (proAccessLoading || !isPro) return;
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
if (scanLoadingRef.current) return;
|
||||||
|
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
|
||||||
|
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [fetchScanTerminal, isPro, proAccessLoading]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshScanTerminalManually,
|
||||||
|
scanError,
|
||||||
|
scanLoading,
|
||||||
|
terminalData,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { formatUserLocalTime } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
|
|
||||||
|
export type ThemeMode = "dark" | "light";
|
||||||
|
|
||||||
|
export function useUserLocalClock() {
|
||||||
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserLocalTime(formatUserLocalTime());
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setUserLocalTime(formatUserLocalTime());
|
||||||
|
}, 10_000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return userLocalTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScanTerminalTheme() {
|
||||||
|
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const hadLight = root.classList.contains("light");
|
||||||
|
const hadDark = root.classList.contains("dark");
|
||||||
|
root.classList.toggle("light", themeMode === "light");
|
||||||
|
root.classList.toggle("dark", themeMode === "dark");
|
||||||
|
return () => {
|
||||||
|
root.classList.toggle("light", hadLight);
|
||||||
|
root.classList.toggle("dark", hadDark);
|
||||||
|
};
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
||||||
|
if (stored === "light") {
|
||||||
|
setThemeMode("light");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
return { setThemeMode, themeMode };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user