feat(offline): gate the offline toggle on dictionary readiness
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
This commit is contained in:
Ilia Denisov
2026-07-06 20:03:12 +02:00
parent 05c445e4da
commit 30770a759b
10 changed files with 153 additions and 9 deletions
+27
View File
@@ -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<boolean> {
if (prof.variantPreferences.length === 0) return false;
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
retries: 1,
});
return raceOfflineReady(run, budgetMs);
}
+2
View File
@@ -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',
+2
View File
@@ -217,6 +217,8 @@ export const ru: Record<MessageKey, string> = {
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
+20
View File
@@ -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<boolean> {
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.
+23 -1
View File
@@ -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<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => 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);
});
});
+17
View File
@@ -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<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)
+47 -3
View File
@@ -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<void> {
if (offlineMode.active || checking) return;
needsData = false;
checking = true;
try {
const ready = await requestOffline(app.profile);
if (!ready) needsData = true;
} finally {
checking = false;
}
}
</script>
<div class="page">
@@ -89,13 +108,26 @@
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button class="opt" class:active={!offlineMode.active} onclick={() => setOfflineMode(false)}>
<button
class="opt"
class:active={!offlineMode.active}
disabled={checking}
onclick={() => {
needsData = false;
setOfflineMode(false);
}}
>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} onclick={() => setOfflineMode(true)}>
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
{t('settings.offline')}
</button>
</div>
{#if checking}
<p class="onote">{t('settings.offlineChecking')}</p>
{:else if needsData}
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
{/if}
</section>
{/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;