30770a759b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m44s
Flipping the Settings toggle to offline now checks that every enabled variant's dictionary is on the device before entering offline mode: it fetches missing ones cache-first and waits up to ~5 s (raceOfflineReady + the lazy dict/offlineready), greying the toggle meanwhile. If they cannot be readied in time it stays online and shows a 'needs internet' note, while the fetch keeps warming the cache in the background so a later flip is instant. Leaving offline is never gated. Prevents entering a half-baked offline mode (no dawg -> cannot create/play a local game) when the background preload has not finished (poor connection, or an immediate flip right after install). - offline.ts: raceOfflineReady (pure, injected sleep; unit-tested red->green) - dict/offlineready.ts: ensureOfflineDicts (cache-first preloadDicts, lazy chunk) - offline.svelte.ts: requestOffline + TOGGLE_READY_BUDGET_MS - Settings.svelte: checking/needsData state, disabled toggle, inline note - i18n: settings.offlineChecking / settings.offlineNeedsData (en+ru) - docs: FUNCTIONAL(+_ru) offline story + ARCHITECTURE offline paragraph
88 lines
4.1 KiB
TypeScript
88 lines
4.1 KiB
TypeScript
// 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<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
): Promise<boolean> {
|
|
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;
|
|
}
|