Files
PolyWeather/frontend/hooks/useDashboardStore.tsx
T
2569718930@qq.com 2b2784d811 数据链路 P2 修复:stale-while-revalidate + 扫描数据复用
P2-7 stale-while-revalidate:
- ensureCityDetail 过期缓存不再阻塞等待刷新
- 立即返回缓存数据,后台异步更新
- 用户打开已有缓存的城市时不再看到 loading spinner

P2-8 扫描终端数据复用:
- 新增 store.preloadCityFromRow():从 ScanOpportunityRow 预填充 cityDetails 缓存
- handleSelectRow / handleMapCitySelect / handleOpenDecisionRow 均调用预加载
- 用户从地图/列表/决策卡选城市后,详情面板立即显示缓存数据
- 后台自动拉取完整 detail(stale-while-revalidate)

Tested: npx tsc --noEmit
2026-05-10 17:00:12 +08:00

1610 lines
48 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
dashboardClient,
getCityRevision,
toCitySummary,
} from "@/lib/dashboard-client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import {
getLocalDevProAccessState,
isBrowserLocalFullAccess,
} from "@/lib/local-dev-access";
import {
CityDetail,
CityListItem,
CitySummary,
DashboardState,
ForecastModalMode,
HistoryPoint,
HistoryPayload,
HistoryPayloadMeta,
HistoryState,
LoadingState,
ProAccessState,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
clearCityFocus: () => void;
closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (
cityName: string,
force?: boolean,
depth?: "panel" | "market" | "nearby" | "full",
) => Promise<CityDetail>;
ensureCityMarketScan: (
cityName: string,
force?: boolean,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => Promise<CityDetail["market_scan"] | null>;
focusCity: (cityName: string) => Promise<void>;
forecastModalMode: ForecastModalMode | null;
futureModalDate: string | null;
loadCities: () => Promise<void>;
preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshProAccess: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise<void>;
setMapInteractionActive: (active: boolean) => void;
setForecastDate: (dateStr: string | null) => void;
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
const CityDetailsContext = createContext<{
cityDetailsByName: Record<string, CityDetail>;
cityDetailMetaByName: Record<string, { cachedAt: number; revision: string }>;
citySummariesByName: Record<string, CitySummary>;
loadingState: LoadingState;
} | null>(null);
function getInitialLoadingState(): LoadingState {
return {
cities: false,
cityDetail: false,
futureDeep: false,
history: false,
historyRecords: false,
refresh: false,
marketScan: false,
};
}
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
metaByCity: {},
recordsLoading: false,
};
}
function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) {
return getLocalDevProAccessState();
}
const cached = readStoredProAccess();
if (cached) {
return cached;
}
return {
loading: true,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0,
error: null,
};
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const PRO_ACCESS_STORAGE_KEY = "polyWeather_pro_access_v1";
const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600];
const PRO_ACCESS_FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
type StoredProAccessState = ProAccessState & {
cachedAt: number;
expiresAtMs: number;
version: 1;
};
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function getSubscriptionExpiryMs(access: Pick<
ProAccessState,
"subscriptionExpiresAt" | "subscriptionTotalExpiresAt"
>) {
const raw =
access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt || "";
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
function clearStoredProAccess() {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(PRO_ACCESS_STORAGE_KEY);
} catch {
// Ignore storage failures; backend auth remains the source of truth.
}
}
function readStoredProAccess(): ProAccessState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(PRO_ACCESS_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredProAccessState>;
if (parsed.version !== 1) {
clearStoredProAccess();
return null;
}
if (!parsed.authenticated || !parsed.subscriptionActive || !parsed.userId) {
clearStoredProAccess();
return null;
}
const expiresAtMs = Number(parsed.expiresAtMs || 0);
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
clearStoredProAccess();
return null;
}
return {
loading: false,
authenticated: true,
userId: String(parsed.userId),
subscriptionActive: true,
subscriptionPlanCode: parsed.subscriptionPlanCode ?? null,
subscriptionExpiresAt: parsed.subscriptionExpiresAt ?? null,
subscriptionTotalExpiresAt: parsed.subscriptionTotalExpiresAt ?? null,
subscriptionQueuedDays: Math.max(
0,
Number(parsed.subscriptionQueuedDays ?? 0),
),
points: Number(parsed.points ?? 0),
error: null,
};
} catch {
clearStoredProAccess();
return null;
}
}
function writeStoredProAccess(access: ProAccessState) {
if (typeof window === "undefined") return;
if (!access.authenticated || !access.subscriptionActive || !access.userId) {
clearStoredProAccess();
return;
}
const explicitExpiryMs = getSubscriptionExpiryMs(access);
const expiresAtMs =
explicitExpiryMs > Date.now()
? explicitExpiryMs
: Date.now() + PRO_ACCESS_FALLBACK_TTL_MS;
const payload: StoredProAccessState = {
...access,
loading: false,
error: null,
cachedAt: Date.now(),
expiresAtMs,
version: 1,
};
try {
window.localStorage.setItem(PRO_ACCESS_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Storage can be unavailable in private mode; keep the in-memory state.
}
}
function mergeWithStoredProAccess(
next: ProAccessState,
reason: string,
): ProAccessState {
if (next.subscriptionActive || !next.authenticated) return next;
const cached = readStoredProAccess();
if (!cached) return next;
if (next.userId && cached.userId !== next.userId) return next;
const payloadExpiryMs = getSubscriptionExpiryMs(next);
if (payloadExpiryMs > 0 && payloadExpiryMs <= Date.now()) return next;
return {
...cached,
loading: false,
points: Math.max(cached.points, next.points),
error: reason,
};
}
async function buildAuthMeHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
Accept: "application/json",
};
if (!hasSupabasePublicEnv()) {
return headers;
}
try {
const {
data: { session: cachedSession },
} = await getSupabaseBrowserClient().auth.getSession();
let accessToken = String(cachedSession?.access_token || "").trim();
const expiresAtSec = Number(cachedSession?.expires_at || 0);
const nowSec = Math.floor(Date.now() / 1000);
if (!accessToken || (Number.isFinite(expiresAtSec) && expiresAtSec <= nowSec + 60)) {
const {
data: { session: refreshedSession },
} = await getSupabaseBrowserClient().auth.refreshSession();
accessToken = String(refreshedSession?.access_token || accessToken || "").trim();
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
} catch {
// The same-origin route can still fall back to cookie-backed auth.
}
return headers;
}
function countAvailableModels(
detail?: CityDetail | null,
targetDate?: string | null,
): number {
if (!detail) return 0;
const date = String(targetDate || detail.local_date || "").trim();
const dailyModels = detail.multi_model_daily?.[date]?.models;
const models = dailyModels && typeof dailyModels === "object"
? dailyModels
: detail.multi_model || {};
return Object.values(models).filter((value) =>
Number.isFinite(Number(value)),
).length;
}
function countForecastDays(detail?: CityDetail | null): number {
const daily = detail?.forecast?.daily;
if (!Array.isArray(daily)) return 0;
return new Set(
daily
.map((day) => String(day?.date || "").trim())
.filter(Boolean),
).size;
}
function normalizeCityLookupKey(value?: string | null): string {
return String(value || "").trim().toLowerCase();
}
function findCachedCityDetail(
detailsByName: Record<string, CityDetail>,
cityName?: string | null,
) {
const key = normalizeCityLookupKey(cityName);
if (!key) return null;
return (
detailsByName[cityName || ""] ||
Object.entries(detailsByName).find(([storedName, detail]) =>
[storedName, detail?.name, detail?.display_name].some(
(value) => normalizeCityLookupKey(value) === key,
),
)?.[1] ||
null
);
}
function hasSparseModelCoverage(
detail?: CityDetail | null,
targetDate?: string | null,
): boolean {
return countAvailableModels(detail, targetDate) <= 1;
}
function hasSparseDetailCoverage(
detail?: CityDetail | null,
targetDate?: string | null,
): boolean {
if (!detail) return true;
return (
hasSparseModelCoverage(detail, targetDate) || countForecastDays(detail) <= 1
);
}
function hasMarketDetailCoverage(
detail?: CityDetail | null,
targetDate?: string | null,
): boolean {
if (!detail) return false;
return countAvailableModels(detail, targetDate) > 1;
}
function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth {
if (detail?.detail_depth === "market") return "market";
if (detail?.detail_depth === "nearby") return "nearby";
if (detail?.detail_depth === "panel") return "panel";
return "full";
}
function detailSatisfiesDepth(
detail: CityDetail | null | undefined,
depth: CityDetailDepth,
targetDate?: string | null,
) {
if (!detail) return false;
if (depth === "panel") return true;
if (depth === "market") {
const normalized = normalizeDetailDepth(detail);
return (
normalized === "market" ||
normalized === "full" ||
hasMarketDetailCoverage(detail, targetDate)
);
}
if (depth === "nearby") {
const normalized = normalizeDetailDepth(detail);
return normalized === "nearby" || normalized === "full";
}
return normalizeDetailDepth(detail) === "full";
}
function hasMeaningfulModelMap(
value: Record<string, number | null> | undefined,
): value is Record<string, number | null> {
return Boolean(
value &&
Object.values(value).some((entry) => Number.isFinite(Number(entry))),
);
}
function hasMeaningfulDailyModelMap(
value: CityDetail["multi_model_daily"] | undefined,
) {
return Boolean(
value &&
Object.values(value).some((day) =>
hasMeaningfulModelMap(day?.models || undefined),
),
);
}
function countModelMapEntries(value: Record<string, number | null> | undefined) {
if (!value || typeof value !== "object") return 0;
return Object.values(value).filter((entry) => Number.isFinite(Number(entry))).length;
}
function pickRicherModelMap(
currentValue: CityDetail["multi_model"] | undefined,
incomingValue: CityDetail["multi_model"] | undefined,
) {
return countModelMapEntries(incomingValue) >= countModelMapEntries(currentValue)
? incomingValue || currentValue
: currentValue;
}
function mergeDailyModelMap(
currentValue: CityDetail["multi_model_daily"] | undefined,
incomingValue: CityDetail["multi_model_daily"] | undefined,
) {
if (!hasMeaningfulDailyModelMap(incomingValue)) return currentValue;
if (!hasMeaningfulDailyModelMap(currentValue)) return incomingValue;
const merged = { ...(currentValue || {}) };
Object.entries(incomingValue || {}).forEach(([date, incomingDay]) => {
const currentDay = merged[date];
const incomingCount = countModelMapEntries(incomingDay?.models || undefined);
const currentCount = countModelMapEntries(currentDay?.models || undefined);
if (incomingCount >= currentCount) {
merged[date] = {
...(currentDay || {}),
...(incomingDay || {}),
models: incomingDay?.models || currentDay?.models,
};
}
});
return merged;
}
function pickRicherForecast(
currentValue: CityDetail["forecast"] | undefined,
incomingValue: CityDetail["forecast"] | undefined,
) {
const picked = countForecastDays({ forecast: incomingValue } as CityDetail) >=
countForecastDays({ forecast: currentValue } as CityDetail)
? incomingValue || currentValue
: currentValue;
if (!picked?.daily || !Array.isArray(picked.daily)) return picked;
const seen = new Set<string>();
return {
...picked,
daily: picked.daily.filter((day) => {
const date = String(day?.date || "").trim();
if (!date || seen.has(date)) return false;
seen.add(date);
return true;
}),
};
}
function pickPreferredNearbyStations(
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
) {
const currentList = Array.isArray(currentValue) ? currentValue : [];
const incomingList = Array.isArray(incomingValue) ? incomingValue : [];
if (incomingList.length > 0) {
return incomingList;
}
return currentList;
}
function mergeCityDetail(
current: CityDetail | undefined,
incoming: CityDetail,
): CityDetail {
if (!current) return incoming;
const currentDepth = normalizeDetailDepth(current);
const incomingDepth = normalizeDetailDepth(incoming);
const mergedDepth =
currentDepth === "full" || incomingDepth === "full"
? "full"
: currentDepth === "nearby" || incomingDepth === "nearby"
? "nearby"
: currentDepth === "market" || incomingDepth === "market"
? "market"
: "panel";
return {
...current,
...incoming,
detail_depth: mergedDepth,
current: incoming.current || current.current,
airport_current: incoming.airport_current || current.airport_current,
deb: incoming.deb || current.deb,
probabilities: incoming.probabilities || current.probabilities,
trend: incoming.trend || current.trend,
multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model),
multi_model_daily: mergeDailyModelMap(
current.multi_model_daily,
incoming.multi_model_daily,
),
forecast: pickRicherForecast(current.forecast, incoming.forecast),
official_nearby: pickPreferredNearbyStations(
current.official_nearby,
incoming.official_nearby,
),
mgm_nearby: pickPreferredNearbyStations(
current.mgm_nearby,
incoming.mgm_nearby,
),
network_lead_signal:
current.network_lead_signal || incoming.network_lead_signal,
airport_vs_network_delta:
current.airport_vs_network_delta ?? incoming.airport_vs_network_delta,
};
}
function mergeMarketScan(
current: CityDetail["market_scan"] | undefined,
incoming: CityDetail["market_scan"] | null | undefined,
): CityDetail["market_scan"] | undefined {
if (!incoming) return current;
if (!current) return incoming || undefined;
const preserveHeavySlices = incoming.scan_scope === "lite";
const nextTopBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.top_buckets) || incoming.top_buckets.length === 0)
? current.top_buckets
: incoming.top_buckets;
const nextAllBuckets =
preserveHeavySlices &&
(!Array.isArray(incoming.all_buckets) || incoming.all_buckets.length === 0)
? current.all_buckets
: incoming.all_buckets;
const nextRecentTrades =
preserveHeavySlices &&
(!Array.isArray(incoming.recent_trades) || incoming.recent_trades.length === 0)
? current.recent_trades
: incoming.recent_trades;
return {
...current,
...incoming,
top_buckets: nextTopBuckets,
all_buckets: nextAllBuckets,
recent_trades: nextRecentTrades,
};
}
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
const history = Array.isArray(payload.history) ? payload.history : [];
const previewCount = Number(payload.preview_count || history.length || 0);
const fullCount = Number(payload.full_count || previewCount || 0);
return {
mode: payload.mode === "full" ? "full" : "preview",
hasMore: payload.has_more === true,
fullCount,
previewCount,
settlementSource: payload.settlement_source ?? null,
settlementSourceLabel: payload.settlement_source_label ?? null,
};
}
export function DashboardStoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const initialCacheRef = useRef<ReturnType<
typeof dashboardClient.readCityDetailCacheBundle
> | null>(null);
const [cities, setCities] = useState<CityListItem[]>([]);
const [cityDetailsByName, setCityDetailsByName] = useState<
Record<string, CityDetail>
>({});
const [citySummariesByName, setCitySummariesByName] = useState<
Record<string, CitySummary>
>({});
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>({});
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
string | null
>(null);
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
const [forecastModalMode, setForecastModalMode] =
useState<ForecastModalMode | null>(null);
const [isMapInteracting, setIsMapInteracting] = useState(false);
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [proAccess, setProAccess] = useState<ProAccessState>(
getInitialProAccessState,
);
const proAccessRef = useRef<ProAccessState>(getInitialProAccessState());
const mapStopMotionRef = useRef<() => void>(() => {});
const modalOpenSeqRef = useRef(0);
const hydratedSelectionRef = useRef(false);
const hydratedProCacheRef = useRef(false);
const summaryInflightByCityRef = useRef<Record<string, Promise<CitySummary>>>(
{},
);
const citiesRef = useRef<CityListItem[]>([]);
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const setCityDetailLoading = (isLoading: boolean) => {
setLoadingState((current) =>
current.cityDetail === isLoading
? current
: { ...current, cityDetail: isLoading },
);
};
const selectedDetail = selectedCity
? findCachedCityDetail(cityDetailsByName, selectedCity)
: null;
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
return;
}
dashboardClient.writeCityDetailCacheBundle(
cityDetailsByName,
cityDetailMetaByName,
);
}, [
cityDetailMetaByName,
cityDetailsByName,
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
]);
useEffect(() => {
citiesRef.current = cities;
}, [cities]);
useEffect(() => {
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
useEffect(() => {
selectedCityRef.current = selectedCity;
}, [selectedCity]);
useEffect(() => {
proAccessRef.current = proAccess;
}, [proAccess]);
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
hydratedProCacheRef.current = false;
initialCacheRef.current = null;
return;
}
if (hydratedProCacheRef.current) return;
hydratedProCacheRef.current = true;
const cached =
initialCacheRef.current || dashboardClient.readCityDetailCacheBundle();
initialCacheRef.current = cached;
if (!Object.keys(cached.details).length) return;
setCityDetailsByName(cached.details);
setCityDetailMetaByName(cached.meta);
setCitySummariesByName((current) => ({
...Object.fromEntries(
Object.entries(cached.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
...current,
}));
}, [proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive]);
useEffect(() => {
if (proAccess.loading) return;
if (proAccess.authenticated && proAccess.subscriptionActive) return;
dashboardClient.clearCityDetailCache();
}, [proAccess]);
const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => {
const cityName = (row.city || row.city_display_name || row.display_name || "").trim();
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
// Pre-populate cache from scan terminal row so detail panel shows data immediately
const now = Date.now();
setCityDetailsByName((current) => ({
...current,
[cityName]: { display_name: cityName, local_date: "", local_time: "", deb: {}, probabilities: {}, multi_model: {}, } as CityDetail,
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: { cachedAt: now - CACHE_TTL_MS, revision: `scan-${now}` },
}));
};
const CACHE_TTL_MS = 30 * 60 * 1000; // reuse same TTL as dashboard-client
const ensureCityDetail = async (
cityName: string,
force = false,
depth: CityDetailDepth = "panel",
) => {
const cached = findCachedCityDetail(cityDetailsByName, cityName);
const cachedMeta = cityDetailMetaByName[cityName];
const marketTargetDate =
depth === "market" ? selectedForecastDate || cached?.local_date : null;
const hasRequestedDepth = detailSatisfiesDepth(
cached,
depth,
marketTargetDate,
);
if (
!force &&
cached &&
hasRequestedDepth &&
dashboardClient.isCityDetailFresh(cachedMeta)
) {
return cached;
}
if (!force && cached && hasRequestedDepth) {
// stale-while-revalidate: return cached immediately, refresh in background
void (async () => {
try {
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: true,
depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
...current,
[cityName]: mergeCityDetail(current[cityName], detail),
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
} catch { /* keep cached data on failure */ }
})();
return cached;
}
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
...current,
[cityName]: mergeCityDetail(current[cityName], detail),
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
return detail;
};
const ensureCityMarketScan = async (
cityName: string,
force = false,
options?: {
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) => {
let cached = findCachedCityDetail(cityDetailsByName, cityName);
try {
if (!cached) {
cached = await ensureCityDetail(cityName, false, "panel");
}
const payload = await dashboardClient.getCityMarketScan(cityName, {
force,
lite: options?.lite === true,
marketSlug: options?.marketSlug || null,
targetDate:
options?.targetDate ||
(options?.marketSlug ? cached?.local_date || selectedForecastDate || null : null),
});
if (!payload.market_scan) return null;
setCityDetailsByName((current) => {
const detail = current[cityName] || cached;
if (!detail) return current;
return {
...current,
[cityName]: {
...detail,
market_scan: mergeMarketScan(detail.market_scan, payload.market_scan),
},
};
});
return payload.market_scan;
} catch {
return null;
}
};
useEffect(() => {
if (proAccess.loading) return;
if (!selectedCity) return;
if (!isPanelOpen) return;
if (findCachedCityDetail(cityDetailsByName, selectedCity)) return;
let cancelled = false;
setCityDetailLoading(true);
void ensureCityDetail(selectedCity, false, "panel")
.then((detail) => {
if (cancelled) return;
setSelectedForecastDate(detail.local_date);
})
.catch(() => {})
.finally(() => {
if (cancelled) return;
setCityDetailLoading(false);
});
return () => {
cancelled = true;
};
}, [
cityDetailsByName,
isPanelOpen,
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
selectedCity,
]);
const loadCities = async () => {
setLoadingState((current) => ({ ...current, cities: true }));
let lastError: unknown = null;
try {
for (let attempt = 0; attempt <= CITY_LOAD_RETRY_DELAYS_MS.length; attempt += 1) {
try {
const nextCities = await dashboardClient.getCities();
if (!nextCities.length) {
throw new Error("City list was empty");
}
setCities(nextCities);
return;
} catch (error) {
lastError = error;
const delayMs = CITY_LOAD_RETRY_DELAYS_MS[attempt];
if (delayMs == null) break;
await wait(delayMs);
}
}
console.error("Failed to load monitored cities", lastError);
} finally {
setLoadingState((current) => ({ ...current, cities: false }));
}
};
const refreshProAccess = async () => {
if (isBrowserLocalFullAccess()) {
const localAccess = getLocalDevProAccessState();
writeStoredProAccess(localAccess);
setProAccess(localAccess);
return;
}
setProAccess((current) => ({
...current,
loading: current.subscriptionActive ? false : true,
error: null,
}));
try {
const headers = await buildAuthMeHeaders();
const response = await fetch("/api/auth/me", {
cache: "no-store",
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as {
authenticated?: boolean;
user_id?: string | null;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
points?: number;
degraded_auth_profile?: boolean | null;
degraded_reason?: string | null;
};
const nextAccess: ProAccessState = {
loading: false,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
subscriptionTotalExpiresAt:
payload.subscription_total_expires_at ?? payload.subscription_expires_at ?? null,
subscriptionQueuedDays: Math.max(
0,
Number(payload.subscription_queued_days ?? 0),
),
points: payload.points ?? 0,
error: null,
};
const mergedAccess = mergeWithStoredProAccess(
nextAccess,
payload.degraded_auth_profile
? String(payload.degraded_reason || "degraded_auth_profile")
: "using_cached_pro_access",
);
if (mergedAccess.subscriptionActive) {
writeStoredProAccess(mergedAccess);
} else if (!mergedAccess.authenticated || payload.subscription_active === false) {
clearStoredProAccess();
}
setProAccess(mergedAccess);
} catch (error) {
const cachedAccess = readStoredProAccess();
if (cachedAccess) {
setProAccess({
...cachedAccess,
loading: false,
error: String(error),
});
return;
}
clearStoredProAccess();
setProAccess({
loading: false,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0,
error: String(error),
});
}
};
useEffect(() => {
void loadCities();
}, []);
useEffect(() => {
if (!cities.length) return;
if (typeof window === "undefined") return;
const schedule = () => dashboardClient.sendPriorityWarmHint();
const idleCallback = (window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
}).requestIdleCallback;
if (typeof idleCallback === "function") {
const id = idleCallback(schedule, { timeout: 3000 });
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(id);
}
};
}
const timer = window.setTimeout(schedule, 2000);
return () => window.clearTimeout(timer);
}, [cities.length]);
useEffect(() => {
void refreshProAccess();
}, []);
useEffect(() => {
if (!hasSupabasePublicEnv()) return;
const {
data: { subscription },
} = getSupabaseBrowserClient().auth.onAuthStateChange((event) => {
if (event === "SIGNED_OUT") {
clearStoredProAccess();
setProAccess({
loading: false,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0,
error: null,
});
return;
}
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
void refreshProAccess();
}
});
return () => subscription.unsubscribe();
}, []);
const ensureCitySummary = async (cityName: string, force = false) => {
const existing = citySummariesRef.current[cityName];
if (!force && existing) {
return existing;
}
const inflight = summaryInflightByCityRef.current[cityName];
if (inflight) {
const settled = await inflight;
if (!force) {
return settled;
}
}
const request = dashboardClient
.getCitySummary(cityName, { force })
.then((summary) => {
setCitySummariesByName((current) => {
const currentSummary = current[cityName];
const currentRevision = getCityRevision(currentSummary);
const nextRevision = getCityRevision(summary);
if (
currentSummary &&
currentRevision &&
nextRevision &&
currentRevision === nextRevision
) {
return current;
}
const next = {
...current,
[cityName]: summary,
};
citySummariesRef.current = next;
return next;
});
return summary;
})
.finally(() => {
if (summaryInflightByCityRef.current[cityName] === request) {
delete summaryInflightByCityRef.current[cityName];
}
});
summaryInflightByCityRef.current[cityName] = request;
return request;
};
useEffect(() => {
if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) {
return;
}
if (
markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session")
) {
trackAppEvent("dashboard_active", {
subscription_active: proAccess.subscriptionActive,
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
const isTrialPlan = /trial/i.test(
String(proAccess.subscriptionPlanCode || ""),
);
if (
isTrialPlan &&
markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
) {
trackAppEvent("signup_completed", {
source: "auth_me_trial",
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
}, [
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
proAccess.subscriptionPlanCode,
proAccess.userId,
]);
const selectCity = async (cityName: string) => {
const wasSelectedCity = selectedCityRef.current === cityName;
const cached = findCachedCityDetail(cityDetailsByName, cityName);
selectedCityRef.current = cityName;
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(
cached?.local_date || (wasSelectedCity ? selectedForecastDate : null),
);
setFutureModalDate(null);
setForecastModalMode(null);
const summaryPromise = !citySummariesRef.current[cityName]
? ensureCitySummary(cityName).catch(() => null)
: Promise.resolve(citySummariesRef.current[cityName]);
if (proAccessRef.current.loading) {
setCityDetailLoading(true);
const detailPromise = ensureCityDetail(cityName, false, "panel");
try {
const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]);
if (selectedCityRef.current === cityName) {
if (detail.status === "fulfilled") {
setSelectedForecastDate(detail.value.local_date);
}
}
} catch {
} finally {
if (selectedCityRef.current === cityName) {
setCityDetailLoading(false);
}
}
return;
}
setCityDetailLoading(!cached);
const detailPromise = ensureCityDetail(cityName, false, "panel");
void Promise.allSettled([summaryPromise, detailPromise])
.then(([, detail]) => {
if (selectedCityRef.current !== cityName) return;
if (detail.status === "fulfilled") {
setSelectedForecastDate(detail.value.local_date);
}
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setCityDetailLoading(false);
});
};
const focusCity = async (cityName: string) => {
const cached = findCachedCityDetail(cityDetailsByName, cityName);
selectedCityRef.current = cityName;
setSelectedCity(cityName);
setIsPanelOpen(false);
setSelectedForecastDate(null);
setFutureModalDate(null);
setForecastModalMode(null);
setCityDetailLoading(!cached);
void Promise.allSettled([
ensureCitySummary(cityName),
ensureCityDetail(cityName, false, "panel"),
])
.then(([, detail]) => {
if (selectedCityRef.current !== cityName) return;
if (detail.status === "fulfilled") {
setSelectedForecastDate(detail.value.local_date);
}
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setCityDetailLoading(false);
});
};
const clearCityFocus = () => {
selectedCityRef.current = null;
setSelectedCity(null);
setIsPanelOpen(false);
setSelectedForecastDate(null);
setFutureModalDate(null);
setForecastModalMode(null);
};
useEffect(() => {
if (typeof window === "undefined") return;
if (selectedCity) {
window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity);
} else {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}
}, [selectedCity]);
useEffect(() => {
if (hydratedSelectionRef.current) return;
if (!cities.length) return;
if (selectedCity) {
hydratedSelectionRef.current = true;
return;
}
if (typeof window === "undefined") return;
hydratedSelectionRef.current = true;
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}, [cities, selectedCity]);
const refreshSelectedCity = async () => {
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await ensureCityDetail(selectedCity, true, "panel");
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
};
const refreshAll = async () => {
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
if (!citiesRef.current.length) {
await loadCities();
}
if (selectedCity) {
const access = proAccessRef.current;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
if (access.authenticated && access.subscriptionActive) {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
depth: "panel",
});
const detail = latestDetail;
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
[selectedCity]: toCitySummary(detail),
}));
setCityDetailMetaByName({
[selectedCity]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
});
setSelectedForecastDate(detail.local_date);
} else {
const summary = await ensureCitySummary(selectedCity, true);
setCitySummariesByName((current) => ({
...current,
[selectedCity]: summary,
}));
}
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
}
};
const openHistory = async () => {
if (!selectedCity) return;
if (!proAccess.subscriptionActive) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
recordsLoading: false,
}));
return;
}
const cityName = selectedCity;
const cachedHistory = historyState.dataByCity[cityName];
const cachedMeta = historyState.metaByCity[cityName];
if (cachedMeta && cachedHistory?.length) {
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: false,
recordsLoading: cachedMeta.mode !== "full" && cachedMeta.hasMore,
}));
if (cachedMeta.mode !== "full" && cachedMeta.hasMore) {
void dashboardClient
.getHistory(cityName, { includeRecords: true })
.then((payload) => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: payload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(payload),
},
recordsLoading: false,
}));
})
.catch(() => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
recordsLoading: false,
}));
});
}
return;
}
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: true,
recordsLoading: false,
}));
try {
const payload = await dashboardClient.getHistory(cityName);
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: payload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(payload),
},
loading: false,
recordsLoading: payload.has_more === true,
}));
if (payload.has_more) {
void dashboardClient
.getHistory(cityName, { includeRecords: true })
.then((fullPayload) => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[cityName]: fullPayload.history,
},
metaByCity: {
...current.metaByCity,
[cityName]: toHistoryMeta(fullPayload),
},
recordsLoading: false,
}));
})
.catch(() => {
if (selectedCityRef.current !== cityName) return;
setHistoryState((current) => ({
...current,
recordsLoading: false,
}));
});
}
} catch (error) {
setHistoryState((current) => ({
...current,
error: String(error),
loading: false,
recordsLoading: false,
}));
}
};
const value = useMemo<DashboardStoreValue>(
() => ({
cities,
cityDetailsByName,
citySummariesByName,
clearCityFocus,
closeFutureModal: () => {
modalOpenSeqRef.current += 1;
setFutureModalDate(null);
setForecastModalMode(null);
},
closeHistory: () =>
setHistoryState((current) => ({ ...current, isOpen: false })),
closePanel: () => {
setIsPanelOpen(false);
},
ensureCityDetail,
ensureCityMarketScan,
focusCity,
forecastModalMode,
futureModalDate,
historyState,
isPanelOpen,
loadCities,
preloadCityFromRow,
loadingState,
proAccess,
openFutureModal: async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
if (!selectedCity || !proAccess.subscriptionActive) return;
const cityName = selectedCity;
const modalSeq = (modalOpenSeqRef.current += 1);
const isLatestModalRequest = () =>
modalOpenSeqRef.current === modalSeq &&
selectedCityRef.current === cityName;
let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity);
if (!cachedDetail) {
setCityDetailLoading(true);
try {
cachedDetail = await ensureCityDetail(cityName, false, "panel");
} finally {
if (isLatestModalRequest()) {
setCityDetailLoading(false);
}
}
}
if (!isLatestModalRequest()) return;
const hasFullCachedDetail =
detailSatisfiesDepth(cachedDetail, "full") &&
!hasSparseDetailCoverage(cachedDetail, dateStr);
const todayDate =
cachedDetail?.local_date ||
cachedDetail?.forecast?.daily?.[0]?.date ||
null;
const modalMode: ForecastModalMode =
todayDate && dateStr === todayDate ? "today" : "future";
setSelectedForecastDate(dateStr);
setFutureModalDate(dateStr);
setForecastModalMode(modalMode);
if (!hasFullCachedDetail || forceRefresh) {
setLoadingState((current) => ({
...current,
futureDeep: true,
}));
void ensureCityDetail(cityName, true, "full")
.catch(() => {})
.finally(() => {
if (!isLatestModalRequest()) return;
setLoadingState((current) => ({
...current,
futureDeep: false,
}));
});
}
},
openHistory,
openTodayModal: async (forceRefresh?: boolean) => {
const activeCity = selectedCityRef.current || selectedCity;
if (!activeCity) {
return;
}
mapStopMotionRef.current();
const cityName = activeCity;
const modalSeq = (modalOpenSeqRef.current += 1);
const isLatestModalRequest = () =>
modalOpenSeqRef.current === modalSeq &&
selectedCityRef.current === cityName;
let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName);
if (!cachedDetail) {
setCityDetailLoading(true);
try {
cachedDetail = await ensureCityDetail(cityName, false, "panel");
} finally {
if (isLatestModalRequest()) {
setCityDetailLoading(false);
}
}
}
if (!isLatestModalRequest()) return;
const hasFullCachedDetail =
detailSatisfiesDepth(cachedDetail, "full") &&
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
const targetDate =
cachedDetail?.local_date ||
cachedDetail?.forecast?.daily?.[0]?.date ||
null;
if (targetDate) {
setSelectedForecastDate(targetDate);
setFutureModalDate(targetDate);
setForecastModalMode("today");
}
if (!proAccess.subscriptionActive) return;
const needsDetailRefresh =
forceRefresh ||
!detailSatisfiesDepth(cachedDetail, "full") ||
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
setLoadingState((current) => ({
...current,
futureDeep: needsDetailRefresh,
}));
void ensureCityDetail(
cityName,
needsDetailRefresh,
"full",
)
.then((detail) => {
if (!isLatestModalRequest()) return;
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);
setForecastModalMode("today");
})
.catch(() => {
if (!isLatestModalRequest()) return;
if (cachedDetail?.local_date) {
setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date);
setForecastModalMode("today");
}
})
.finally(() => {
if (!isLatestModalRequest()) return;
setLoadingState((current) => ({
...current,
futureDeep: false,
}));
});
},
registerMapStopMotion: (stopMotion: () => void) => {
mapStopMotionRef.current = stopMotion;
},
refreshAll,
refreshProAccess,
refreshSelectedCity,
selectedCity,
selectedDetail,
selectedForecastDate,
selectCity,
setMapInteractionActive: setIsMapInteracting,
setForecastDate: (dateStr: string | null) =>
setSelectedForecastDate(dateStr),
}),
[
cities,
cityDetailsByName,
citySummariesByName,
forecastModalMode,
futureModalDate,
historyState,
isPanelOpen,
loadingState,
proAccess,
selectedCity,
selectedDetail,
selectedForecastDate,
],
);
const cityDetailsValue = useMemo(
() => ({ cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState }),
[cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState],
);
return (
<DashboardStoreContext.Provider value={value}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardStoreContext.Provider>
);
}
export function useDashboardStore() {
const context = useContext(DashboardStoreContext);
if (!context) {
throw new Error(
"useDashboardStore must be used within DashboardStoreProvider",
);
}
return context;
}
export function useCityDetails() {
const context = useContext(CityDetailsContext);
if (!context) {
throw new Error(
"useCityDetails must be used within DashboardStoreProvider",
);
}
return context;
}
export function useCityData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key ? findCachedCityDetail(store.cityDetailsByName, key) : null,
isLoading:
store.loadingState.cityDetail &&
Boolean(key) &&
store.selectedCity === key,
};
}
export function useHistoryData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key
? store.historyState.dataByCity[key] || ([] as HistoryPoint[])
: [],
error: store.historyState.error,
isLoading: store.historyState.loading,
isOpen: store.historyState.isOpen,
isRecordsLoading: store.historyState.recordsLoading,
meta: key ? store.historyState.metaByCity[key] || null : null,
};
}