Separate scan terminal storage cache helpers
The city-card data hook still contained localStorage serialization and TTL eviction helpers alongside AI fallback and UI state transitions. Moving those helpers into scan-terminal-cache creates a reusable cache boundary for the scan terminal request layer without changing cache keys, TTLs, or payload shapes. Constraint: Preserve existing localStorage keys and expiry behavior. Rejected: Migrate all caches to RemoteData in this commit | cache-helper extraction is a safer intermediate step before query policy changes. Confidence: high Scope-risk: narrow Reversibility: clean Tested: npm run build Not-tested: Browser private-mode quota edge cases beyond existing guarded behavior.
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
function getStorage() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStorageKey(
|
||||||
|
prefix: string,
|
||||||
|
parts: Array<string | null | undefined>,
|
||||||
|
) {
|
||||||
|
return `${prefix}:${parts
|
||||||
|
.map((part) => encodeURIComponent(String(part || "").trim()))
|
||||||
|
.join(":")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedPayload<T>(key: string, ttlMs: number): T | null {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T };
|
||||||
|
if (!parsed?.payload) return null;
|
||||||
|
if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedPayload<T>(key: string, payload: T) {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload }));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/privacy-mode failures; network fallbacks still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCachedPayload(key: string) {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Ignore privacy-mode failures; the next network request can still proceed.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
scanTerminalClient,
|
scanTerminalClient,
|
||||||
type AiCityStreamProgress,
|
type AiCityStreamProgress,
|
||||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||||
|
import {
|
||||||
|
buildStorageKey,
|
||||||
|
readCachedPayload,
|
||||||
|
removeCachedPayload,
|
||||||
|
writeCachedPayload,
|
||||||
|
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
|
||||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||||
import { normalizeCityKey } from "./decision-utils";
|
import { normalizeCityKey } from "./decision-utils";
|
||||||
|
|
||||||
@@ -21,21 +27,6 @@ const aiCityForecastStateCache = new Map<
|
|||||||
{ state: AiCityForecastState; updatedAt: number }
|
{ state: AiCityForecastState; updatedAt: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
function getStorage() {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
return window.localStorage;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStorageKey(prefix: string, parts: Array<string | null | undefined>) {
|
|
||||||
return `${prefix}:${parts
|
|
||||||
.map((part) => encodeURIComponent(String(part || "").trim()))
|
|
||||||
.join(":")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||||
const source = String(
|
const source = String(
|
||||||
detail?.current?.settlement_source ||
|
detail?.current?.settlement_source ||
|
||||||
@@ -47,44 +38,6 @@ function isHkoObservationCity(detail?: CityDetail | null) {
|
|||||||
return source === "hko";
|
return source === "hko";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCachedPayload<T>(key: string, ttlMs: number): T | null {
|
|
||||||
const storage = getStorage();
|
|
||||||
if (!storage) return null;
|
|
||||||
try {
|
|
||||||
const raw = storage.getItem(key);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T };
|
|
||||||
if (!parsed?.payload) return null;
|
|
||||||
if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) {
|
|
||||||
storage.removeItem(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parsed.payload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCachedPayload<T>(key: string, payload: T) {
|
|
||||||
const storage = getStorage();
|
|
||||||
if (!storage) return;
|
|
||||||
try {
|
|
||||||
storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload }));
|
|
||||||
} catch {
|
|
||||||
// Ignore quota/privacy-mode failures; network fallbacks still work.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCachedPayload(key: string) {
|
|
||||||
const storage = getStorage();
|
|
||||||
if (!storage) return;
|
|
||||||
try {
|
|
||||||
storage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// Ignore privacy-mode failures; the next network request can still proceed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCachedAiForecastState(key: string, ttlMs: number) {
|
function readCachedAiForecastState(key: string, ttlMs: number) {
|
||||||
const cached = aiCityForecastStateCache.get(key);
|
const cached = aiCityForecastStateCache.get(key);
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user