diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 95c16736..f433455a 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -7,6 +7,7 @@ import { mergeHourlyWithLiveObservations, mergePatchIntoHourly, mergeRowObservationIntoHourly, + readCachedHourlyForInitialRow, rememberHourlyDetailSnapshot, selectInitialHourlyForRowChange, seedHourlyForecastFromRow, @@ -223,6 +224,28 @@ export function runTests() { "chart should use the current scan row local date when cached detail localDate is older so today's model curve is drawn through 23:00", ); + _hourlyCache.clear(); + _hourlyCache.set("madrid:10m", { + ts: Date.now(), + data: { + localDate: "2026-06-16", + localTime: "15:46", + times: [], + temps: [], + airportPrimary: { + temp: 28.8, + obs_time: "2026-06-16T13:46:00Z", + }, + airportPrimaryTodayObs: [["2026-06-16T13:46:00Z", 28.8]], + } as any, + }); + const observationOnlyInitialCache = readCachedHourlyForInitialRow("madrid", "10m"); + assert( + observationOnlyInitialCache === null, + "observation-only hourly cache entries must not block loading the full city detail payload", + ); + _hourlyCache.clear(); + const correctedDetailChart = getTemperatureChartData( { name: "shanghai", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index aace1e50..ca37d0a6 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -441,9 +441,13 @@ function isRetainedHourlyCacheEntry( return Number.isFinite(age) && age >= 0 && age < maxAgeMs; } +function isUsableHourlyDetailCacheEntry(entry: HourlyCacheEntry | null | undefined) { + return Boolean(entry?.data && hasFullHourlyDetailPayload(entry.data)); +} + function pruneHourlyCache() { for (const [key, entry] of _hourlyCache.entries()) { - if (!isRetainedHourlyCacheEntry(entry, HOURLY_CACHE_STALE_TTL_MS)) { + if (!isUsableHourlyDetailCacheEntry(entry) || !isRetainedHourlyCacheEntry(entry, HOURLY_CACHE_STALE_TTL_MS)) { _hourlyCache.delete(key); } } @@ -459,7 +463,7 @@ function pruneHourlyCache() { } function rememberMemoryHourlyCacheEntry(cacheKey: string, entry: HourlyCacheEntry) { - if (!cacheKey || !entry?.data) return; + if (!cacheKey || !isUsableHourlyDetailCacheEntry(entry)) return; _hourlyCache.set(cacheKey, entry); pruneHourlyCache(); } @@ -476,7 +480,11 @@ function readSessionCache( const maxAgeMs = options.allowStale ? HOURLY_CACHE_STALE_TTL_MS : options.maxAgeMs ?? SESSION_CACHE_TTL_MS; - if (item && item.ts && isRetainedHourlyCacheEntry(item, maxAgeMs)) { + if (!item || !item.ts || !isUsableHourlyDetailCacheEntry(item) || !isRetainedHourlyCacheEntry(item, HOURLY_CACHE_STALE_TTL_MS)) { + sessionStorage.removeItem(`${SESSION_CACHE_PREFIX}${city}`); + return null; + } + if (isRetainedHourlyCacheEntry(item, maxAgeMs)) { return item; } } catch {} @@ -488,10 +496,14 @@ function readHourlyCacheEntry( options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyCacheEntry | null { const cached = _hourlyCache.get(cacheKey); - if (cached && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { + if ( + cached && + isUsableHourlyDetailCacheEntry(cached) && + (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) + ) { return cached; } - if (cached && !isRetainedHourlyCacheEntry(cached)) { + if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { _hourlyCache.delete(cacheKey); } @@ -509,10 +521,14 @@ function readHourlyCacheSnapshot( options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyDetailSnapshotEntry | null { const cached = _hourlyCache.get(cacheKey); - if (cached && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))) { + if ( + cached && + isUsableHourlyDetailCacheEntry(cached) && + (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) + ) { return { ...cached, source: "memory_cache" }; } - if (cached && !isRetainedHourlyCacheEntry(cached)) { + if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { _hourlyCache.delete(cacheKey); }