From 30770a759b3fea13afcf8f791f92952865549c2d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 20:03:12 +0200 Subject: [PATCH] feat(offline): gate the offline toggle on dictionary readiness 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 --- docs/ARCHITECTURE.md | 6 +++- docs/FUNCTIONAL.md | 7 +++-- docs/FUNCTIONAL_ru.md | 7 +++-- ui/src/lib/dict/offlineready.ts | 27 ++++++++++++++++++ ui/src/lib/i18n/en.ts | 2 ++ ui/src/lib/i18n/ru.ts | 2 ++ ui/src/lib/offline.svelte.ts | 20 +++++++++++++ ui/src/lib/offline.test.ts | 24 +++++++++++++++- ui/src/lib/offline.ts | 17 +++++++++++ ui/src/screens/Settings.svelte | 50 +++++++++++++++++++++++++++++++-- 10 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 ui/src/lib/dict/offlineready.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6695360..02193d0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads* — on lobby entry and on a variant-preference change — through the same three-tier loader, retried with backoff and honouring the session miss-breaker; the move generator, the loader and the preload orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in -the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and +the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch +bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only +once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the +fetch finishes in the background (the next flip is then instant). A **cold launch already in offline +mode** boots from the persisted session and profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch that would otherwise hang with no network, and lands straight in the offline lobby; without a cached profile (an install that was never online) it drops the sticky flag diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index cb32d02..2f830a5 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -265,8 +265,11 @@ Only **vs_ai** games are playable — New Game creates a device-local game again plays entirely in the browser with no backend. Local games are kept on the device, visible only in offline mode, and never sync to the account. So a game can be created and played with no connection, the app quietly preloads the dictionaries for the player's enabled variants while still online, and -(once installed) launches from a precached shell even with no network. The mode is device-scoped and -sticky across launches. +(once installed) launches from a precached shell even with no network. Switching **to** offline first +checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them +and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note +(the download keeps running in the background, so a later switch is instant). The mode is +device-scoped and sticky across launches. The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch with no connection it switches to offline for that session; when the device is online but the gateway diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 659fad5..afb1222 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -270,8 +270,11 @@ e-mail) либо ввод фразы. Активные игры форфейтя целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки) -запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между -запусками. +запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что +словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго +ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка +продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и +сохраняется между запусками. Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит diff --git a/ui/src/lib/dict/offlineready.ts b/ui/src/lib/dict/offlineready.ts new file mode 100644 index 0000000..cc00dd7 --- /dev/null +++ b/ui/src/lib/dict/offlineready.ts @@ -0,0 +1,27 @@ +// Browser orchestration for the offline-toggle readiness wait. Lazily imported by +// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main +// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by +// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the +// fetch running on a timeout so a later flip is instant. + +import type { Profile } from '../model'; +import { preloadDicts } from './preload'; +import { getDawg, dictLoadingDisabled } from '../dict'; +import { raceOfflineReady } from '../offline'; + +/** + * ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when + * already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is + * available — the offline toggle's readiness gate. With no enabled variant there is nothing to play + * offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the + * on-device cache in the background so a later flip to offline succeeds immediately. + */ +export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise { + if (prof.variantPreferences.length === 0) return false; + const run = preloadDicts(prof.dictVersions, prof.variantPreferences, { + getDawg, + disabled: dictLoadingDisabled, + retries: 1, + }); + return raceOfflineReady(run, budgetMs); +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 0a7a73f..82c5d3b 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -217,6 +217,8 @@ export const en = { 'settings.offlineMode': 'Play mode', 'settings.online': 'Online', 'settings.offline': 'Offline', + 'settings.offlineChecking': 'Loading dictionaries…', + 'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.', 'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.', 'offline.promptTitle': 'No connection. Enable offline mode?', 'offline.promptYes': 'Enable', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 6c354c9..ec7de99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -217,6 +217,8 @@ export const ru: Record = { 'settings.offlineMode': 'Режим игры', 'settings.online': 'Онлайн', 'settings.offline': 'Оффлайн', + 'settings.offlineChecking': 'Загрузка словарей…', + 'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.', 'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.', 'offline.promptTitle': 'Нет связи. Включить офлайн-режим?', 'offline.promptYes': 'Включить', diff --git a/ui/src/lib/offline.svelte.ts b/ui/src/lib/offline.svelte.ts index 544e4a7..b355ee7 100644 --- a/ui/src/lib/offline.svelte.ts +++ b/ui/src/lib/offline.svelte.ts @@ -40,6 +40,26 @@ export function setOfflineMode(on: boolean, persist = true): void { if (persist) saveOfflinePref(on); } +/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled + * variants' dictionaries before reverting to online (the fetch then continues in the background). */ +export const TOGGLE_READY_BUDGET_MS = 5000; + +/** + * requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled + * variants' dictionaries cache-first and, if every one is available within the readiness budget, + * switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and + * returns false — the caller shows the "needs internet" note — while the fetch keeps warming the + * cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically, + * so neither is pulled into the main bundle. Returns false with no profile. + */ +export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise { + if (!prof) return false; + const m = await import('./dict/offlineready'); + const ready = await m.ensureOfflineDicts(prof, budgetMs); + if (ready) setOfflineMode(true); + return ready; +} + // The dict-preload warning: true when a first-lobby background preload could not fetch every // enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete. // The lobby shows a notice in place of the ad banner while it holds. diff --git a/ui/src/lib/offline.test.ts b/ui/src/lib/offline.test.ts index 00c48ab..895e863 100644 --- a/ui/src/lib/offline.test.ts +++ b/ui/src/lib/offline.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline'; +import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline'; import type { Variant } from './model'; // A minimal in-memory localStorage for the persistence tests (node has none). @@ -53,3 +53,25 @@ describe('offline mode helpers', () => { expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false); }); }); + +describe('raceOfflineReady (toggle-flip readiness wait)', () => { + // A sleep that never elapses: the fetch always wins the race, exercising its resolution. + const never = (): Promise => new Promise(() => {}); + // A sleep that elapses immediately: the budget always wins the race, exercising the timeout. + const now = (): Promise => Promise.resolve(); + + it('is ready when the fetch resolves with nothing failed before the budget', async () => { + const run = Promise.resolve({ failed: [] as Variant[] }); + expect(await raceOfflineReady(run, 5000, never)).toBe(true); + }); + + it('is not ready when a variant is still missing after the fetch', async () => { + const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] }); + expect(await raceOfflineReady(run, 5000, never)).toBe(false); + }); + + it('is not ready when the budget elapses before the fetch resolves', async () => { + const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves + expect(await raceOfflineReady(run, 5000, now)).toBe(false); + }); +}); diff --git a/ui/src/lib/offline.ts b/ui/src/lib/offline.ts index e461e6e..f72ec08 100644 --- a/ui/src/lib/offline.ts +++ b/ui/src/lib/offline.ts @@ -42,6 +42,23 @@ export function missingDicts(enabled: readonly Variant[], hasDict: (v: 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) diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte index a1cbcc2..b017018 100644 --- a/ui/src/screens/Settings.svelte +++ b/ui/src/screens/Settings.svelte @@ -11,7 +11,7 @@ import type { BoardLabelMode } from '../lib/boardlabels'; import { insideTelegram } from '../lib/telegram'; import { insideVK } from '../lib/vk'; - import { offlineMode, setOfflineMode } from '../lib/offline.svelte'; + import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte'; import { isStandalone } from '../lib/pwa'; import InstallApp from '../components/InstallApp.svelte'; @@ -35,6 +35,25 @@ classic: 'settings.labelsClassic', none: 'settings.labelsNone', }; + + // The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled + // variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made + // available it stays online and shows the "needs internet" note, while the fetch keeps warming the + // cache in the background so a later flip is instant. Leaving offline (-> online) is never gated. + let checking = $state(false); + let needsData = $state(false); + + async function goOffline(): Promise { + if (offlineMode.active || checking) return; + needsData = false; + checking = true; + try { + const ready = await requestOffline(app.profile); + if (!ready) needsData = true; + } finally { + checking = false; + } + }
@@ -89,13 +108,26 @@

{t('settings.offlineMode')}

- -
+ {#if checking} +

{t('settings.offlineChecking')}

+ {:else if needsData} + + {/if}
{/if} @@ -139,6 +171,18 @@ color: var(--accent-text); border-color: var(--accent); } + .opt:disabled { + opacity: 0.55; + cursor: default; + } + .onote { + font-size: 0.85rem; + color: var(--text-muted); + margin: 8px 0 0; + } + .onote--warn { + color: var(--warn); + } .row { display: flex; align-items: center; -- 2.52.0