From e9ef965438544cea586b298a28a8444598229d3a Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Thu, 12 Nov 2026 10:18:07 +0100 Subject: [PATCH] =?UTF-8?q?Progression=20des=20le=C3=A7ons=20dans=20le=20s?= =?UTF-8?q?tockage=20des=20parties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle clé « lessons » du magasin settings d'IndexedDB (et ustagammon.lessons en repli), format versionné relu par readLessonProgress. Amendement de l'ADR 0007. Co-Authored-By: Claude Opus 5.5 --- app/src/domain/learning/lessons.ts | 184 ++++++++++++++++++ app/src/storage/game-library.ts | 4 + app/src/storage/indexeddb-backend.ts | 12 +- app/src/storage/key-value-backend.ts | 4 + app/src/storage/lesson-store.ts | 48 +++++ app/src/storage/open-library.ts | 31 ++- ...007-architecture-et-conventions-de-code.md | 25 +++ 7 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 app/src/domain/learning/lessons.ts create mode 100644 app/src/storage/lesson-store.ts diff --git a/app/src/domain/learning/lessons.ts b/app/src/domain/learning/lessons.ts new file mode 100644 index 0000000..46c9a1e --- /dev/null +++ b/app/src/domain/learning/lessons.ts @@ -0,0 +1,184 @@ +// La progression dans les leçons (ADR 0011, étape 1g) : où en est chaque +// leçon, et laquelle proposer ensuite. Code pur, testé sous Node +// (app/tests/lessons.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Le contenu des leçons vit dans docs/lecons/ (ADR 0008) ; ici, une leçon +// n'est que son numéro et les étapes qui portent un exercice « à vous ». +// La progression est enregistrée telle quelle (ADR 0007, amendement du +// 2026-11-12) : son format est durable, et readLessonProgress relit toute +// version enregistrée. + +/** Version du format de la progression enregistrée. */ +export const LESSON_PROGRESS_VERSION = 1; + +/** reached quand l'écran de fin d'une leçon a été vu. */ +export const LESSON_END = 99; + +/** Où en est une leçon. */ +export interface LessonRecord { + /** La dernière étape vue (0 : aucune ; LESSON_END : la fin). */ + readonly last: number; + /** L'étape la plus loin déjà vue : la barre de progression ne mène + * qu'aux étapes vues. */ + readonly reached: number; + /** Les étapes dont l'exercice « à vous » a été réussi, dans l'ordre. */ + readonly solved: readonly number[]; +} + +/** La progression dans toutes les leçons, par numéro de leçon. */ +export interface LessonProgress { + readonly version: typeof LESSON_PROGRESS_VERSION; + readonly lessons: Readonly>; +} + +export const NO_PROGRESS: LessonProgress = { version: LESSON_PROGRESS_VERSION, lessons: {} }; + +const NO_RECORD: LessonRecord = { last: 0, reached: 0, solved: [] }; + +/** Ce qu'une leçon offre à la progression : son numéro et les étapes qui + * ont un exercice « à vous ». */ +export interface LessonOutline { + readonly number: number; + readonly exercises: readonly number[]; +} + +function step(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= LESSON_END + ? value + : null; +} + +/** + * La progression enregistrée, relue : absente (avant l'étape 1g) ou + * illisible, elle est vide ; une leçon mal formée est oubliée, sans toucher + * aux autres. Une version inconnue (enregistrée par une version future de + * l'application) est lue comme vide plutôt que mal comprise. + */ +export function readLessonProgress(value: unknown): LessonProgress { + if (value === null || typeof value !== "object") { + return NO_PROGRESS; + } + const raw = value as { version?: unknown; lessons?: unknown }; + if (raw.version !== LESSON_PROGRESS_VERSION) { + return NO_PROGRESS; + } + if (raw.lessons === null || typeof raw.lessons !== "object") { + return NO_PROGRESS; + } + const lessons: Record = {}; + for (const [key, entry] of Object.entries(raw.lessons as Record)) { + if (!/^[1-9][0-9]?$/.test(key) || entry === null || typeof entry !== "object") { + continue; + } + const record = entry as { last?: unknown; reached?: unknown; solved?: unknown }; + const last = step(record.last); + const reached = step(record.reached); + const solved = Array.isArray(record.solved) + ? record.solved.map(step).filter((s): s is number => s !== null && s > 0) + : null; + if (last === null || reached === null || solved === null) { + continue; + } + lessons[key] = { last, reached, solved: [...new Set(solved)].sort((a, b) => a - b) }; + } + return { version: LESSON_PROGRESS_VERSION, lessons }; +} + +/** Où en est la leçon number (rien de fait si elle n'a jamais été ouverte). */ +export function lessonRecord(progress: LessonProgress, number: number): LessonRecord { + return progress.lessons[String(number)] ?? NO_RECORD; +} + +function withRecord( + progress: LessonProgress, + number: number, + change: (record: LessonRecord) => LessonRecord, +): LessonProgress { + return { + version: LESSON_PROGRESS_VERSION, + lessons: { ...progress.lessons, [String(number)]: change(lessonRecord(progress, number)) }, + }; +} + +/** La progression après l'arrivée sur une étape d'une leçon (LESSON_END : + * sa fin). */ +export function withVisit(progress: LessonProgress, number: number, at: number): LessonProgress { + return withRecord(progress, number, (record) => ({ + ...record, + last: at, + reached: Math.max(record.reached, at), + })); +} + +/** La progression après la réussite de l'exercice d'une étape. */ +export function withSolved(progress: LessonProgress, number: number, at: number): LessonProgress { + return withRecord(progress, number, (record) => + record.solved.includes(at) + ? record + : { ...record, solved: [...record.solved, at].sort((a, b) => a - b) }, + ); +} + +/** L'état d'une leçon, tel que l'affichent la liste et l'accueil : à + * commencer, en cours (« 2 exercices sur 4 »), terminée (tous ses + * exercices réussis). */ +export type LessonState = + | { readonly kind: "toStart"; readonly total: number } + | { readonly kind: "inProgress"; readonly solved: number; readonly total: number } + | { readonly kind: "done"; readonly total: number }; + +export function lessonState(progress: LessonProgress, outline: LessonOutline): LessonState { + const record = lessonRecord(progress, outline.number); + const total = outline.exercises.length; + const solved = outline.exercises.filter((s) => record.solved.includes(s)).length; + if (solved === total && (total > 0 || record.reached === LESSON_END)) { + return { kind: "done", total }; + } + if (solved === 0 && record.reached === 0) { + return { kind: "toStart", total }; + } + return { kind: "inProgress", solved, total }; +} + +/** + * La leçon à proposer ensuite (carte de l'accueil, « Continuer » de la + * liste) : la première en cours, sinon la première à commencer, sinon + * aucune (toutes terminées). L'ordre est conseillé, jamais imposé. + */ +export function nextLesson( + progress: LessonProgress, + outlines: readonly LessonOutline[], +): LessonOutline | null { + const states = outlines.map((outline) => ({ outline, state: lessonState(progress, outline) })); + return ( + states.find((s) => s.state.kind === "inProgress")?.outline ?? + states.find((s) => s.state.kind === "toStart")?.outline ?? + null + ); +} + +/** + * L'étape où reprendre une leçon : là où on l'a quittée ; après sa fin, le + * premier exercice pas encore réussi ; sinon le début. + */ +export function resumeStep(progress: LessonProgress, outline: LessonOutline): number { + const record = lessonRecord(progress, outline.number); + if (record.last > 0 && record.last < LESSON_END) { + return record.last; + } + if (record.last === LESSON_END) { + return outline.exercises.find((s) => !record.solved.includes(s)) ?? 1; + } + return 1; +} + +/** Le nombre de leçons terminées. */ +export function finishedLessons( + progress: LessonProgress, + outlines: readonly LessonOutline[], +): number { + return outlines.filter((outline) => lessonState(progress, outline).kind === "done").length; +} diff --git a/app/src/storage/game-library.ts b/app/src/storage/game-library.ts index 4d7703c..e4207c0 100644 --- a/app/src/storage/game-library.ts +++ b/app/src/storage/game-library.ts @@ -59,6 +59,10 @@ export interface LibraryBackend { remove(id: string): Promise; currentId(): Promise; setCurrentId(id: string): Promise; + /** La progression dans les leçons (étape 1g), telle qu'enregistrée + * (undefined : jamais enregistrée) ; relue par readLessonProgress. */ + lessonProgress(): Promise; + saveLessonProgress(progress: unknown): Promise; } export interface LibraryOptions { diff --git a/app/src/storage/indexeddb-backend.ts b/app/src/storage/indexeddb-backend.ts index 1821c8a..7407de9 100644 --- a/app/src/storage/indexeddb-backend.ts +++ b/app/src/storage/indexeddb-backend.ts @@ -6,7 +6,8 @@ // Base « ustagammon », trois magasins d'objets : // - games : les résumés des parties (SavedGameSummary), clé id ; // - journals : leurs journaux, { id, journal }, clé id ; -// - settings : { key, value }, dont la partie en cours (key « current »). +// - settings : { key, value }, dont la partie en cours (key « current ») et +// la progression dans les leçons (key « lessons », étape 1g). // Le résumé et le journal d'une partie sont écrits dans la même transaction. // L'API IndexedDB est utilisée directement, sans bibliothèque. @@ -19,6 +20,9 @@ const VERSION = 1; const GAMES = "games"; const JOURNALS = "journals"; const SETTINGS = "settings"; +/** Clé de la progression dans les leçons, dans le magasin settings (étape + * 1g : nouvelle clé, même schéma, version 1 de la base). */ +const LESSONS = "lessons"; /** Le résultat d'une requête IndexedDB, en promesse. */ function done(request: IDBRequest): Promise { @@ -77,5 +81,11 @@ export async function openIndexedDbBackend(factory: IDBFactory): Promise (await get<{ value: unknown }>(SETTINGS, LESSONS))?.value, + saveLessonProgress: async (progress) => { + const transaction = db.transaction(SETTINGS, "readwrite"); + transaction.objectStore(SETTINGS).put({ key: LESSONS, value: progress }); + await committed(transaction); + }, }; } diff --git a/app/src/storage/key-value-backend.ts b/app/src/storage/key-value-backend.ts index 1a06e01..ebf0d69 100644 --- a/app/src/storage/key-value-backend.ts +++ b/app/src/storage/key-value-backend.ts @@ -19,6 +19,8 @@ export interface KeyValueStorage { const SUMMARIES = "ustagammon.games"; const CURRENT = "ustagammon.current-game-id"; const journalKey = (id: string) => `ustagammon.game.${id}`; +/** La progression dans les leçons (étape 1g). */ +const LESSONS = "ustagammon.lessons"; /** La partie unique des versions précédentes (étape 0), à migrer. */ const LEGACY_GAME = "ustagammon.current-game"; @@ -60,6 +62,8 @@ export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend return typeof id === "string" ? id : undefined; }, setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)), + lessonProgress: async () => read(storage, LESSONS), + saveLessonProgress: async (progress) => storage.setItem(LESSONS, JSON.stringify(progress)), }; } diff --git a/app/src/storage/lesson-store.ts b/app/src/storage/lesson-store.ts new file mode 100644 index 0000000..009bdf8 --- /dev/null +++ b/app/src/storage/lesson-store.ts @@ -0,0 +1,48 @@ +// La progression dans les leçons (étape 1g), dans le stockage des parties +// (IndexedDB, ou localStorage en repli) : une seule valeur, relue par le +// domaine (readLessonProgress), qui en connaît le format. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import { type LessonProgress, readLessonProgress } from "../domain/learning/lessons.js"; +import type { LibraryBackend } from "./game-library.js"; + +export interface LessonStore { + /** La progression enregistrée (vide si rien n'est enregistré ou si le + * stockage est illisible). */ + load(): Promise; + /** Enregistre la progression ; les enregistrements se font dans l'ordre + * des appels. Un stockage refusé ne fait pas échouer l'appel. */ + save(progress: LessonProgress): Promise; +} + +export function createLessonStore( + backend: Pick, +): LessonStore { + let queue: Promise = Promise.resolve(); + const inTurn = (task: () => Promise): Promise => { + const run = queue.then(task, task); + queue = run.catch(() => undefined); + return run; + }; + return { + load: () => + inTurn(async () => { + try { + return readLessonProgress(await backend.lessonProgress()); + } catch { + return readLessonProgress(undefined); + } + }), + save: (progress) => + inTurn(async () => { + try { + await backend.saveLessonProgress(progress); + } catch { + // Stockage plein ou refusé : la progression reste en + // mémoire pour cette visite. + } + }), + }; +} diff --git a/app/src/storage/open-library.ts b/app/src/storage/open-library.ts index 98e7c88..92c67ac 100644 --- a/app/src/storage/open-library.ts +++ b/app/src/storage/open-library.ts @@ -4,13 +4,19 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). -import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js"; +import { + createGameLibrary, + type GameLibrary, + type LibraryBackend, + type LibraryOptions, +} from "./game-library.js"; import { openIndexedDbBackend } from "./indexeddb-backend.js"; import { createKeyValueBackend, type KeyValueStorage, migrateLegacyGame, } from "./key-value-backend.js"; +import { createLessonStore, type LessonStore } from "./lesson-store.js"; /** Les stockages du navigateur ; chacun peut manquer ou être refusé. */ export interface BrowserStorage { @@ -23,6 +29,8 @@ export interface OpenedLibrary { /** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule * mémoire de la page (rien n'est gardé après sa fermeture). */ readonly kind: "indexeddb" | "localStorage" | "memory"; + /** La progression dans les leçons, dans le même stockage. */ + readonly lessons: LessonStore; } /** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne @@ -55,7 +63,7 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS); }), ]); - return { library: createGameLibrary(backend, options), kind: "indexeddb" }; + return opened(backend, options, "indexeddb"); } catch { // Indisponible (navigation privée de certains navigateurs) : repli. } finally { @@ -63,14 +71,21 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise