From 7625a9902027697acaaf4e746a3d276fb630e54a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 1 Jun 2026 06:09:44 +0800 Subject: [PATCH] Cache terminal city registry fallback --- .../dashboard/ScanTerminalDashboard.tsx | 13 ++++- .../__tests__/cityFallbackRows.test.ts | 23 ++++++++- .../scan-terminal/city-fallback-rows.ts | 51 +++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 47a5c790..38e3875f 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -66,6 +66,8 @@ import { import { cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, + readCachedCityList, + writeCachedCityList, } from "@/components/dashboard/scan-terminal/city-fallback-rows"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; import { STATIC_CITY_LIST } from "@/lib/static-cities"; @@ -1264,7 +1266,7 @@ function ScanTerminalScreen() { }, [refreshScanTerminalManually]); const [cityFallbackRows, setCityFallbackRows] = useState(() => - cityListItemsToScanRows(STATIC_CITY_LIST), + cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST), ); const rows = useMemo( () => { @@ -1280,9 +1282,15 @@ function ScanTerminalScreen() { useEffect(() => { if (!isPro || typeof fetch !== "function") return; if (fallbackFetchedRef.current) return; + const cachedCities = readCachedCityList(); + if (cachedCities) { + fallbackFetchedRef.current = true; + setCityFallbackRows(cityListItemsToScanRows(cachedCities)); + return; + } const controller = new AbortController(); fetch("/api/cities", { - cache: "no-store", + cache: "default", headers: { Accept: "application/json" }, signal: controller.signal, }) @@ -1293,6 +1301,7 @@ function ScanTerminalScreen() { .then((payload) => { if (!payload || !Array.isArray(payload.cities)) return; fallbackFetchedRef.current = true; + writeCachedCityList(payload.cities); setCityFallbackRows(cityListItemsToScanRows(payload.cities)); }) .catch(() => {}); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts index bd49ff1e..6e081920 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts @@ -1,6 +1,9 @@ import { + CITY_LIST_CACHE_TTL_MS, cityListItemsToScanRows, mergeScanRowsWithCityFallbackRows, + readCachedCityList, + writeCachedCityList, } from "@/components/dashboard/scan-terminal/city-fallback-rows"; import type { CityListItem } from "@/lib/dashboard-types"; @@ -47,6 +50,15 @@ export function runTests() { ]; const rows = cityListItemsToScanRows(cities); + const cacheStorage = new Map(); + const memoryStorage = { + getItem: (key: string) => cacheStorage.get(key) ?? null, + removeItem: (key: string) => { cacheStorage.delete(key); }, + setItem: (key: string, value: string) => { cacheStorage.set(key, value); }, + }; + + writeCachedCityList(cities, memoryStorage, 1_000); + const cachedCities = readCachedCityList(memoryStorage, 1_000 + 60_000); assert(rows.length === 2, "fallback rows should preserve every city"); assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable"); @@ -57,6 +69,12 @@ export function runTests() { assert(rows[0].temp_symbol === "°C", "celsius cities should use °C"); assert(rows[1].trading_region === "north_america", "known cities should keep their configured product region"); assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F"); + assert(cachedCities?.length === 2, "fresh city registry cache should return the stored city list"); + assert(cachedCities?.[0]?.name === "taipei", "city registry cache should preserve city payloads"); + assert( + readCachedCityList(memoryStorage, 1_000 + CITY_LIST_CACHE_TTL_MS + 1) === null, + "expired city registry cache should be ignored so the dashboard can revalidate", + ); const scanRows = [ { @@ -122,10 +140,13 @@ export function runTests() { assert( dashboardSource.includes("cityListItemsToScanRows") && dashboardSource.includes("STATIC_CITY_LIST") && + dashboardSource.includes("readCachedCityList") && + dashboardSource.includes("writeCachedCityList") && dashboardSource.includes("useState(() =>") && dashboardSource.includes("/api/cities") && + dashboardSource.includes('cache: "default"') && dashboardSource.includes("cityFallbackRows"), - "terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities", + "terminal dashboard should seed fallback rows from cached/static city snapshots and avoid no-store /api/cities fetches when possible", ); assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback"); assert( diff --git a/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts b/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts index ee7bab95..f393b74d 100644 --- a/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts +++ b/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts @@ -5,10 +5,61 @@ import { type RegionKey, } from "@/components/dashboard/scan-terminal/continent-grouping"; +const CITY_LIST_CACHE_KEY = "polyweather_city_list_v1"; +export const CITY_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +type CityListCacheStorage = Pick; + function normalizeCityKey(value: string) { return String(value || "").trim().toLowerCase(); } +function getCityListCacheStorage(storage?: CityListCacheStorage): CityListCacheStorage | null { + if (storage) return storage; + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readCachedCityList( + storage?: CityListCacheStorage, + now = Date.now(), +): CityListItem[] | null { + const cacheStorage = getCityListCacheStorage(storage); + if (!cacheStorage) return null; + try { + const raw = cacheStorage.getItem(CITY_LIST_CACHE_KEY); + if (!raw) return null; + const cached = JSON.parse(raw); + const age = now - Number(cached.ts || 0); + if (age < 0 || age >= CITY_LIST_CACHE_TTL_MS || !Array.isArray(cached.cities)) { + return null; + } + return cached.cities as CityListItem[]; + } catch { + try { cacheStorage.removeItem(CITY_LIST_CACHE_KEY); } catch {} + } + return null; +} + +export function writeCachedCityList( + cities: CityListItem[], + storage?: CityListCacheStorage, + now = Date.now(), +) { + const cacheStorage = getCityListCacheStorage(storage); + if (!cacheStorage || !Array.isArray(cities) || !cities.length) return; + try { + cacheStorage.setItem( + CITY_LIST_CACHE_KEY, + JSON.stringify({ ts: now, cities }), + ); + } catch {} +} + function tempSymbolForCity(city: CityListItem) { return city.temp_unit === "fahrenheit" ? "°F" : "°C"; }