收敛终端图表缓存状态
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user