WFDASHDRIVER-APP/lib/itemsCache.ts

138 lines
4.5 KiB
TypeScript
Raw Permalink Normal View History

import { OrderItem } from './types';
/**
* Tiny in-memory + localStorage cache for the three list scopes
* (`assigned`, `collection_pool`, `problem`).
*
* Why this exists
* ---------------
* The driver app's three list pages each mount → fetch → render.
* That made every bottom-nav tab click show a loading skeleton
* before any data appeared, even when the user had just been
* looking at that exact list two seconds ago. Perceived
* "sluggishness on PC tab switching" was almost entirely the
* skeleton flash, not a slow API.
*
* Strategy: stale-while-revalidate at the React level.
* 1. The page reads `getCached(scope)` synchronously on mount
* and renders immediately. No loading spinner if we have
* anything cached.
* 2. The page still fires the real fetch in `useEffect`. When
* it resolves, we `setCached(scope, items)` and the page
* re-renders with fresh data.
* 3. On the next tab switch, the new page reads the (now
* fresh) cache and renders without a skeleton.
*
* Backing store
* -------------
* Two layers, in order:
* - Module-level `Map` for synchronous reads on the same tab
* session (instant).
* - `localStorage` so the cache survives a hard refresh and a
* tab close+reopen.
*
* Cache shape
* -----------
* One key per scope. Stored as `{ items, ts }` where `ts` is the
* fetch time (ms since epoch). The `ts` lets callers decide to
* force-refresh if the data is stale (we currently don't — the
* in-flight re-fetch handles that).
*
* Limits / non-goals
* ------------------
* - NOT a TTL cache. Stale data is fine because we always
* re-fetch in the background.
* - NOT shared across drivers. localStorage is per-origin per-
* browser, which on a shared cab tablet would mean driver A
* sees driver B's cached list until the re-fetch lands.
* For the single-driver-per-device PWA this is fine.
* - NOT encrypted. Items include PO numbers and quantities —
* same data the app already puts in localStorage via auth,
* so we're not expanding the threat surface.
*/
const STORAGE_KEY = '1driver_items_cache_v1';
const ALL_SCOPES = ['assigned', 'collection_pool', 'problem'] as const;
export type ItemScope = typeof ALL_SCOPES[number];
type CacheEntry = { items: OrderItem[]; ts: number };
// Module-level map is the hot path — synchronous, free, lives
// only as long as the page is loaded.
const memory = new Map<ItemScope, CacheEntry>();
function readStorage(): Record<string, CacheEntry> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, CacheEntry>;
} catch {
return {};
}
}
function writeStorage(data: Record<string, CacheEntry>): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
/* quota / private mode — fine, memory layer still works */
}
}
function hydrateMemoryFromStorage(): void {
const data = readStorage();
for (const scope of ALL_SCOPES) {
const entry = data[scope];
if (entry) memory.set(scope, entry);
}
}
// One-time per page load — pulls whatever localStorage had into
// the module-level map so subsequent reads are sync.
let hydrated = false;
function ensureHydrated(): void {
if (hydrated) return;
hydrateMemoryFromStorage();
hydrated = true;
}
/** Synchronous read. Returns null if nothing cached for this scope. */
export function getCached(scope: ItemScope): OrderItem[] | null {
ensureHydrated();
const entry = memory.get(scope);
return entry ? entry.items : null;
}
/** Persist a successful fetch. */
export function setCached(scope: ItemScope, items: OrderItem[]): void {
ensureHydrated();
const entry: CacheEntry = { items, ts: Date.now() };
memory.set(scope, entry);
// Mirror to localStorage. We re-read-modify-write (rather than
// use memory directly) so other scopes survive this call.
const data = readStorage();
data[scope] = entry;
writeStorage(data);
}
/** Drop everything — used on logout so the next driver doesn't
* briefly see the previous driver's items. */
export function clearCache(): void {
memory.clear();
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* fine */
}
}
}
/** Best-effort timestamp from the last successful fetch, or null. */
export function getCachedAt(scope: ItemScope): number | null {
ensureHydrated();
return memory.get(scope)?.ts ?? null;
}