// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness // decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env. // The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the // gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome // and shows only local games. import type { Variant } from './model'; const STORAGE_KEY = 'scrabble.offlineMode'; /** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when * storage is unavailable, so a device that cannot persist simply starts online. */ export function loadOfflinePref(): boolean { try { return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'; } catch { return false; } } /** saveOfflinePref persists the offline-mode flag (best-effort). */ export function saveOfflinePref(on: boolean): void { try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0'); } catch { /* best-effort — a failed persist just reverts to online on the next launch */ } } /** * offlineReady reports whether the device can play offline right now: at least one variant is * enabled and every enabled variant's dictionary is already available on the device (hasDict). The * offline toggle uses it to decide whether flipping to offline can succeed immediately. */ export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean { return enabled.length > 0 && enabled.every((v) => hasDict(v)); } /** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the * toggle must fetch (or wait on) before offline mode can be entered. */ export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] { return enabled.filter((v) => !hasDict(v)); } /** * raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether * offline mode can be entered now: ready only when the fetch resolves with nothing still failed * before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it * keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so * the logic stays pure and unit-tests in the node env. */ export async function raceOfflineReady( run: Promise<{ failed: readonly unknown[] }>, budgetMs: number, sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), ): Promise { const elapsed = sleep(budgetMs).then(() => null); const res = await Promise.race([run, elapsed]); return res !== null && res.failed.length === 0; } /** * offlinePreloadEligible reports whether a background dictionary preload should run in this * context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab) * with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context * has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline * toggle's eligibility so the two never disagree. */ export function offlinePreloadEligible(opts: { hasEmail: boolean; standalone: boolean; inTelegram: boolean; inVK: boolean; online: boolean; }): boolean { return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online; } /** * shouldBootOffline decides whether a cold start skips the network and launches straight into * offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached * session and profile to start from. Without both (e.g. cleared storage, or a never-online install), * the app must boot online once first — the caller then drops the sticky flag and continues online. */ export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean { return opts.offlineActive && opts.hasSession && opts.hasProfile; }