diff --git a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts index 8a074cb2..bdad23f0 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts @@ -20,6 +20,7 @@ export function runTests() { const cached = buildCityDetailProxyCachePolicy("false", 15); assert.equal(cached.fetchMode, "revalidate"); assert.equal(cached.revalidateSeconds, 15); + assert.match(cached.responseCacheControl, /max-age=15/); assert.match(cached.responseCacheControl, /s-maxage=15/); const scanForced = buildForceRefreshProxyCachePolicy("true", 10); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index a331a2ce..dbb15f83 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -76,6 +76,12 @@ export async function runTests() { chartSource.includes("setHourly(data)"), "visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop", ); + assert( + chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") && + chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") && + chartLogicSource.includes("recentlyRefreshed.data"), + "forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly", + ); assert( __shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true, "compact grid slots are visible charts and should run the no-patch fallback guard", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 26547366..a22d4df2 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -353,6 +353,7 @@ type LocalDayBounds = { start: number; end: number }; const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; +const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000; const _hourlyCache = new Map(); const _hourlyRequestCache = new Map>(); const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; @@ -366,13 +367,16 @@ const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; type HourlyCacheEntry = { ts: number; data: HourlyForecast }; -function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) { - return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS); +function isFreshHourlyCacheEntry( + entry: HourlyCacheEntry | null | undefined, + maxAgeMs = SESSION_CACHE_TTL_MS, +) { + return Boolean(entry && Date.now() - Number(entry.ts || 0) < maxAgeMs); } function readSessionCache( city: string, - options: { allowStale?: boolean } = {}, + options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyCacheEntry | null { if (typeof window === "undefined") return null; try { @@ -382,7 +386,7 @@ function readSessionCache( if ( item && item.ts && - (options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS) + (options.allowStale || Date.now() - item.ts < (options.maxAgeMs ?? SESSION_CACHE_TTL_MS)) ) { return item; } @@ -392,10 +396,10 @@ function readSessionCache( function readHourlyCacheEntry( cacheKey: string, - options: { allowStale?: boolean } = {}, + options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyCacheEntry | null { const cached = _hourlyCache.get(cacheKey); - if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) { + if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { return cached; } @@ -1193,6 +1197,13 @@ async function fetchHourlyForecastForCity( if (cached) { return cached.data; } + } else { + const recentlyRefreshed = readHourlyCacheEntry(cacheKey, { + maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS, + }); + if (recentlyRefreshed) { + return recentlyRefreshed.data; + } } const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; @@ -2459,6 +2470,7 @@ export { MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, HOURLY_DETAIL_REQUEST_TIMEOUT_MS, HOURLY_CACHE_TTL_MS, + HOURLY_FORCE_REFRESH_DEDUP_MS, _hourlyCache, __readHourlyCacheEntryForTest, resolveCityDetailFromBatch as __resolveCityDetailFromBatchForTest, diff --git a/frontend/lib/proxy-cache-policy.ts b/frontend/lib/proxy-cache-policy.ts index c3f91185..2c9c5807 100644 --- a/frontend/lib/proxy-cache-policy.ts +++ b/frontend/lib/proxy-cache-policy.ts @@ -28,4 +28,22 @@ export function buildForceRefreshProxyCachePolicy( }; } -export const buildCityDetailProxyCachePolicy = buildForceRefreshProxyCachePolicy; +export function buildCityDetailProxyCachePolicy( + forceRefresh: string | null | undefined, + revalidateSeconds = 15, +): ProxyCachePolicy { + if (isForceRefreshValue(forceRefresh)) { + return { + fetchMode: "no-store", + responseCacheControl: "no-store, max-age=0", + }; + } + return { + fetchMode: "revalidate", + responseCacheControl: `public, max-age=${revalidateSeconds}, s-maxage=${revalidateSeconds}, stale-while-revalidate=${Math.max( + revalidateSeconds * 3, + 30, + )}`, + revalidateSeconds, + }; +}