go-learn/apps/web/src/lib/storage.ts
sab.code.lab 1c85091186 chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:34:02 +02:00

342 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Хранение настроек в 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<T> {
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<string, unknown>;
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<T>(
raw: string,
version: number,
guard: (value: unknown) => value is T,
): T | null {
let envelope: StorageEnvelope<unknown>;
try {
envelope = JSON.parse(raw) as StorageEnvelope<unknown>;
} 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<T>(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<T> = {
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<Record<string, LessonProgress>>; // key = lesson id
readonly tasks: Readonly<Record<string, TaskProgress>>; // 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<string, unknown>;
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<string, unknown>;
return typeof record['solved'] === 'boolean' && typeof record['attempts'] === 'number';
}
function isRecordOf<T>(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<string, unknown>;
const streak = record['streak'];
if (typeof streak !== 'object' || streak === null) return false;
const streakRecord = streak as Record<string, unknown>;
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<string, unknown>)['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);
}