收敛终端图表缓存状态

This commit is contained in:
2569718930@qq.com
2026-06-16 04:57:26 +08:00
parent fbea6df175
commit 3bc8e73ab6
4 changed files with 219 additions and 83 deletions
@@ -15,7 +15,6 @@ import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import { import {
HOURLY_CACHE_TTL_MS, HOURLY_CACHE_TTL_MS,
_hourlyCache,
buildChartDomain, buildChartDomain,
buildFullDayChartData, buildFullDayChartData,
buildIntDegreeTicks, buildIntDegreeTicks,
@@ -33,8 +32,10 @@ import {
mergeRowObservationIntoHourly, mergeRowObservationIntoHourly,
normObs, normObs,
prefersHighFrequencyRunwayResolution, prefersHighFrequencyRunwayResolution,
readCachedHourlyForInitialRow,
readCityDetailBatchDiagnostics, readCityDetailBatchDiagnostics,
readSessionCache, readHourlyDetailSnapshot,
readHourlyDetailSnapshotAgeMs,
rememberHourlyDetailSnapshot, rememberHourlyDetailSnapshot,
selectCompactSecondaryTemp, selectCompactSecondaryTemp,
selectDisplayRunwayTemp, selectDisplayRunwayTemp,
@@ -530,30 +531,6 @@ function getInitialDetailLoadDelayMs({
return DEFERRED_DETAIL_LOAD_DELAY_MS + deferredWave * DEFERRED_DETAIL_LOAD_WAVE_STEP_MS; 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 = { type HourlyDetailFetchOptions = {
ignoreCache?: boolean; ignoreCache?: boolean;
bypassLocalCache?: boolean; bypassLocalCache?: boolean;
@@ -719,9 +696,10 @@ export function LiveTemperatureThresholdChart({
})); }));
}, []); }, []);
const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => { const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => {
if (options?.showUserError) { const detailErrorMessage = options?.showUserError
setDetailError(isEn ? "Detail temporarily unavailable." : "详情暂不可用"); ? (isEn ? "Detail temporarily unavailable." : "详情暂不可用")
} : null;
setDetailError(detailErrorMessage);
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
...prev, ...prev,
detailErrorAtMs: Date.now(), detailErrorAtMs: Date.now(),
@@ -811,6 +789,14 @@ export function LiveTemperatureThresholdChart({
return () => clearInterval(id); return () => clearInterval(id);
}, [row?.tz_offset_seconds]); }, [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 }) => { const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
if (!data) return; if (!data) return;
const loadedAtMs = Date.now(); const loadedAtMs = Date.now();
@@ -822,9 +808,8 @@ export function LiveTemperatureThresholdChart({
const temp = getLiveTempFromHourly(dataWithCurrentRow); const temp = getLiveTempFromHourly(dataWithCurrentRow);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
} }
setHourly((prev) => { commitHourlySnapshot((prev) => {
const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow); const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow);
rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly);
return mergedHourly; return mergedHourly;
}); });
setDetailError(null); setDetailError(null);
@@ -839,7 +824,7 @@ export function LiveTemperatureThresholdChart({
? "network" ? "network"
: prev.detailSource, : prev.detailSource,
})); }));
}, [city, targetResolution, getLatestRowSnapshot]); }, [commitHourlySnapshot, getLatestRowSnapshot]);
const runHourlyDetailFetch = useHourlyDetailFetcher({ const runHourlyDetailFetch = useHourlyDetailFetcher({
city, city,
@@ -857,17 +842,13 @@ export function LiveTemperatureThresholdChart({
const rowSeed = seedHourlyForecastFromRow(row); const rowSeed = seedHourlyForecastFromRow(row);
const temp = getLiveTempFromHourly(rowSeed); const temp = getLiveTempFromHourly(rowSeed);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
setHourly((prev) => { commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row));
const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row);
rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly);
return mergedHourly;
});
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
...prev, ...prev,
rowAppliedAtMs: now, rowAppliedAtMs: now,
rowObservationTime: rowObservationTimeForFreshness(row), rowObservationTime: rowObservationTimeForFreshness(row),
})); }));
}, [city, currentRowObservationSignature, row, targetResolution]); }, [city, currentRowObservationSignature, row, commitHourlySnapshot]);
useEffect(() => { useEffect(() => {
if (!city) { if (!city) {
@@ -875,17 +856,7 @@ export function LiveTemperatureThresholdChart({
return; return;
} }
const cacheKey = `${city}:${targetResolution}`; const cached = readHourlyDetailSnapshot(city, targetResolution, { allowStale: true });
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 cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY; const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS; const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
@@ -897,7 +868,7 @@ export function LiveTemperatureThresholdChart({
...prev, ...prev,
detailResolvedAtMs: Number(cached.ts || Date.now()), detailResolvedAtMs: Number(cached.ts || Date.now()),
detailStatus: hasFreshCache ? "fresh" : "stale_cache", detailStatus: hasFreshCache ? "fresh" : "stale_cache",
detailSource: cachedSource, detailSource: cached.source,
})); }));
} }
@@ -925,7 +896,7 @@ export function LiveTemperatureThresholdChart({
} }
if (!cached && !hasLoadedHourlyDetailRef.current) { if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot())); commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, getLatestRowSnapshot()));
setShowingStaleDetail(false); setShowingStaleDetail(false);
} }
setIsHourlyLoading(true); setIsHourlyLoading(true);
@@ -976,6 +947,7 @@ export function LiveTemperatureThresholdChart({
detailLoadReady, detailLoadReady,
detailRetryNonce, detailRetryNonce,
getLatestRowSnapshot, getLatestRowSnapshot,
commitHourlySnapshot,
markDetailDegraded, markDetailDegraded,
runHourlyDetailFetch, runHourlyDetailFetch,
]); ]);
@@ -987,9 +959,8 @@ export function LiveTemperatureThresholdChart({
lastPatchAtRef.current = patchAppliedAtMs; lastPatchAtRef.current = patchAppliedAtMs;
const tempValue = validNumber(latestPatch.changes.temp); const tempValue = validNumber(latestPatch.changes.temp);
if (tempValue !== null) setLiveTemp(tempValue); if (tempValue !== null) setLiveTemp(tempValue);
setHourly((prev) => { commitHourlySnapshot((prev) => {
const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch); const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);
rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly);
return mergedHourly; return mergedHourly;
}); });
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
@@ -1026,7 +997,7 @@ export function LiveTemperatureThresholdChart({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, runHourlyDetailFetch]); }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, commitHourlySnapshot, runHourlyDetailFetch]);
useEffect(() => { useEffect(() => {
if (!resyncVersion || !city) return; if (!resyncVersion || !city) return;
@@ -1119,9 +1090,7 @@ export function LiveTemperatureThresholdChart({
const refreshForegroundFullDetail = () => { const refreshForegroundFullDetail = () => {
const now = Date.now(); const now = Date.now();
const cacheKey = `${city}:${targetResolution}`; const cacheAge = readHourlyDetailSnapshotAgeMs(city, targetResolution);
const cached = _hourlyCache.get(cacheKey);
const cacheAge = cached ? now - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
if ( if (
now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS || now - lastForegroundRefreshAtRef.current < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS ||
(cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS) (cacheAge >= 0 && cacheAge < FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS)
@@ -149,6 +149,13 @@ export async function runTests() {
chartLogicSource.includes("recentlyRefreshed.data"), chartLogicSource.includes("recentlyRefreshed.data"),
"forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly", "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( assert(
chartLogicSource.includes("forceRefresh: boolean") && chartLogicSource.includes("forceRefresh: boolean") &&
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'), chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
@@ -254,8 +261,9 @@ export async function runTests() {
assert( assert(
chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedHourlyForecastFromRow") && chartLogicSource.includes("seedHourlyForecastFromRow") &&
chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))"), !chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))") &&
"terminal charts should render from row data immediately and dedupe concurrent city detail requests", 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( assert(
chartLogicSource.includes("/api/cities/detail-batch") && chartLogicSource.includes("/api/cities/detail-batch") &&
@@ -343,21 +351,41 @@ export async function runTests() {
chartSource.includes("latestRowRef.current = row"), chartSource.includes("latestRowRef.current = row"),
"temperature chart should keep the latest row in a ref so live row changes do not restart detail fetches", "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 successfulHourlyDetailBlock =
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || "";
assert( assert(
successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setDetailError(null)") &&
successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") &&
successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && successfulHourlyDetailBlock.includes("getLatestRowSnapshot") &&
successfulHourlyDetailBlock.includes("rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly)") && successfulHourlyDetailBlock.includes("commitHourlySnapshot") &&
!/\[row\]/.test(successfulHourlyDetailBlock), !/\[row\]/.test(successfulHourlyDetailBlock),
"successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object", "successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object",
); );
assert( assert(
chartSource.includes("rememberHourlyDetailSnapshot") && chartSource.includes("commitHourlySnapshot") &&
chartSource.includes("const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row);") && chartSource.includes("mergeRowObservationIntoHourly") &&
chartSource.includes("const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);"), chartSource.includes("mergePatchIntoHourly"),
"live row and SSE patch merges must persist their hourly snapshots so returning to terminal restores runway history immediately", "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 = const coldDetailFetchBlock =
/useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || "";
@@ -834,4 +834,32 @@ export function runTests() {
(restoredChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2, (restoredChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2,
"instant-restore cache must include live-merged runway history so returning to terminal shows it immediately", "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",
);
} }
@@ -360,6 +360,9 @@ type LocalDayBounds = { start: number; end: number };
const MAX_OBS_POINTS = 1440; const MAX_OBS_POINTS = 1440;
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; 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 HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>(); const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>(); const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
@@ -370,9 +373,10 @@ const _hourlyDetailRequestQueue: Array<() => void> = [];
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; 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 HourlyCacheEntry = { ts: number; data: HourlyForecast };
type HourlyDetailSnapshotSource = "memory_cache" | "session_cache";
type HourlyDetailSnapshotEntry = HourlyCacheEntry & { source: HourlyDetailSnapshotSource };
type CityDetailBatchDiagnostics = Record<string, any>; type CityDetailBatchDiagnostics = Record<string, any>;
type CityDetailBatchDiagnosticsEntry = { ts: number; data: CityDetailBatchDiagnostics }; type CityDetailBatchDiagnosticsEntry = { ts: number; data: CityDetailBatchDiagnostics };
@@ -411,11 +415,53 @@ function readCityDetailBatchDiagnostics(
return entry.data; 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( function isFreshHourlyCacheEntry(
entry: HourlyCacheEntry | null | undefined, 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( function readSessionCache(
@@ -427,11 +473,10 @@ function readSessionCache(
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null; if (!raw) return null;
const item = JSON.parse(raw); const item = JSON.parse(raw);
if ( const maxAgeMs = options.allowStale
item && ? HOURLY_CACHE_STALE_TTL_MS
item.ts && : options.maxAgeMs ?? SESSION_CACHE_TTL_MS;
(options.allowStale || Date.now() - item.ts < (options.maxAgeMs ?? SESSION_CACHE_TTL_MS)) if (item && item.ts && isRetainedHourlyCacheEntry(item, maxAgeMs)) {
) {
return item; return item;
} }
} catch {} } catch {}
@@ -443,29 +488,94 @@ function readHourlyCacheEntry(
options: { allowStale?: boolean; maxAgeMs?: number } = {}, options: { allowStale?: boolean; maxAgeMs?: number } = {},
): HourlyCacheEntry | null { ): HourlyCacheEntry | null {
const cached = _hourlyCache.get(cacheKey); 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; return cached;
} }
if (cached && !isRetainedHourlyCacheEntry(cached)) {
_hourlyCache.delete(cacheKey);
}
const sessionEntry = readSessionCache(cacheKey, options); const sessionEntry = readSessionCache(cacheKey, options);
if (sessionEntry) { if (sessionEntry) {
_hourlyCache.set(cacheKey, sessionEntry); rememberMemoryHourlyCacheEntry(cacheKey, sessionEntry);
return sessionEntry; return sessionEntry;
} }
return null; 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; if (typeof window === "undefined" || !data) return;
try { try {
sessionStorage.setItem( sessionStorage.setItem(
`${SESSION_CACHE_PREFIX}${city}`, `${SESSION_CACHE_PREFIX}${city}`,
JSON.stringify({ ts: Date.now(), data }) JSON.stringify({ ts, data })
); );
} catch {} } 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( function rememberHourlyDetailSnapshot(
city: string, city: string,
resolution: string, resolution: string,
@@ -473,9 +583,8 @@ function rememberHourlyDetailSnapshot(
) { ) {
const cityKey = normalizeCityKey(city); const cityKey = normalizeCityKey(city);
if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return; if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return;
const cacheKey = `${cityKey}:${resolution || "10m"}`; const cacheKey = hourlyCacheKey(cityKey, resolution);
_hourlyCache.set(cacheKey, { ts: Date.now(), data }); writeHourlyCacheEntry(cacheKey, data);
writeSessionCache(cacheKey, data);
} }
function drainHourlyDetailRequestQueue() { function drainHourlyDetailRequestQueue() {
@@ -1627,10 +1736,9 @@ function primeCityDetailCache(
): HourlyForecast { ): HourlyForecast {
let data = parseHourlyForecastFromCityDetail(detail || null); let data = parseHourlyForecastFromCityDetail(detail || null);
if (!data) return null; if (!data) return null;
const cacheKey = `${city}:${resolution}`; const cacheKey = hourlyCacheKey(city, resolution);
data = preserveCachedRunwayHistory(cacheKey, data); data = preserveCachedRunwayHistory(cacheKey, data);
_hourlyCache.set(cacheKey, { ts: Date.now(), data }); writeHourlyCacheEntry(cacheKey, data);
writeSessionCache(cacheKey, data);
return data; return data;
} }
@@ -1792,7 +1900,7 @@ async function fetchHourlyForecastForCity(
options: HourlyForecastFetchOptions = {}, options: HourlyForecastFetchOptions = {},
): Promise<HourlyForecast> { ): Promise<HourlyForecast> {
const resParam = options.resolution || "10m"; const resParam = options.resolution || "10m";
const cacheKey = `${city}:${resParam}`; const cacheKey = hourlyCacheKey(city, resParam);
const forceRefresh = Boolean(options.ignoreCache); const forceRefresh = Boolean(options.ignoreCache);
const bypassLocalCache = forceRefresh || Boolean(options.bypassLocalCache); const bypassLocalCache = forceRefresh || Boolean(options.bypassLocalCache);
@@ -3127,7 +3235,10 @@ export {
normObs, normObs,
normalizeCityKey, normalizeCityKey,
prefersHighFrequencyRunwayResolution, prefersHighFrequencyRunwayResolution,
readCachedHourlyForInitialRow,
readCityDetailBatchDiagnostics, readCityDetailBatchDiagnostics,
readHourlyDetailSnapshot,
readHourlyDetailSnapshotAgeMs,
readSessionCache, readSessionCache,
rememberHourlyDetailSnapshot, rememberHourlyDetailSnapshot,
selectCompactSecondaryTemp, selectCompactSecondaryTemp,