/** * Хранение настроек в localStorage (контракт — docs/INTERFACES.md, «Схема * localStorage»). Конверт {version, checksum, payload}; checksum — FNV-1a hex * от JSON.stringify(payload). Битое состояние не роняет приложение: чтение * возвращает дефолты с пометкой recovered. Запись — только через * saveSettings. DOM не используется напрямую: хранилище инжектируется * (в браузере — глобальный localStorage, в тестах — заглушка). */ import type { BoardSize } from '@go-learn/core'; export interface StorageEnvelope { readonly version: number; readonly checksum: string; readonly payload: T; } export interface SettingsV1 { readonly size: BoardSize; readonly level: 1 | 2 | 3 | 4 | 5 | 6; readonly playerColor: 'black' | 'white' | 'random'; readonly komi: number | 'auto'; readonly showCoordinates: boolean; readonly showMoveNumbers: boolean; readonly hintsEnabled: boolean; } /** Минимальный интерфейс хранилища (совместим с DOM Storage). */ export interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; } export const SETTINGS_KEY = 'go-learn:settings'; export const SETTINGS_VERSION = 1; export const DEFAULT_SETTINGS: SettingsV1 = { size: 9, level: 2, playerColor: 'black', komi: 'auto', showCoordinates: true, showMoveNumbers: false, hintsEnabled: false, }; /** FNV-1a (32-бит), hex без ведущих нулей — стабилен для одинакового json. */ export function checksumOf(json: string): string { let hash = 0x811c9dc5; for (let index = 0; index < json.length; index += 1) { hash ^= json.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(16); } /** Хранилище по умолчанию — глобальный localStorage; null, если недоступен. */ function defaultStorage(): StorageLike | null { try { const candidate = (globalThis as { localStorage?: StorageLike }).localStorage; return candidate ?? null; } catch { return null; // доступ к localStorage может кидать (приватный режим и т.п.) } } function isBoardSize(value: unknown): value is BoardSize { return value === 9 || value === 13 || value === 19; } function isLevel(value: unknown): value is SettingsV1['level'] { return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6; } /** Структурная валидация payload: неизвестные поля игнорируются. */ function isSettingsV1(value: unknown): value is SettingsV1 { if (typeof value !== 'object' || value === null) return false; const record = value as Record; const colors = ['black', 'white', 'random']; return ( isBoardSize(record['size']) && isLevel(record['level']) && colors.includes(record['playerColor'] as string) && (record['komi'] === 'auto' || typeof record['komi'] === 'number') && typeof record['showCoordinates'] === 'boolean' && typeof record['showMoveNumbers'] === 'boolean' && typeof record['hintsEnabled'] === 'boolean' ); } /** Разбор конверта; null — любая порча (JSON, checksum, version, схема). */ function parseEnvelope( raw: string, version: number, guard: (value: unknown) => value is T, ): T | null { let envelope: StorageEnvelope; try { envelope = JSON.parse(raw) as StorageEnvelope; } catch { return null; } if (typeof envelope !== 'object' || envelope === null) return null; if (envelope.version !== version) return null; if (checksumOf(JSON.stringify(envelope.payload)) !== envelope.checksum) return null; return guard(envelope.payload) ? envelope.payload : null; } /** Общая запись конверта; без хранилища — тихий no-op. */ function writeEnvelope(key: string, version: number, payload: T, storage?: StorageLike): void { const store = storage ?? defaultStorage(); if (store === null) return; const payloadJson = JSON.stringify(payload); const envelope: StorageEnvelope = { version, checksum: checksumOf(payloadJson), payload, }; store.setItem(key, JSON.stringify(envelope)); } /** * Чтение настроек: нет ключа → дефолты (recovered: false); битый JSON / * несовпадение checksum / неизвестная version → дефолты + recovered: true. */ export function loadSettings(storage?: StorageLike): { settings: SettingsV1; recovered: boolean } { const store = storage ?? defaultStorage(); if (store === null) return { settings: DEFAULT_SETTINGS, recovered: false }; const raw = store.getItem(SETTINGS_KEY); if (raw === null) return { settings: DEFAULT_SETTINGS, recovered: false }; const parsed = parseEnvelope(raw, SETTINGS_VERSION, isSettingsV1); if (parsed === null) return { settings: DEFAULT_SETTINGS, recovered: true }; return { settings: parsed, recovered: false }; } /** Запись настроек с пересчётом checksum; без хранилища — тихий no-op. */ export function saveSettings(settings: SettingsV1, storage?: StorageLike): void { writeEnvelope(SETTINGS_KEY, SETTINGS_VERSION, settings, storage); } /* ── Прогресс курса (этап 6, контракт «Прогресс курса» в INTERFACES.md) ── */ export interface LessonProgress { readonly done: boolean; readonly tasksSolved: readonly string[]; } export interface TaskProgress { readonly solved: boolean; readonly attempts: number; } export interface StreakV1 { readonly lastDay: string; // 'YYYY-MM-DD' последней активности readonly current: number; // дней подряд } export interface ProgressV1 { readonly lessons: Readonly>; // key = lesson id readonly tasks: Readonly>; // key = task id readonly streak: StreakV1; } /* ── Прогресс V2 и синхронизация (этап 8, INTERFACES.md «Личный кабинет») ── */ /** * Источник метки времени (ISO). Инжектируется параметром: чистые функции * сами Date/Date.now не вызывают (контракт этапа 8). */ export type Clock = () => string; /** Часы по умолчанию — текущее время в ISO (UTC-суффикс Z). */ const defaultClock: Clock = () => new Date().toISOString(); /** * ProgressV2 = ProgressV1 + updatedAt (ISO локального изменения). Пустая * строка — «локальных изменений ещё не было» (дефолты без ключа): sync-client * трактует её как отсутствие локальной метки. Ключ и конверт — те же, что у * V1; миграция V1→V2 — при чтении (updatedAt = момент миграции), до дефолтов. */ export interface ProgressV2 extends ProgressV1 { readonly updatedAt: string; } export const PROGRESS_KEY = 'go-learn:progress'; export const PROGRESS_VERSION = 1; export const DEFAULT_PROGRESS: ProgressV2 = { lessons: {}, tasks: {}, streak: { lastDay: '', current: 0 }, updatedAt: '', }; function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === 'string'); } function isLessonProgress(value: unknown): value is LessonProgress { if (typeof value !== 'object' || value === null) return false; const record = value as Record; return typeof record['done'] === 'boolean' && isStringArray(record['tasksSolved']); } function isTaskProgress(value: unknown): value is TaskProgress { if (typeof value !== 'object' || value === null) return false; const record = value as Record; return typeof record['solved'] === 'boolean' && typeof record['attempts'] === 'number'; } function isRecordOf(value: unknown, guard: (item: unknown) => item is T): boolean { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; return Object.values(value).every((item) => guard(item)); } /** Структурная валидация payload прогресса (V1-поля; updatedAt не требуется). */ function isProgressV1(value: unknown): value is ProgressV1 { if (typeof value !== 'object' || value === null) return false; const record = value as Record; const streak = record['streak']; if (typeof streak !== 'object' || streak === null) return false; const streakRecord = streak as Record; return ( isRecordOf(record['lessons'], isLessonProgress) && isRecordOf(record['tasks'], isTaskProgress) && typeof streakRecord['lastDay'] === 'string' && typeof streakRecord['current'] === 'number' ); } /** * Публичная проверка формы прогресса — для sync-client (валидация payload, * пришедшего с сервера, до локальной записи). updatedAt не проверяется: * серверная метка подставляется отдельно. */ export function isProgressPayload(value: unknown): value is ProgressV1 { return isProgressV1(value); } /** * Миграция V1→V2: payload без updatedAt получает updatedAt = clock() * (момент миграции); payload с updatedAt-строкой — уже V2. null — updatedAt * испорчен (не строка): дальше сработают дефолты с recovered. */ function migrateProgressV2(payload: ProgressV1, clock: Clock): ProgressV2 | null { const updatedAt = (payload as unknown as Record)['updatedAt']; if (updatedAt === undefined) return { ...payload, updatedAt: clock() }; if (typeof updatedAt === 'string') return payload as ProgressV2; return null; } /** * Чтение прогресса: нет ключа → дефолты (recovered: false); payload V1 → * миграция в V2 (до дефолтов, контракт конверта); битый JSON / несовпадение * checksum / неизвестная version / испорченный updatedAt → дефолты + * recovered: true. Clock инжектируется для детерминированных тестов. */ export function loadProgress( storage?: StorageLike, clock: Clock = defaultClock, ): { progress: ProgressV2; recovered: boolean; } { const store = storage ?? defaultStorage(); if (store === null) return { progress: DEFAULT_PROGRESS, recovered: false }; const raw = store.getItem(PROGRESS_KEY); if (raw === null) return { progress: DEFAULT_PROGRESS, recovered: false }; const parsed = parseEnvelope(raw, PROGRESS_VERSION, isProgressV1); if (parsed === null) return { progress: DEFAULT_PROGRESS, recovered: true }; const migrated = migrateProgressV2(parsed, clock); if (migrated === null) return { progress: DEFAULT_PROGRESS, recovered: true }; return { progress: migrated, recovered: false }; } /** * Запись прогресса: updatedAt перевыставляется в clock() при КАЖДОЙ записи * (контракт этапа 8) — через saveProgress проходят все сохранители * (авто-прогресс задач, markLessonDone-страница урока). Чистые функции * (touchStreak, markLessonDone) updatedAt не трогают: штамп — здесь. * Без хранилища — тихий no-op. */ export function saveProgress( progress: ProgressV1, storage?: StorageLike, clock: Clock = defaultClock, ): void { writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, { ...progress, updatedAt: clock() }, storage); } /** * Прямая запись снимка V2 БЕЗ сдвига updatedAt. Только для sync-client * (pull и подтверждение push): серверная метка сохраняется как есть, иначе * следующая сверка увидела бы «локальное изменение» и ушла бы в ложный push. * Обычные правки прогресса — через saveProgress. */ export function writeProgressSnapshot(progress: ProgressV2, storage?: StorageLike): void { writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, progress, storage); } /** Вчерашняя дата относительно 'YYYY-MM-DD' (UTC-арифметика, без DST). */ function previousDay(today: string): string | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today); if (match === null) return null; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const time = Date.UTC(year, month - 1, day); const date = new Date(time); // Отсекаем невалидные даты вида 2026-02-31 (Date их «прокручивает»). if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1) return null; const prev = new Date(time - 86_400_000); const yyyy = prev.getUTCFullYear(); const mm = String(prev.getUTCMonth() + 1).padStart(2, '0'); const dd = String(prev.getUTCDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } /** * Серия дней (чистая): lastDay == today → без изменений; lastDay == вчера → * current+1; иначе (разрыв, пустая или битая дата) → 1. today — 'YYYY-MM-DD' * локальной даты. */ export function touchStreak(progress: ProgressV1, today: string): ProgressV1 { const { streak } = progress; if (streak.lastDay === today) return progress; const current = previousDay(today) === streak.lastDay ? streak.current + 1 : 1; return { ...progress, streak: { lastDay: today, current } }; } /** * Ручная отметка «урок пройден» (этап 7, чистая): lessons[id].done = true, * tasksSolved сохраняются, серия дней — через touchStreak. today — 'YYYY-MM-DD' * локальной даты. Уже пройденный урок — без изменений записи урока. */ export function markLessonDone(progress: ProgressV1, lessonId: string, today: string): ProgressV1 { const lesson = progress.lessons[lessonId]; const lessons = { ...progress.lessons, [lessonId]: { done: true, tasksSolved: lesson?.tasksSolved ?? [] }, }; return touchStreak({ ...progress, lessons }, today); }