Cache terminal city registry fallback
This commit is contained in:
@@ -66,6 +66,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
cityListItemsToScanRows,
|
cityListItemsToScanRows,
|
||||||
mergeScanRowsWithCityFallbackRows,
|
mergeScanRowsWithCityFallbackRows,
|
||||||
|
readCachedCityList,
|
||||||
|
writeCachedCityList,
|
||||||
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||||
@@ -1264,7 +1266,7 @@ function ScanTerminalScreen() {
|
|||||||
}, [refreshScanTerminalManually]);
|
}, [refreshScanTerminalManually]);
|
||||||
|
|
||||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
||||||
cityListItemsToScanRows(STATIC_CITY_LIST),
|
cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST),
|
||||||
);
|
);
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@@ -1280,9 +1282,15 @@ function ScanTerminalScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPro || typeof fetch !== "function") return;
|
if (!isPro || typeof fetch !== "function") return;
|
||||||
if (fallbackFetchedRef.current) return;
|
if (fallbackFetchedRef.current) return;
|
||||||
|
const cachedCities = readCachedCityList();
|
||||||
|
if (cachedCities) {
|
||||||
|
fallbackFetchedRef.current = true;
|
||||||
|
setCityFallbackRows(cityListItemsToScanRows(cachedCities));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
fetch("/api/cities", {
|
fetch("/api/cities", {
|
||||||
cache: "no-store",
|
cache: "default",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -1293,6 +1301,7 @@ function ScanTerminalScreen() {
|
|||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!payload || !Array.isArray(payload.cities)) return;
|
if (!payload || !Array.isArray(payload.cities)) return;
|
||||||
fallbackFetchedRef.current = true;
|
fallbackFetchedRef.current = true;
|
||||||
|
writeCachedCityList(payload.cities);
|
||||||
setCityFallbackRows(cityListItemsToScanRows(payload.cities));
|
setCityFallbackRows(cityListItemsToScanRows(payload.cities));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
CITY_LIST_CACHE_TTL_MS,
|
||||||
cityListItemsToScanRows,
|
cityListItemsToScanRows,
|
||||||
mergeScanRowsWithCityFallbackRows,
|
mergeScanRowsWithCityFallbackRows,
|
||||||
|
readCachedCityList,
|
||||||
|
writeCachedCityList,
|
||||||
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||||
import type { CityListItem } from "@/lib/dashboard-types";
|
import type { CityListItem } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
@@ -47,6 +50,15 @@ export function runTests() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows = cityListItemsToScanRows(cities);
|
const rows = cityListItemsToScanRows(cities);
|
||||||
|
const cacheStorage = new Map<string, string>();
|
||||||
|
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.length === 2, "fallback rows should preserve every city");
|
||||||
assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable");
|
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[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].trading_region === "north_america", "known cities should keep their configured product region");
|
||||||
assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F");
|
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 = [
|
const scanRows = [
|
||||||
{
|
{
|
||||||
@@ -122,10 +140,13 @@ export function runTests() {
|
|||||||
assert(
|
assert(
|
||||||
dashboardSource.includes("cityListItemsToScanRows") &&
|
dashboardSource.includes("cityListItemsToScanRows") &&
|
||||||
dashboardSource.includes("STATIC_CITY_LIST") &&
|
dashboardSource.includes("STATIC_CITY_LIST") &&
|
||||||
|
dashboardSource.includes("readCachedCityList") &&
|
||||||
|
dashboardSource.includes("writeCachedCityList") &&
|
||||||
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
|
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
|
||||||
dashboardSource.includes("/api/cities") &&
|
dashboardSource.includes("/api/cities") &&
|
||||||
|
dashboardSource.includes('cache: "default"') &&
|
||||||
dashboardSource.includes("cityFallbackRows"),
|
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(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback");
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -5,10 +5,61 @@ import {
|
|||||||
type RegionKey,
|
type RegionKey,
|
||||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
} 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<Storage, "getItem" | "removeItem" | "setItem">;
|
||||||
|
|
||||||
function normalizeCityKey(value: string) {
|
function normalizeCityKey(value: string) {
|
||||||
return String(value || "").trim().toLowerCase();
|
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) {
|
function tempSymbolForCity(city: CityListItem) {
|
||||||
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user