From 3bc8e73ab669bb8eda1f7b666ca43102f18a301e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 04:57:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=BB=88=E7=AB=AF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E7=BC=93=E5=AD=98=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTemperatureThresholdChart.tsx | 83 +++------- .../__tests__/refreshCadencePolicy.test.ts | 42 ++++- .../__tests__/temperatureChartData.test.ts | 28 ++++ .../scan-terminal/temperature-chart-logic.ts | 149 +++++++++++++++--- 4 files changed, 219 insertions(+), 83 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index e1109106..e8687119 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -15,7 +15,6 @@ import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; import { HOURLY_CACHE_TTL_MS, - _hourlyCache, buildChartDomain, buildFullDayChartData, buildIntDegreeTicks, @@ -33,8 +32,10 @@ import { mergeRowObservationIntoHourly, normObs, prefersHighFrequencyRunwayResolution, + readCachedHourlyForInitialRow, readCityDetailBatchDiagnostics, - readSessionCache, + readHourlyDetailSnapshot, + readHourlyDetailSnapshotAgeMs, rememberHourlyDetailSnapshot, selectCompactSecondaryTemp, selectDisplayRunwayTemp, @@ -530,30 +531,6 @@ function getInitialDetailLoadDelayMs({ return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS; } -function readCachedHourlyForInitialRow( - city: string, - preferredResolution: string, -): HourlyForecast { - const cityKey = String(city || "").toLowerCase().trim(); - if (!cityKey) return null; - const resolutions = [ - preferredResolution, - preferredResolution === "1m" ? "10m" : "1m", - ].filter((value, index, list) => value && list.indexOf(value) === index); - - for (const resolution of resolutions) { - const cacheKey = `${cityKey}:${resolution}`; - const memoryEntry = _hourlyCache.get(cacheKey); - if (memoryEntry?.data) return memoryEntry.data; - const sessionEntry = readSessionCache(cacheKey, { allowStale: true }); - if (sessionEntry?.data) { - _hourlyCache.set(cacheKey, sessionEntry); - return sessionEntry.data; - } - } - return null; -} - type HourlyDetailFetchOptions = { ignoreCache?: boolean; bypassLocalCache?: boolean; @@ -719,9 +696,10 @@ export function LiveTemperatureThresholdChart({ })); }, []); const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => { - if (options?.showUserError) { - setDetailError(isEn ? "Detail temporarily unavailable." : "详情暂不可用"); - } + const detailErrorMessage = options?.showUserError + ? (isEn ? "Detail temporarily unavailable." : "详情暂不可用") + : null; + setDetailError(detailErrorMessage); setChartFreshness((prev) => ({ ...prev, detailErrorAtMs: Date.now(), @@ -811,6 +789,14 @@ export function LiveTemperatureThresholdChart({ return () => clearInterval(id); }, [row?.tz_offset_seconds]); + const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => { + setHourly((prev) => { + const next = buildNext(prev); + rememberHourlyDetailSnapshot(city, targetResolution, next); + return next; + }); + }, [city, targetResolution]); + const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { if (!data) return; const loadedAtMs = Date.now(); @@ -822,9 +808,8 @@ export function LiveTemperatureThresholdChart({ const temp = getLiveTempFromHourly(dataWithCurrentRow); if (temp !== null) setLiveTemp(temp); } - setHourly((prev) => { + commitHourlySnapshot((prev) => { const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow); - rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); return mergedHourly; }); setDetailError(null); @@ -839,7 +824,7 @@ export function LiveTemperatureThresholdChart({ ? "network" : prev.detailSource, })); - }, [city, targetResolution, getLatestRowSnapshot]); + }, [commitHourlySnapshot, getLatestRowSnapshot]); const runHourlyDetailFetch = useHourlyDetailFetcher({ city, @@ -857,17 +842,13 @@ export function LiveTemperatureThresholdChart({ const rowSeed = seedHourlyForecastFromRow(row); const temp = getLiveTempFromHourly(rowSeed); if (temp !== null) setLiveTemp(temp); - setHourly((prev) => { - const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row); - rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); - return mergedHourly; - }); + commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row)); setChartFreshness((prev) => ({ ...prev, rowAppliedAtMs: now, rowObservationTime: rowObservationTimeForFreshness(row), })); - }, [city, currentRowObservationSignature, row, targetResolution]); + }, [city, currentRowObservationSignature, row, commitHourlySnapshot]); useEffect(() => { if (!city) { @@ -875,17 +856,7 @@ export function LiveTemperatureThresholdChart({ return; } - const cacheKey = `${city}:${targetResolution}`; - let cached = _hourlyCache.get(cacheKey); - let cachedSource: ChartDetailSource = cached ? "memory_cache" : "none"; - if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) { - const sessionEntry = readSessionCache(cacheKey, { allowStale: true }); - if (sessionEntry) { - cached = sessionEntry; - cachedSource = "session_cache"; - _hourlyCache.set(cacheKey, sessionEntry); - } - } + const cached = readHourlyDetailSnapshot(city, targetResolution, { allowStale: true }); const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY; const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS; @@ -897,7 +868,7 @@ export function LiveTemperatureThresholdChart({ ...prev, detailResolvedAtMs: Number(cached.ts || Date.now()), detailStatus: hasFreshCache ? "fresh" : "stale_cache", - detailSource: cachedSource, + detailSource: cached.source, })); } @@ -925,7 +896,7 @@ export function LiveTemperatureThresholdChart({ } if (!cached && !hasLoadedHourlyDetailRef.current) { - setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot())); + commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot())); setShowingStaleDetail(false); } setIsHourlyLoading(true); @@ -976,6 +947,7 @@ export function LiveTemperatureThresholdChart({ detailLoadReady, detailRetryNonce, getLatestRowSnapshot, + commitHourlySnapshot, markDetailDegraded, runHourlyDetailFetch, ]); @@ -987,9 +959,8 @@ export function LiveTemperatureThresholdChart({ lastPatchAtRef.current = patchAppliedAtMs; const tempValue = validNumber(latestPatch.changes.temp); if (tempValue !== null) setLiveTemp(tempValue); - setHourly((prev) => { + commitHourlySnapshot((prev) => { const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch); - rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly); return mergedHourly; }); setChartFreshness((prev) => ({ @@ -1026,7 +997,7 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; - }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, runHourlyDetailFetch]); + }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, commitHourlySnapshot, runHourlyDetailFetch]); useEffect(() => { if (!resyncVersion || !city) return; @@ -1119,9 +1090,7 @@ export function LiveTemperatureThresholdChart({ const refreshForegroundFullDetail = () => { const now = Date.now(); - const cacheKey = `${city}:${targetResolution}`; - const cached = _hourlyCache.get(cacheKey); - const cacheAge = cached ? now - Number(cached.ts || 0) : Number.POSITIVE_INFINITY; + const cacheAge = readHourlyDetailSnapshotAgeMs(city, targetResolution); if ( now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS || (cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 6d419ca9..c1395cd9 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -149,6 +149,13 @@ export async function runTests() { chartLogicSource.includes("recentlyRefreshed.data"), "forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly", ); + assert( + chartLogicSource.includes("const SESSION_CACHE_TTL_MS = HOURLY_CACHE_TTL_MS") && + chartLogicSource.includes("MAX_HOURLY_CACHE_ENTRIES") && + chartLogicSource.includes("pruneHourlyCache") && + chartLogicSource.includes("writeHourlyCacheEntry"), + "hourly detail cache should use one TTL policy and bounded memory writes instead of separate ad-hoc memory/session lifetimes", + ); assert( chartLogicSource.includes("forceRefresh: boolean") && chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'), @@ -254,8 +261,9 @@ export async function runTests() { assert( chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("seedHourlyForecastFromRow") && - chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))"), - "terminal charts should render from row data immediately and dedupe concurrent city detail requests", + !chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))") && + chartSource.includes("mergeRowObservationIntoHourly"), + "terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches", ); assert( chartLogicSource.includes("/api/cities/detail-batch") && @@ -343,21 +351,41 @@ export async function runTests() { chartSource.includes("latestRowRef.current = row"), "temperature chart should keep the latest row in a ref so live row changes do not restart detail fetches", ); + assert( + !chartSource.includes("_hourlyCache") && + !chartSource.includes("readSessionCache") && + chartSource.includes("readCachedHourlyForInitialRow") && + chartSource.includes("readHourlyDetailSnapshot") && + chartSource.includes("detailSource: cached.source"), + "temperature chart component should not directly read global memory/session caches; cache policy belongs in temperature-chart-logic.ts", + ); + const rememberSnapshotCalls = chartSource.match(/rememberHourlyDetailSnapshot\(/g) || []; + assert( + rememberSnapshotCalls.length === 1, + "temperature chart should persist hourly snapshots through one local commit helper instead of writing cache snapshots from multiple effects", + ); + const markDetailDegradedBlock = + /const markDetailDegraded = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; + assert( + markDetailDegradedBlock.includes("const detailErrorMessage") && + markDetailDegradedBlock.includes("setDetailError(detailErrorMessage)"), + "detail degraded state and visible detail error text should be updated from the same branch so they cannot drift", + ); const successfulHourlyDetailBlock = /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; assert( successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && - successfulHourlyDetailBlock.includes("rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly)") && + successfulHourlyDetailBlock.includes("commitHourlySnapshot") && !/\[row\]/.test(successfulHourlyDetailBlock), "successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object", ); assert( - chartSource.includes("rememberHourlyDetailSnapshot") && - chartSource.includes("const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row);") && - chartSource.includes("const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);"), - "live row and SSE patch merges must persist their hourly snapshots so returning to terminal restores runway history immediately", + chartSource.includes("commitHourlySnapshot") && + chartSource.includes("mergeRowObservationIntoHourly") && + chartSource.includes("mergePatchIntoHourly"), + "live row and SSE patch merges must persist their hourly snapshots through the shared commit helper so returning to terminal restores runway history immediately", ); const coldDetailFetchBlock = /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index d5c7f260..013e7b29 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -834,4 +834,32 @@ export function runTests() { (restoredChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2, "instant-restore cache must include live-merged runway history so returning to terminal shows it immediately", ); + + _hourlyCache.clear(); + for (let i = 0; i < 180; i += 1) { + const row = { + city: `cache-city-${i}`, + local_date: "2026-06-15", + local_time: "2026-06-15T09:00:00Z", + current_temp: 20 + i / 100, + current_max_so_far: 20 + i / 100, + temp_symbol: "°C", + tz_offset_seconds: 0, + } as any; + rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", { + ...seedHourlyForecastFromRow(row), + times: ["09:00"], + temps: [20 + i / 100], + modelTimes: ["09:00"], + modelCurves: { ECMWF: [20 + i / 100] }, + } as any); + } + assert( + _hourlyCache.size <= 160, + "hourly detail memory cache must be bounded so many mounted chart instances cannot leak entries indefinitely", + ); + assert( + !_hourlyCache.has("cachecity0:10m") && _hourlyCache.has("cachecity179:10m"), + "hourly detail memory cache should evict oldest entries first while retaining the newest chart detail", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 984433d7..f62f3d88 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -360,6 +360,9 @@ type LocalDayBounds = { start: number; end: number }; const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; +const SESSION_CACHE_TTL_MS = HOURLY_CACHE_TTL_MS; +const HOURLY_CACHE_STALE_TTL_MS = 6 * HOURLY_CACHE_TTL_MS; +const MAX_HOURLY_CACHE_ENTRIES = 160; const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000; const _hourlyCache = new Map(); const _hourlyRequestCache = new Map>(); @@ -370,9 +373,10 @@ const _hourlyDetailRequestQueue: Array<() => void> = []; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; -const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; type HourlyCacheEntry = { ts: number; data: HourlyForecast }; +type HourlyDetailSnapshotSource = "memory_cache" | "session_cache"; +type HourlyDetailSnapshotEntry = HourlyCacheEntry & { source: HourlyDetailSnapshotSource }; type CityDetailBatchDiagnostics = Record; type CityDetailBatchDiagnosticsEntry = { ts: number; data: CityDetailBatchDiagnostics }; @@ -411,11 +415,53 @@ function readCityDetailBatchDiagnostics( return entry.data; } +function hourlyCacheKey(city: string, resolution: string) { + const cityKey = normalizeCityKey(city); + return cityKey ? `${cityKey}:${resolution || "10m"}` : ""; +} + +function hourlyCacheEntryAgeMs(entry: HourlyCacheEntry | null | undefined, now = Date.now()) { + if (!entry) return Number.POSITIVE_INFINITY; + return now - Number(entry.ts || 0); +} + function isFreshHourlyCacheEntry( entry: HourlyCacheEntry | null | undefined, - maxAgeMs = SESSION_CACHE_TTL_MS, + maxAgeMs = HOURLY_CACHE_TTL_MS, ) { - return Boolean(entry && Date.now() - Number(entry.ts || 0) < maxAgeMs); + const age = hourlyCacheEntryAgeMs(entry); + return Number.isFinite(age) && age >= 0 && age < maxAgeMs; +} + +function isRetainedHourlyCacheEntry( + entry: HourlyCacheEntry | null | undefined, + maxAgeMs = HOURLY_CACHE_STALE_TTL_MS, +) { + const age = hourlyCacheEntryAgeMs(entry); + return Number.isFinite(age) && age >= 0 && age < maxAgeMs; +} + +function pruneHourlyCache() { + for (const [key, entry] of _hourlyCache.entries()) { + if (!isRetainedHourlyCacheEntry(entry, HOURLY_CACHE_STALE_TTL_MS)) { + _hourlyCache.delete(key); + } + } + + if (_hourlyCache.size <= MAX_HOURLY_CACHE_ENTRIES) return; + + const oldestFirst = Array.from(_hourlyCache.entries()) + .sort((left, right) => Number(left[1].ts || 0) - Number(right[1].ts || 0)); + for (const [key] of oldestFirst) { + if (_hourlyCache.size <= MAX_HOURLY_CACHE_ENTRIES) break; + _hourlyCache.delete(key); + } +} + +function rememberMemoryHourlyCacheEntry(cacheKey: string, entry: HourlyCacheEntry) { + if (!cacheKey || !entry?.data) return; + _hourlyCache.set(cacheKey, entry); + pruneHourlyCache(); } function readSessionCache( @@ -427,11 +473,10 @@ function readSessionCache( const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); if (!raw) return null; const item = JSON.parse(raw); - if ( - item && - item.ts && - (options.allowStale || Date.now() - item.ts < (options.maxAgeMs ?? SESSION_CACHE_TTL_MS)) - ) { + const maxAgeMs = options.allowStale + ? HOURLY_CACHE_STALE_TTL_MS + : options.maxAgeMs ?? SESSION_CACHE_TTL_MS; + if (item && item.ts && isRetainedHourlyCacheEntry(item, maxAgeMs)) { return item; } } catch {} @@ -443,29 +488,94 @@ function readHourlyCacheEntry( options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyCacheEntry | null { const cached = _hourlyCache.get(cacheKey); - if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { + if (cached && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { return cached; } + if (cached && !isRetainedHourlyCacheEntry(cached)) { + _hourlyCache.delete(cacheKey); + } const sessionEntry = readSessionCache(cacheKey, options); if (sessionEntry) { - _hourlyCache.set(cacheKey, sessionEntry); + rememberMemoryHourlyCacheEntry(cacheKey, sessionEntry); return sessionEntry; } return null; } -function writeSessionCache(city: string, data: HourlyForecast) { +function readHourlyCacheSnapshot( + cacheKey: string, + options: { allowStale?: boolean; maxAgeMs?: number } = {}, +): HourlyDetailSnapshotEntry | null { + const cached = _hourlyCache.get(cacheKey); + if (cached && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { + return { ...cached, source: "memory_cache" }; + } + if (cached && !isRetainedHourlyCacheEntry(cached)) { + _hourlyCache.delete(cacheKey); + } + + const sessionEntry = readSessionCache(cacheKey, options); + if (sessionEntry) { + rememberMemoryHourlyCacheEntry(cacheKey, sessionEntry); + return { ...sessionEntry, source: "session_cache" }; + } + + return null; +} + +function writeSessionCache(city: string, data: HourlyForecast, ts = Date.now()) { if (typeof window === "undefined" || !data) return; try { sessionStorage.setItem( `${SESSION_CACHE_PREFIX}${city}`, - JSON.stringify({ ts: Date.now(), data }) + JSON.stringify({ ts, data }) ); } catch {} } +function writeHourlyCacheEntry(cacheKey: string, data: HourlyForecast, ts = Date.now()) { + if (!cacheKey || !data) return; + rememberMemoryHourlyCacheEntry(cacheKey, { ts, data }); + writeSessionCache(cacheKey, data, ts); +} + +function readHourlyDetailSnapshot( + city: string, + resolution: string, + options: { allowStale?: boolean; maxAgeMs?: number } = {}, +): HourlyDetailSnapshotEntry | null { + const cacheKey = hourlyCacheKey(city, resolution); + return cacheKey ? readHourlyCacheSnapshot(cacheKey, options) : null; +} + +function readHourlyDetailSnapshotAgeMs( + city: string, + resolution: string, +) { + const entry = readHourlyDetailSnapshot(city, resolution, { allowStale: true }); + return entry ? hourlyCacheEntryAgeMs(entry) : Number.POSITIVE_INFINITY; +} + +function readCachedHourlyForInitialRow( + city: string, + preferredResolution: string, +): HourlyForecast { + const cityKey = normalizeCityKey(city); + if (!cityKey) return null; + const resolutions = [ + preferredResolution, + preferredResolution === "1m" ? "10m" : "1m", + ].filter((value, index, list) => value && list.indexOf(value) === index); + + for (const resolution of resolutions) { + const entry = readHourlyDetailSnapshot(cityKey, resolution, { allowStale: true }); + if (entry?.data) return entry.data; + } + return null; +} + function rememberHourlyDetailSnapshot( city: string, resolution: string, @@ -473,9 +583,8 @@ function rememberHourlyDetailSnapshot( ) { const cityKey = normalizeCityKey(city); if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return; - const cacheKey = `${cityKey}:${resolution || "10m"}`; - _hourlyCache.set(cacheKey, { ts: Date.now(), data }); - writeSessionCache(cacheKey, data); + const cacheKey = hourlyCacheKey(cityKey, resolution); + writeHourlyCacheEntry(cacheKey, data); } function drainHourlyDetailRequestQueue() { @@ -1627,10 +1736,9 @@ function primeCityDetailCache( ): HourlyForecast { let data = parseHourlyForecastFromCityDetail(detail || null); if (!data) return null; - const cacheKey = `${city}:${resolution}`; + const cacheKey = hourlyCacheKey(city, resolution); data = preserveCachedRunwayHistory(cacheKey, data); - _hourlyCache.set(cacheKey, { ts: Date.now(), data }); - writeSessionCache(cacheKey, data); + writeHourlyCacheEntry(cacheKey, data); return data; } @@ -1792,7 +1900,7 @@ async function fetchHourlyForecastForCity( options: HourlyForecastFetchOptions = {}, ): Promise { const resParam = options.resolution || "10m"; - const cacheKey = `${city}:${resParam}`; + const cacheKey = hourlyCacheKey(city, resParam); const forceRefresh = Boolean(options.ignoreCache); const bypassLocalCache = forceRefresh || Boolean(options.bypassLocalCache); @@ -3127,7 +3235,10 @@ export { normObs, normalizeCityKey, prefersHighFrequencyRunwayResolution, + readCachedHourlyForInitialRow, readCityDetailBatchDiagnostics, + readHourlyDetailSnapshot, + readHourlyDetailSnapshotAgeMs, readSessionCache, rememberHourlyDetailSnapshot, selectCompactSecondaryTemp,