6636d7c309
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their first game board: a light dimmed layer draws one tail-pointed hint bubble at a time, advancing on a tap anywhere and removing itself for good after the last hint. Two independent series (lobby: settings/stats/new game; game: header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag and marked done only after the last hint, so an interrupted run replays from the start. A deep-link into Settings -> Friends still triggers the lobby series on the first trip back to the lobby. Targets carry a data-coach attribute, so one positioning engine anchors the bubble in both portrait and landscape, re-measuring each frame until the geometry settles (route slide, hidden-banner reflow, fonts). The promo banner hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset visited" control replays the walk-through. Off by default in the mock build so the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e. Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts (unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding story, UI_DESIGN coachmark section.
163 lines
4.5 KiB
TypeScript
163 lines
4.5 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 } 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);
|
|
}
|
|
|
|
export interface Prefs {
|
|
theme: ThemePref;
|
|
locale: Locale;
|
|
reduceMotion: boolean;
|
|
boardLabels: BoardLabelMode;
|
|
}
|
|
|
|
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);
|
|
}
|