chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
342
apps/web/src/lib/storage.ts
Normal file
342
apps/web/src/lib/storage.ts
Normal 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; миграция 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue