Files
scrabble-game/ui/src/lib/session.ts
T
Ilia Denisov 77a690fcf6
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) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
feat(ui): move in-game status to the board — highlight, score badge, full-width rack
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00

183 lines
5.2 KiB
TypeScript

// 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<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((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<T>(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<T>(key: string): Promise<T | null> {
const db = openDb();
if (!db) return lsGet<T>(key);
try {
const d = await db;
return await new Promise<T | null>((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<T>(key);
}
}
async function kvSet(key: string, value: unknown): Promise<void> {
const db = openDb();
if (!db) return lsSet(key, value);
try {
const d = await db;
await new Promise<void>((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<void> {
const db = openDb();
if (!db) return lsDel(key);
try {
const d = await db;
await new Promise<void>((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<Session | null> {
return kvGet<Session>(SESSION_KEY);
}
export function saveSession(s: Session): Promise<void> {
return kvSet(SESSION_KEY, s);
}
export function clearSession(): Promise<void> {
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<Profile | null> {
return kvGet<Profile>(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<void> {
return kvSet(PROFILE_KEY, p);
}
/** clearProfile drops the persisted profile (on logout, with the session). */
export function clearProfile(): Promise<void> {
return kvDel(PROFILE_KEY);
}
export interface Prefs {
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
zoomBoard: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
return (await kvGet<Prefs>(PREFS_KEY)) ?? {};
}
export function savePrefs(p: Prefs): Promise<void> {
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<OnboardingState> {
const s = await kvGet<Partial<OnboardingState>>(ONBOARDING_KEY);
return { lobbyDone: s?.lobbyDone ?? false, gameDone: s?.gameDone ?? false };
}
/** Persists the onboarding completion flags. */
export function saveOnboarding(s: OnboardingState): Promise<void> {
return kvSet(ONBOARDING_KEY, s);
}
/** Clears the onboarding flags so the coachmarks replay on the next launch (DebugPanel reset). */
export function clearOnboarding(): Promise<void> {
return kvDel(ONBOARDING_KEY);
}