From 132f0921e07c4da9930a6cf8cb5d194d710c7a99 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 17:41:15 +0330 Subject: [PATCH] feat(dashboard/offline): persist React Query cache for offline reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of offline-first (Phase 1). Makes every dashboard area *viewable* offline with last-synced data, instead of empty lists on an offline reload (previously only next-pwa's 10-min API cache survived). - offline-db: add a generic `kv` IndexedDB store (DB v2, preserves order_queue) with kvGet/kvSet/kvDelete; all degrade silently on quota/unavailable. - query-persister: debounced snapshot of the React Query cache via dehydrate/hydrate (no new dependency). Restore is guarded by a schema buster, 24h max-age, and a café scope so one tenant never hydrates another's data. - providers: gcTime 24h so hydrated data isn't GC'd; restore on mount + persist on cache changes, re-scoped when the signed-in café changes. No write-path changes; the existing POS order queue is untouched. Next in Phase 1: generalize that queue into an idempotent outbox with client→server ID remapping. Co-Authored-By: Claude Opus 4.8 --- web/dashboard/src/components/providers.tsx | 30 +++++++- web/dashboard/src/lib/offline/offline-db.ts | 54 +++++++++++++- .../src/lib/offline/query-persister.ts | 70 +++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 web/dashboard/src/lib/offline/query-persister.ts diff --git a/web/dashboard/src/components/providers.tsx b/web/dashboard/src/components/providers.tsx index 9b31b3d..d50ca10 100644 --- a/web/dashboard/src/components/providers.tsx +++ b/web/dashboard/src/components/providers.tsx @@ -1,20 +1,46 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ConfirmProvider } from "@/components/providers/confirm-provider"; import { MeeziToaster } from "@/components/ui/meezi-toaster"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { - queries: { staleTime: 30_000, retry: 1 }, + queries: { + staleTime: 30_000, + retry: 1, + // Keep data in memory long enough to back offline reads; it is also + // persisted to IndexedDB by the persister below. + gcTime: 24 * 60 * 60 * 1000, + }, }, }) ); + // Persist the query cache to IndexedDB so the dashboard is viewable offline. + // Scoped to the current café so a different tenant never hydrates this data. + const cafeId = useAuthStore((s) => s.user?.cafeId); + useEffect(() => { + const scope = cafeId ?? "anon"; + let active = true; + let stop: () => void = () => {}; + void (async () => { + await restoreQueryCache(queryClient, scope); + if (!active) return; + stop = startPersisting(queryClient, scope); + })(); + return () => { + active = false; + stop(); + }; + }, [queryClient, cafeId]); + return ( diff --git a/web/dashboard/src/lib/offline/offline-db.ts b/web/dashboard/src/lib/offline/offline-db.ts index 1587ad4..f0a0214 100644 --- a/web/dashboard/src/lib/offline/offline-db.ts +++ b/web/dashboard/src/lib/offline/offline-db.ts @@ -22,8 +22,10 @@ export type OfflineQueueItem = { }; const DB_NAME = "meezi_pos_offline"; -const DB_VERSION = 1; +const DB_VERSION = 2; const STORE = "order_queue"; +/** Generic key-value store (used to persist the React Query cache for offline reads). */ +const KV_STORE = "kv"; let _db: IDBDatabase | null = null; @@ -36,6 +38,9 @@ function openDb(): Promise { if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE, { keyPath: "id" }); } + if (!db.objectStoreNames.contains(KV_STORE)) { + db.createObjectStore(KV_STORE); + } }; req.onsuccess = () => { _db = req.result; @@ -109,3 +114,50 @@ export async function markQueueItemFailed(id: string): Promise { tx.onerror = () => reject(tx.error); }); } + +// ─── Generic key-value store (React Query cache persistence) ─────────────────── + +/** Store an arbitrary JSON-serializable value under a key. Never throws. */ +export async function kvSet(key: string, value: unknown): Promise { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(KV_STORE, "readwrite"); + tx.objectStore(KV_STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + // IndexedDB unavailable / quota exceeded / blocked — degrade silently. + } +} + +/** Read a value previously stored with {@link kvSet}. Returns undefined on any failure. */ +export async function kvGet(key: string): Promise { + try { + const db = await openDb(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(KV_STORE, "readonly"); + const req = tx.objectStore(KV_STORE).get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); + } catch { + return undefined; + } +} + +/** Remove a persisted value (e.g. on logout, to avoid leaking another user's cache). */ +export async function kvDelete(key: string): Promise { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(KV_STORE, "readwrite"); + tx.objectStore(KV_STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + // ignore + } +} diff --git a/web/dashboard/src/lib/offline/query-persister.ts b/web/dashboard/src/lib/offline/query-persister.ts new file mode 100644 index 0000000..3132fbe --- /dev/null +++ b/web/dashboard/src/lib/offline/query-persister.ts @@ -0,0 +1,70 @@ +/** + * Persists the React Query cache to IndexedDB so the dashboard is *viewable* + * offline (last-synced data) and survives a reload with no connection. + * + * Uses `dehydrate`/`hydrate` from @tanstack/react-query directly — no extra + * dependency. Writes are debounced; reads are guarded by a schema buster, a + * max-age, and a tenant scope so one café never hydrates another's data. + */ +import { dehydrate, hydrate, type QueryClient } from "@tanstack/react-query"; +import { kvGet, kvSet } from "@/lib/offline/offline-db"; + +const CACHE_KEY = "rq-cache"; +/** Bump when cached shapes change so stale persisted data is dropped on deploy. */ +const BUSTER = "v1"; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h +const SAVE_DEBOUNCE_MS = 1000; + +type PersistedCache = { + buster: string; + timestamp: number; + /** Tenant/user scope this cache belongs to (cafeId, or "anon"). */ + scope: string; + state: unknown; +}; + +/** + * Hydrate the query cache from IndexedDB if a valid snapshot exists for this + * scope. Safe to call before or after queries mount. + */ +export async function restoreQueryCache(qc: QueryClient, scope: string): Promise { + const saved = await kvGet(CACHE_KEY); + if (!saved) return; + if (saved.buster !== BUSTER) return; // schema changed + if (saved.scope !== scope) return; // different tenant/user — do not leak + if (Date.now() - saved.timestamp > MAX_AGE_MS) return; // too old + try { + hydrate(qc, saved.state as never); + } catch { + // corrupt snapshot — ignore, it will be overwritten on next save + } +} + +/** + * Subscribe to cache changes and persist a debounced snapshot. Returns an + * unsubscribe function. + */ +export function startPersisting(qc: QueryClient, scope: string): () => void { + let timer: ReturnType | null = null; + + const save = () => { + timer = null; + const snapshot: PersistedCache = { + buster: BUSTER, + timestamp: Date.now(), + scope, + state: dehydrate(qc), + }; + void kvSet(CACHE_KEY, snapshot); + }; + + const unsubscribe = qc.getQueryCache().subscribe(() => { + if (timer) return; // a save is already scheduled + timer = setTimeout(save, SAVE_DEBOUNCE_MS); + }); + + return () => { + if (timer) clearTimeout(timer); + unsubscribe(); + }; +}