feat: add multi-model Open-Meteo data collection and implement scan terminal dashboard service
This commit is contained in:
@@ -466,6 +466,10 @@ function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function waitForDeepAnalysisQueue(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
const { locale } = useI18n();
|
||||
const chartData = useMemo(
|
||||
@@ -674,7 +678,7 @@ function AiPinnedCityCard({
|
||||
body: JSON.stringify({
|
||||
city: detailCityName,
|
||||
force_refresh: aiRefreshToken > 0,
|
||||
locale,
|
||||
locale: "zh-CN",
|
||||
}),
|
||||
})
|
||||
.then(async (response) => {
|
||||
@@ -715,7 +719,7 @@ function AiPinnedCityCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
|
||||
}, [aiForecastKey, aiRefreshToken, detailCityName]);
|
||||
|
||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||
const localizedFinalJudgment =
|
||||
@@ -1215,6 +1219,8 @@ function ScanTerminalScreen() {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const lastMapSelectedCityRef = useRef<string>("");
|
||||
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
||||
const aiHydrationQueueRef = useRef<string[]>([]);
|
||||
const aiHydrationRunningRef = useRef(false);
|
||||
|
||||
const timeSortedRows = useMemo(
|
||||
() => sortRowsByUserTime(terminalData?.rows || []),
|
||||
@@ -1373,6 +1379,48 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||
|
||||
const runAiHydrationQueue = useCallback(async () => {
|
||||
if (aiHydrationRunningRef.current) return;
|
||||
aiHydrationRunningRef.current = true;
|
||||
try {
|
||||
while (aiHydrationQueueRef.current.length > 0) {
|
||||
const nextCity = aiHydrationQueueRef.current.shift();
|
||||
const key = normalizeCityKey(nextCity || "");
|
||||
if (!nextCity || !key) continue;
|
||||
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
|
||||
try {
|
||||
const detail = await store.ensureCityDetail(
|
||||
nextCity,
|
||||
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
||||
"full",
|
||||
);
|
||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
} catch {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
await waitForDeepAnalysisQueue(1200);
|
||||
}
|
||||
} finally {
|
||||
aiHydrationRunningRef.current = false;
|
||||
if (aiHydrationQueueRef.current.length > 0) {
|
||||
void runAiHydrationQueue();
|
||||
}
|
||||
}
|
||||
}, [store.cityDetailsByName, store.ensureCityDetail]);
|
||||
|
||||
const queueAiFullHydration = useCallback(
|
||||
(cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||
aiFullHydrationRef.current.add(key);
|
||||
aiHydrationQueueRef.current.push(cityName);
|
||||
void runAiHydrationQueue();
|
||||
},
|
||||
[runAiHydrationQueue],
|
||||
);
|
||||
|
||||
const addAiPinnedCity = useCallback((cityName: string) => {
|
||||
const cleanName = String(cityName || "").trim();
|
||||
const key = normalizeCityKey(cleanName);
|
||||
@@ -1404,22 +1452,15 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
return [nextItem, ...current].slice(0, 8);
|
||||
});
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiFullHydrationRef.current.add(key);
|
||||
void store
|
||||
.ensureCityDetail(cleanName, true, "full")
|
||||
.then((detail) => {
|
||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
});
|
||||
}, [locale, store.ensureCityDetail, timeSortedRows]);
|
||||
queueAiFullHydration(matchedRow?.city || cleanName);
|
||||
}, [locale, queueAiFullHydration, timeSortedRows]);
|
||||
|
||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
||||
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
||||
);
|
||||
setAiPinnedCities((current) =>
|
||||
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
||||
);
|
||||
@@ -1432,14 +1473,9 @@ function ScanTerminalScreen() {
|
||||
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
||||
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
||||
if (!needsFullHydration) return;
|
||||
aiFullHydrationRef.current.add(key);
|
||||
void store
|
||||
.ensureCityDetail(item.cityName, Boolean(detail), "full")
|
||||
.catch(() => {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
});
|
||||
queueAiFullHydration(item.cityName);
|
||||
});
|
||||
}, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]);
|
||||
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
||||
|
||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||
setMapSelectedCityName(cityName);
|
||||
|
||||
@@ -101,6 +101,10 @@ function getInitialProAccessState(): ProAccessState {
|
||||
if (isBrowserLocalFullAccess()) {
|
||||
return getLocalDevProAccessState();
|
||||
}
|
||||
const cached = readStoredProAccess();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return {
|
||||
loading: true,
|
||||
authenticated: false,
|
||||
@@ -116,13 +120,124 @@ function getInitialProAccessState(): ProAccessState {
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -247,6 +362,52 @@ function hasMeaningfulDailyModelMap(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
return countForecastDays({ forecast: incomingValue } as CityDetail) >=
|
||||
countForecastDays({ forecast: currentValue } as CityDetail)
|
||||
? incomingValue || currentValue
|
||||
: currentValue;
|
||||
}
|
||||
|
||||
function pickPreferredNearbyStations(
|
||||
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||
@@ -264,12 +425,17 @@ function mergeCityDetail(
|
||||
incoming: CityDetail,
|
||||
): CityDetail {
|
||||
if (!current) return incoming;
|
||||
if (incoming.detail_depth !== "market") return incoming;
|
||||
|
||||
const currentDepth = normalizeDetailDepth(current);
|
||||
const incomingDepth = normalizeDetailDepth(incoming);
|
||||
const mergedDepth =
|
||||
current.detail_depth === "full" || current.detail_depth === "nearby"
|
||||
? current.detail_depth
|
||||
: incoming.detail_depth;
|
||||
currentDepth === "full" || incomingDepth === "full"
|
||||
? "full"
|
||||
: currentDepth === "nearby" || incomingDepth === "nearby"
|
||||
? "nearby"
|
||||
: currentDepth === "market" || incomingDepth === "market"
|
||||
? "market"
|
||||
: "panel";
|
||||
|
||||
return {
|
||||
...current,
|
||||
@@ -280,16 +446,12 @@ function mergeCityDetail(
|
||||
deb: incoming.deb || current.deb,
|
||||
probabilities: incoming.probabilities || current.probabilities,
|
||||
trend: incoming.trend || current.trend,
|
||||
multi_model: hasMeaningfulModelMap(incoming.multi_model)
|
||||
? incoming.multi_model
|
||||
: current.multi_model,
|
||||
multi_model_daily: hasMeaningfulDailyModelMap(incoming.multi_model_daily)
|
||||
? {
|
||||
...(current.multi_model_daily || {}),
|
||||
...(incoming.multi_model_daily || {}),
|
||||
}
|
||||
: current.multi_model_daily,
|
||||
forecast: current.forecast || incoming.forecast,
|
||||
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,
|
||||
@@ -641,12 +803,14 @@ export function DashboardStoreProvider({
|
||||
|
||||
const refreshProAccess = async () => {
|
||||
if (isBrowserLocalFullAccess()) {
|
||||
setProAccess(getLocalDevProAccessState());
|
||||
const localAccess = getLocalDevProAccessState();
|
||||
writeStoredProAccess(localAccess);
|
||||
setProAccess(localAccess);
|
||||
return;
|
||||
}
|
||||
setProAccess((current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
loading: current.subscriptionActive ? false : true,
|
||||
error: null,
|
||||
}));
|
||||
try {
|
||||
@@ -667,8 +831,10 @@ export function DashboardStoreProvider({
|
||||
subscription_total_expires_at?: string | null;
|
||||
subscription_queued_days?: number | null;
|
||||
points?: number;
|
||||
degraded_auth_profile?: boolean | null;
|
||||
degraded_reason?: string | null;
|
||||
};
|
||||
setProAccess({
|
||||
const nextAccess: ProAccessState = {
|
||||
loading: false,
|
||||
authenticated: Boolean(payload.authenticated),
|
||||
userId: payload.user_id ?? null,
|
||||
@@ -683,8 +849,30 @@ export function DashboardStoreProvider({
|
||||
),
|
||||
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,
|
||||
@@ -727,6 +915,34 @@ export function DashboardStoreProvider({
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user