1147 lines
32 KiB
TypeScript
1147 lines
32 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 {
|
|
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" | "nearby" | "full",
|
|
) => Promise<CityDetail>;
|
|
futureModalDate: string | null;
|
|
loadCities: () => Promise<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>;
|
|
selectedMarketScan: MarketScan | null;
|
|
selectedDetail: CityDetail | null;
|
|
selectCity: (cityName: string) => Promise<void>;
|
|
setMapInteractionActive: (active: boolean) => void;
|
|
setForecastDate: (dateStr: string | null) => void;
|
|
marketScanByCityName: Record<string, MarketScan>;
|
|
}
|
|
|
|
const DashboardStoreContext = createContext<DashboardStoreValue | null>(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" | "nearby" | "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 {
|
|
if (detail?.detail_depth === "nearby") return "nearby";
|
|
if (detail?.detail_depth === "panel") return "panel";
|
|
return "full";
|
|
}
|
|
|
|
function detailSatisfiesDepth(
|
|
detail: CityDetail | null | undefined,
|
|
depth: CityDetailDepth,
|
|
) {
|
|
if (!detail) return false;
|
|
if (depth === "panel") return true;
|
|
if (depth === "nearby") {
|
|
const normalized = normalizeDetailDepth(detail);
|
|
return normalized === "nearby" || normalized === "full";
|
|
}
|
|
return normalizeDetailDepth(detail) === "full";
|
|
}
|
|
|
|
function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) {
|
|
return depth === "panel" || depth === "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<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 [marketScanByCityName, setMarketScanByCityName] = useState<
|
|
Record<string, MarketScan>
|
|
>({});
|
|
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 [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 hydratedSelectionRef = useRef(false);
|
|
const hydratedProCacheRef = useRef(false);
|
|
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
|
const summaryInflightByCityRef = useRef<Record<string, Promise<CitySummary>>>(
|
|
{},
|
|
);
|
|
const citySummariesRef = useRef<Record<string, CitySummary>>({});
|
|
const selectedCityRef = useRef<string | null>(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 =
|
|
shouldCheckSparseCoverageForDepth(depth) &&
|
|
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<string>();
|
|
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;
|
|
if (isMapInteracting) return;
|
|
cancelIdleSchedule = scheduleWhenBrowserIdle(() => {
|
|
if (!active || isMapInteracting) return;
|
|
void runQueue(
|
|
backgroundQueue,
|
|
EAGER_SUMMARY_BACKGROUND_CONCURRENCY,
|
|
);
|
|
});
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
cancelIdleSchedule();
|
|
};
|
|
}, [cities, isMapInteracting, 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<DashboardStoreValue>(
|
|
() => ({
|
|
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,
|
|
setMapInteractionActive: setIsMapInteracting,
|
|
setForecastDate: (dateStr: string | null) =>
|
|
setSelectedForecastDate(dateStr),
|
|
marketScanByCityName,
|
|
}),
|
|
[
|
|
cities,
|
|
cityDetailsByName,
|
|
citySummariesByName,
|
|
futureModalDate,
|
|
historyState,
|
|
isPanelOpen,
|
|
loadingState,
|
|
proAccess,
|
|
marketScanByCityName,
|
|
selectedMarketScan,
|
|
selectedCity,
|
|
selectedDetail,
|
|
selectedForecastDate,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<DashboardStoreContext.Provider value={value}>
|
|
{children}
|
|
</DashboardStoreContext.Provider>
|
|
);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|