// Session + preferences persistence. The session token lives in memory for the app // session and is mirrored to IndexedDB when available (so a reload does not force a // re-login), with a localStorage fallback. Losing the store just means re-login — // acceptable, and for a guest it simply mints a fresh guest. import type { Session, Profile } from './model'; import type { ThemePref } from './theme'; import type { Locale } from './i18n/catalog'; import type { BoardLabelMode } from './boardlabels'; const DB_NAME = 'scrabble'; const STORE = 'kv'; const LS_PREFIX = 'scrabble.'; let dbPromise: Promise | null | undefined; function openDb(): Promise | null { if (dbPromise !== undefined) return dbPromise; if (typeof indexedDB === 'undefined') { dbPromise = null; return null; } dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => req.result.createObjectStore(STORE); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }).catch(() => { dbPromise = null; throw new Error('indexedDB unavailable'); }); return dbPromise; } function lsGet(key: string): T | null { try { const v = localStorage.getItem(LS_PREFIX + key); return v ? (JSON.parse(v) as T) : null; } catch { return null; } } function lsSet(key: string, value: unknown): void { try { localStorage.setItem(LS_PREFIX + key, JSON.stringify(value)); } catch { /* storage unavailable — stay in-memory only */ } } function lsDel(key: string): void { try { localStorage.removeItem(LS_PREFIX + key); } catch { /* ignore */ } } async function kvGet(key: string): Promise { const db = openDb(); if (!db) return lsGet(key); try { const d = await db; return await new Promise((resolve, reject) => { const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key); r.onsuccess = () => resolve((r.result ?? null) as T | null); r.onerror = () => reject(r.error); }); } catch { return lsGet(key); } } async function kvSet(key: string, value: unknown): Promise { const db = openDb(); if (!db) return lsSet(key, value); try { const d = await db; await new Promise((resolve, reject) => { const tx = d.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(value, key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch { lsSet(key, value); } } async function kvDel(key: string): Promise { const db = openDb(); if (!db) return lsDel(key); try { const d = await db; await new Promise((resolve, reject) => { const tx = d.transaction(STORE, 'readwrite'); tx.objectStore(STORE).delete(key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch { lsDel(key); } } const SESSION_KEY = 'session'; const PREFS_KEY = 'prefs'; export function loadSession(): Promise { return kvGet(SESSION_KEY); } export function saveSession(s: Session): Promise { return kvSet(SESSION_KEY, s); } export function clearSession(): Promise { return kvDel(SESSION_KEY); } const PROFILE_KEY = 'profile'; /** loadProfile reads the last persisted profile (or null). It backs the offline cold-boot: with no * network, bootstrap uses it instead of fetching a fresh one. */ export function loadProfile(): Promise { return kvGet(PROFILE_KEY); } /** saveProfile persists the caller's own profile so a later offline launch can start from it (its * variant preferences, dictionary versions and display name), best-effort. */ export function saveProfile(p: Profile): Promise { return kvSet(PROFILE_KEY, p); } /** clearProfile drops the persisted profile (on logout, with the session). */ export function clearProfile(): Promise { return kvDel(PROFILE_KEY); } export interface Prefs { theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; zoomBoard: boolean; } export async function loadPrefs(): Promise> { return (await kvGet(PREFS_KEY)) ?? {}; } export function savePrefs(p: Prefs): Promise { return kvSet(PREFS_KEY, p); } const ONBOARDING_KEY = 'onboarding'; /** * Whether each first-run coachmark series has been completed. A series is marked done only after * its last hint, so a player interrupted mid-series sees it again from the start next launch. */ export interface OnboardingState { lobbyDone: boolean; gameDone: boolean; } /** Loads the persisted onboarding completion flags, defaulting to not-yet-seen. */ export async function loadOnboarding(): Promise { const s = await kvGet>(ONBOARDING_KEY); return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false }; } /** Persists the onboarding completion flags. */ export function saveOnboarding(s: OnboardingState): Promise { return kvSet(ONBOARDING_KEY, s); } /** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */ export function clearOnboarding(): Promise { return kvDel(ONBOARDING_KEY); }