"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 { CityDetail, CityListItem, CitySummary, DashboardState, HistoryPoint, HistoryState, LoadingState, MarketScan, ProAccessState, } from "@/lib/dashboard-types"; interface DashboardStoreValue extends DashboardState { closeFutureModal: () => void; closeHistory: () => void; closePanel: () => void; ensureCityDetail: ( cityName: string, force?: boolean, depth?: "panel" | "full", ) => Promise; futureModalDate: string | null; loadCities: () => Promise; openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; openHistory: () => Promise; openTodayModal: (forceRefresh?: boolean) => Promise; registerMapStopMotion: (stopMotion: () => void) => void; refreshAll: () => Promise; refreshProAccess: () => Promise; refreshSelectedCity: () => Promise; selectedMarketScan: MarketScan | null; selectedDetail: CityDetail | null; selectCity: (cityName: string) => Promise; setForecastDate: (dateStr: string | null) => void; marketScanByCityName: Record; } const DashboardStoreContext = createContext(null); function getInitialLoadingState(): LoadingState { return { cities: false, cityDetail: false, history: false, refresh: false, marketScan: false, }; } function getInitialHistoryState(): HistoryState { return { dataByCity: {}, error: null, isOpen: false, loading: false, }; } function getInitialProAccessState(): ProAccessState { return { loading: true, authenticated: false, userId: null, subscriptionActive: false, subscriptionPlanCode: null, subscriptionExpiresAt: null, points: 0, error: null, }; } function getMarketScanCacheKey(cityName: string, targetDate?: string | null) { const normalizedDate = String(targetDate || "").trim() || "local"; return `${cityName}::${normalizedDate}`; } const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1"; const BACKGROUND_SUMMARY_REFRESH_MS = 30_000; const EAGER_CITY_SUMMARIES_DISABLED = process.env.NEXT_PUBLIC_POLYWEATHER_DISABLE_EAGER_SUMMARIES === "true"; const EAGER_SUMMARY_PRIORITY_CONCURRENCY = 3; const EAGER_SUMMARY_BACKGROUND_CONCURRENCY = 2; const EAGER_SUMMARY_BACKGROUND_IDLE_TIMEOUT_MS = 1200; const EAGER_SUMMARY_PRIORITY_CITY_ORDER = [ "hong kong", "taipei", "shanghai", "beijing", "wuhan", "shenzhen", "chengdu", "chongqing", "seoul", "busan", "tokyo", "singapore", "kuala lumpur", "jakarta", "tel aviv", "jeddah", "istanbul", "moscow", "helsinki", "warsaw", "amsterdam", "london", "paris", "milan", "madrid", ] as const; type CityDetailDepth = "panel" | "full"; 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; return Array.isArray(daily) ? daily.length : 0; } 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 normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth { return detail?.detail_depth === "panel" ? "panel" : "full"; } function detailSatisfiesDepth( detail: CityDetail | null | undefined, depth: CityDetailDepth, ) { if (!detail) return false; if (depth === "panel") return true; return normalizeDetailDepth(detail) === "full"; } function getStoredSelectedCityName(cities: CityListItem[]) { if (typeof window === "undefined") return null; const stored = String( window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "", ) .trim() .toLowerCase(); if (!stored) return null; if (!cities.some((city) => city.name === stored)) return null; return stored; } function getPriorityPreloadCities(cities: CityListItem[]) { const citySet = new Set(cities.map((city) => city.name)); return EAGER_SUMMARY_PRIORITY_CITY_ORDER.filter((cityName) => citySet.has(cityName) ); } function scheduleWhenBrowserIdle(callback: () => void) { if (typeof window === "undefined") { callback(); return () => {}; } const browserWindow = window as Window & { requestIdleCallback?: ( cb: IdleRequestCallback, options?: IdleRequestOptions, ) => number; cancelIdleCallback?: (handle: number) => void; }; if (typeof browserWindow.requestIdleCallback === "function") { const handle = browserWindow.requestIdleCallback( () => callback(), { timeout: EAGER_SUMMARY_BACKGROUND_IDLE_TIMEOUT_MS }, ); return () => { if (typeof browserWindow.cancelIdleCallback === "function") { browserWindow.cancelIdleCallback(handle); } }; } const timeoutId = window.setTimeout(callback, 400); return () => { window.clearTimeout(timeoutId); }; } export function DashboardStoreProvider({ children, }: { children: React.ReactNode; }) { const initialCacheRef = useRef | null>(null); const [cities, setCities] = useState([]); const [cityDetailsByName, setCityDetailsByName] = useState< Record >({}); const [citySummariesByName, setCitySummariesByName] = useState< Record >({}); const [cityDetailMetaByName, setCityDetailMetaByName] = useState< Record >({}); const [marketScanByCityName, setMarketScanByCityName] = useState< Record >({}); const [selectedCity, setSelectedCity] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(false); const [selectedForecastDate, setSelectedForecastDate] = useState< string | null >(null); const [futureModalDate, setFutureModalDate] = useState(null); const [loadingState, setLoadingState] = useState( getInitialLoadingState, ); const [historyState, setHistoryState] = useState( getInitialHistoryState, ); const [proAccess, setProAccess] = useState( getInitialProAccessState, ); const proAccessRef = useRef(getInitialProAccessState()); const mapStopMotionRef = useRef<() => void>(() => {}); const hydratedSelectionRef = useRef(false); const hydratedProCacheRef = useRef(false); const backgroundSummaryCheckAtRef = useRef>({}); const summaryInflightByCityRef = useRef>>( {}, ); const citySummariesRef = useRef>({}); const selectedCityRef = useRef(null); const selectedDetail = selectedCity && proAccess.subscriptionActive ? cityDetailsByName[selectedCity] || null : null; const selectedMarketDate = futureModalDate || selectedForecastDate || selectedDetail?.local_date || null; const selectedMarketScanKey = selectedCity ? getMarketScanCacheKey(selectedCity, selectedMarketDate) : null; const selectedMarketScan = selectedCity && proAccess.subscriptionActive ? marketScanByCityName[selectedMarketScanKey || ""] || null : null; useEffect(() => { if (proAccess.loading) return; if (!proAccess.authenticated || !proAccess.subscriptionActive) { dashboardClient.clearCityDetailCache(); return; } dashboardClient.writeCityDetailCacheBundle( cityDetailsByName, cityDetailMetaByName, ); }, [ cityDetailMetaByName, cityDetailsByName, proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive, ]); 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(); setCityDetailsByName({}); setCityDetailMetaByName({}); setMarketScanByCityName({}); }, [proAccess]); const scheduleBackgroundDetailRefresh = ( cityName: string, cached: CityDetail, cachedMeta?: { cachedAt: number; revision: string }, ) => { const nowTs = Date.now(); const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0; if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) { return; } backgroundSummaryCheckAtRef.current[cityName] = nowTs; void dashboardClient .getCitySummary(cityName) .then(async (summary) => { const revision = getCityRevision(summary); if (!revision || revision === cachedMeta?.revision) { return; } const latestDetail = await dashboardClient.getCityDetail(cityName, { force: false, depth: normalizeDetailDepth(cached), }); const detail = latestDetail; setCityDetailsByName((current) => ({ ...current, [cityName]: detail, })); setCitySummariesByName((current) => ({ ...current, [cityName]: toCitySummary(detail), })); setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision: getCityRevision(detail), }, })); }) .catch(() => {}); }; const ensureCityDetail = async ( cityName: string, force = false, depth: CityDetailDepth = "panel", ) => { const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; const hasRequestedDepth = detailSatisfiesDepth(cached, depth); const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date); if ( !force && cached && hasRequestedDepth && !cachedIsSparse && dashboardClient.isCityDetailFresh(cachedMeta) ) { scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta); return cached; } if (!force && cached && hasRequestedDepth) { try { const summary = await dashboardClient.getCitySummary(cityName); const revision = getCityRevision(summary); if (revision && revision === cachedMeta?.revision) { if (cachedIsSparse) { const latestDetail = await dashboardClient.getCityDetail(cityName, { force: true, depth, }); const detail = latestDetail; setCityDetailsByName((current) => ({ ...current, [cityName]: detail, })); setCitySummariesByName((current) => ({ ...current, [cityName]: toCitySummary(detail), })); setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision: getCityRevision(detail), }, })); return detail; } setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision, }, })); return cached; } } catch { return cached; } } const latestDetail = await dashboardClient.getCityDetail(cityName, { force, depth, }); const detail = latestDetail; setCityDetailsByName((current) => ({ ...current, [cityName]: detail, })); setCitySummariesByName((current) => ({ ...current, [cityName]: toCitySummary(detail), })); setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision: getCityRevision(detail), }, })); return detail; }; useEffect(() => { if (proAccess.loading) return; if (!selectedCity) return; if (!isPanelOpen) return; if (!proAccess.authenticated || !proAccess.subscriptionActive) return; if (cityDetailsByName[selectedCity]) return; let cancelled = false; setLoadingState((current) => ({ ...current, cityDetail: true })); void ensureCityDetail(selectedCity, false, "panel") .then((detail) => { if (cancelled) return; setSelectedForecastDate(detail.local_date); }) .catch(() => {}) .finally(() => { if (cancelled) return; setLoadingState((current) => ({ ...current, cityDetail: false })); }); return () => { cancelled = true; }; }, [ cityDetailsByName, ensureCityDetail, isPanelOpen, proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive, selectedCity, ]); const ensureCityMarketScan = async ( cityName: string, force = false, marketSlug?: string | null, targetDate?: string | null, ) => { const cacheKey = getMarketScanCacheKey(cityName, targetDate); const cached = marketScanByCityName[cacheKey]; if (!force && cached && !marketSlug) { return cached; } const latestScan = await dashboardClient.getCityMarketScan(cityName, { force, marketSlug, targetDate, }); if (latestScan) { setMarketScanByCityName((current) => ({ ...current, [cacheKey]: latestScan, })); } return latestScan; }; const loadCities = async () => { setLoadingState((current) => ({ ...current, cities: true })); try { const nextCities = await dashboardClient.getCities(); setCities(nextCities); } finally { setLoadingState((current) => ({ ...current, cities: false })); } }; const refreshProAccess = async () => { setProAccess((current) => ({ ...current, loading: true, error: null, })); try { const response = await fetch("/api/auth/me", { cache: "no-store", headers: { Accept: "application/json", }, }); 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; points?: number; }; setProAccess({ 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, points: payload.points ?? 0, error: null, }); } catch (error) { setProAccess({ loading: false, authenticated: false, userId: null, subscriptionActive: false, subscriptionPlanCode: null, subscriptionExpiresAt: null, points: 0, error: String(error), }); } }; useEffect(() => { void loadCities(); }, []); useEffect(() => { void refreshProAccess(); }, []); 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, ]); useEffect(() => { if (EAGER_CITY_SUMMARIES_DISABLED) return; if (!cities.length) return; if (loadingState.refresh) return; const priorityQueue: string[] = []; const backgroundQueue: string[] = []; const queued = new Set(); const enqueue = (cityName: string | null, queue: string[]) => { if (!cityName || queued.has(cityName) || citySummariesRef.current[cityName]) { return; } queued.add(cityName); queue.push(cityName); }; enqueue(getStoredSelectedCityName(cities), priorityQueue); getPriorityPreloadCities(cities).forEach((cityName) => { enqueue(cityName, priorityQueue); }); cities.forEach((city) => { enqueue(city.name, backgroundQueue); }); if (!priorityQueue.length && !backgroundQueue.length) return; let active = true; let cancelIdleSchedule = () => {}; const runQueue = async (queue: string[], concurrency: number) => { if (!queue.length) return; let cursor = 0; const worker = async () => { while (active && cursor < queue.length) { const cityName = queue[cursor]; cursor += 1; if (citySummariesRef.current[cityName]) continue; try { await ensureCitySummary(cityName); } catch {} } }; await Promise.all( Array.from({ length: Math.min(concurrency, queue.length) }, () => worker() ), ); }; void (async () => { await runQueue(priorityQueue, EAGER_SUMMARY_PRIORITY_CONCURRENCY); if (!active) return; cancelIdleSchedule = scheduleWhenBrowserIdle(() => { if (!active) return; void runQueue( backgroundQueue, EAGER_SUMMARY_BACKGROUND_CONCURRENCY, ); }); })(); return () => { active = false; cancelIdleSchedule(); }; }, [cities, loadingState.refresh]); const selectCity = async (cityName: string) => { setSelectedCity(cityName); setIsPanelOpen(true); setSelectedForecastDate(null); setFutureModalDate(null); const summaryPromise = !citySummariesRef.current[cityName] ? ensureCitySummary(cityName).catch(() => null) : Promise.resolve(citySummariesRef.current[cityName]); if (proAccessRef.current.loading) { setLoadingState((current) => ({ ...current, cityDetail: true })); try { await summaryPromise; } catch { } finally { setLoadingState((current) => ({ ...current, cityDetail: false })); } return; } const access = proAccessRef.current; if (!access.authenticated || !access.subscriptionActive) { setLoadingState((current) => ({ ...current, cityDetail: true })); try { await summaryPromise; } catch { } finally { setLoadingState((current) => ({ ...current, cityDetail: false })); } return; } const cachedDetail = cityDetailsByName[cityName]; const needsDetailRefresh = hasSparseDetailCoverage( cachedDetail, cachedDetail?.local_date, ); setLoadingState((current) => ({ ...current, cityDetail: true })); try { await summaryPromise; } catch { } void ensureCityDetail(cityName, needsDetailRefresh, "panel") .then((detail) => { if (selectedCityRef.current !== cityName) return; setSelectedForecastDate(detail.local_date); if (access.authenticated && access.subscriptionActive) { // 预热市场数据,不做 await 阻塞,后台静默拉取 void ensureCityMarketScan( cityName, false, null, detail.local_date, ).catch(() => {}); } }) .finally(() => { if (selectedCityRef.current !== cityName) return; setLoadingState((current) => ({ ...current, cityDetail: false })); }); }; 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; const stored = String( window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "", ) .trim() .toLowerCase(); if (!stored) return; if (!cities.some((city) => city.name === stored)) return; void selectCity(stored); }, [cities, selectedCity, selectCity]); 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 (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, })); return; } setHistoryState((current) => ({ ...current, error: null, isOpen: true, loading: true, })); try { const history = await dashboardClient.getHistory(selectedCity); setHistoryState((current) => ({ ...current, dataByCity: { ...current.dataByCity, [selectedCity]: history, }, loading: false, })); } catch (error) { setHistoryState((current) => ({ ...current, error: String(error), loading: false, })); } }; const value = useMemo( () => ({ cities, cityDetailsByName, citySummariesByName, closeFutureModal: () => setFutureModalDate(null), closeHistory: () => setHistoryState((current) => ({ ...current, isOpen: false })), closePanel: () => { setIsPanelOpen(false); }, ensureCityDetail, futureModalDate, historyState, isPanelOpen, loadCities, loadingState, proAccess, openFutureModal: async (dateStr: string, forceRefresh = false) => { mapStopMotionRef.current(); if (!selectedCity || !proAccess.subscriptionActive) return; const cachedDetail = cityDetailsByName[selectedCity]; const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, dateStr); if (!hasFullCachedDetail || forceRefresh) { setLoadingState((current) => ({ ...current, refresh: true, })); try { await ensureCityDetail(selectedCity, true, "full"); } catch { } finally { setLoadingState((current) => ({ ...current, refresh: false, })); } } setFutureModalDate(dateStr); const cacheKey = getMarketScanCacheKey(selectedCity, dateStr); setLoadingState((current) => ({ ...current, marketScan: true })); void ensureCityMarketScan( selectedCity, forceRefresh || !marketScanByCityName[cacheKey], null, dateStr, ) .catch(() => {}) .finally(() => { setLoadingState((current) => ({ ...current, marketScan: false })); }); }, openHistory, openTodayModal: async (forceRefresh?: boolean) => { if (!selectedCity) { return; } mapStopMotionRef.current(); const cachedDetail = cityDetailsByName[selectedCity]; const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); if (hasFullCachedDetail && cachedDetail?.local_date) { setSelectedForecastDate(cachedDetail.local_date); setFutureModalDate(cachedDetail.local_date); } if (!proAccess.subscriptionActive) return; const needsDetailRefresh = forceRefresh || !detailSatisfiesDepth(cachedDetail, "full") || hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); setLoadingState((current) => ({ ...current, refresh: needsDetailRefresh, marketScan: true, })); try { const detail = await ensureCityDetail( selectedCity, needsDetailRefresh, "full", ); setSelectedForecastDate(detail.local_date); setFutureModalDate(detail.local_date); const marketKey = getMarketScanCacheKey(selectedCity, detail.local_date); try { await ensureCityMarketScan( selectedCity, forceRefresh || !marketScanByCityName[marketKey], null, detail.local_date, ); } catch {} } catch { if (cachedDetail?.local_date) { setSelectedForecastDate(cachedDetail.local_date); setFutureModalDate(cachedDetail.local_date); } } finally { setLoadingState((current) => ({ ...current, refresh: false, marketScan: false, })); } }, registerMapStopMotion: (stopMotion: () => void) => { mapStopMotionRef.current = stopMotion; }, refreshAll, refreshProAccess, refreshSelectedCity, selectedMarketScan, selectedCity, selectedDetail, selectedForecastDate, selectCity, setForecastDate: (dateStr: string | null) => setSelectedForecastDate(dateStr), marketScanByCityName, }), [ cities, cityDetailsByName, citySummariesByName, futureModalDate, historyState, isPanelOpen, loadingState, proAccess, marketScanByCityName, selectedMarketScan, selectedCity, selectedDetail, selectedForecastDate, ], ); return ( {children} ); } export function useDashboardStore() { const context = useContext(DashboardStoreContext); if (!context) { throw new Error( "useDashboardStore must be used within DashboardStoreProvider", ); } return context; } export function useCityData(name?: string | null) { const store = useDashboardStore(); const key = name || store.selectedCity; return { data: key ? store.cityDetailsByName[key] || null : 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, }; }