chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

342
apps/web/src/lib/storage.ts Normal file
View file

@ -0,0 +1,342 @@
/**
* Хранение настроек в 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; миграция V1V2 при чтении (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);
}
/**
* Миграция V1V2: 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);
}