Écran de progression (#/progression) : courbe du taux d'erreur, parties, exercices
La ligne de progression de l'accueil y mène. Les taux viennent des analyses rangées ; les parties non analysées le sont en arrière-plan. « Créer des exercices » ouvre le prompt du gabarit exercices, avec les thèmes des erreurs comptés sur les faits du moteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0493858c1a
commit
fdbbff50ea
+174
-16
@@ -34,6 +34,11 @@ import {
|
||||
masterWords,
|
||||
parsePhrases,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type FinishedGame,
|
||||
PROGRESSION_MAX_GAMES,
|
||||
progressionEntries,
|
||||
} from "../domain/analysis/progression.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
@@ -99,6 +104,7 @@ import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { createProgressionView } from "./progression-view.js";
|
||||
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
|
||||
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
|
||||
import {
|
||||
@@ -282,6 +288,7 @@ export function startApp(options: AppOptions): void {
|
||||
summaryVeil: byId("summary-veil"),
|
||||
promptVeil: byId("prompt-veil"),
|
||||
analysis: byId("analysis"),
|
||||
progression: byId("progression"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
|
||||
@@ -1467,6 +1474,18 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
case "glossary":
|
||||
return promptSource.concept(route.term ?? "", game?.journal ?? null);
|
||||
case "progression": {
|
||||
// Les parties analysées de l'écran, de la plus ancienne à la
|
||||
// plus récente : rien n'est analysé pour le prompt.
|
||||
const games = progressionGames ?? (await loadProgression());
|
||||
const journals = [...games]
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.map((g) => g.journal)
|
||||
.filter((journal) => readyForSummary(journal));
|
||||
return journals.length === 0
|
||||
? { message: ASK_AI_TEXTS.noAnalysedGame }
|
||||
: promptSource.exercises(journals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1504,20 +1523,142 @@ export function startApp(options: AppOptions): void {
|
||||
if (missingAnalyses(journal).length === 0) {
|
||||
return;
|
||||
}
|
||||
reviewRunner.start(id, journal, (analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
showReview(id, false);
|
||||
render();
|
||||
});
|
||||
reviewRunner.start(
|
||||
id,
|
||||
journal,
|
||||
(analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
progressionAnalysed(id, live?.journal ?? analysed);
|
||||
showReview(id, false);
|
||||
render();
|
||||
},
|
||||
// L'écran de progression attend peut-être cette analyse pour
|
||||
// lancer celle de la partie suivante.
|
||||
() => reviewProgression(),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Progression (1h)
|
||||
|
||||
// L'écran #/progression : les parties terminées (les plus récentes),
|
||||
// relues à chaque visite, avec leurs analyses rangées. Celles qui n'en
|
||||
// ont pas encore sont analysées en arrière-plan, une à une, la plus
|
||||
// récente d'abord ; rien n'est recalculé pour les autres.
|
||||
const progressionView = createProgressionView({
|
||||
section: ui.progression,
|
||||
back: (link) => leavingLink(link, (route) => route.screen === "progression"),
|
||||
});
|
||||
let progressionGames: FinishedGame[] | null = null;
|
||||
// Chaque lecture a son numéro : une lecture dépassée n'est pas dessinée.
|
||||
let progressionRequest = 0;
|
||||
// Les parties dont l'analyse a été lancée depuis l'ouverture de l'écran.
|
||||
const progressionTried = new Set<string>();
|
||||
|
||||
/** Les parties terminées les plus récentes, avec leur journal (celui
|
||||
* de la partie à l'écran, s'il s'agit d'elle). */
|
||||
async function loadProgression(): Promise<FinishedGame[]> {
|
||||
const list = await options.games.list();
|
||||
const over = list
|
||||
.filter((summary) => summary.status === "over")
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.slice(0, PROGRESSION_MAX_GAMES);
|
||||
const games = await Promise.all(
|
||||
over.map(async (summary) => {
|
||||
const journal =
|
||||
liveGame(summary.id)?.journal ?? (await options.games.journal(summary.id));
|
||||
return journal === null
|
||||
? null
|
||||
: {
|
||||
id: summary.id,
|
||||
startedAt: summary.startedAt,
|
||||
level: summary.level,
|
||||
result: summary.result,
|
||||
journal,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return games.filter((g): g is FinishedGame => g !== null);
|
||||
}
|
||||
|
||||
/** Ouvre l'écran : ce qui est déjà lu, puis les parties relues. */
|
||||
function showProgression(): void {
|
||||
const request = ++progressionRequest;
|
||||
progressionTried.clear();
|
||||
renderProgression();
|
||||
options.engineReady
|
||||
.then(() => loadProgression())
|
||||
.then(
|
||||
(games) => {
|
||||
if (request !== progressionRequest) {
|
||||
return;
|
||||
}
|
||||
progressionGames = games;
|
||||
renderProgression();
|
||||
reviewProgression();
|
||||
},
|
||||
() => {
|
||||
if (request === progressionRequest && progressionGames === null) {
|
||||
progressionView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (phrases === null) {
|
||||
phrasesLoading.then(
|
||||
() => renderProgression(),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgression(): void {
|
||||
if (navigation.route.screen !== "progression") {
|
||||
return;
|
||||
}
|
||||
progressionView.render(
|
||||
progressionGames === null
|
||||
? null
|
||||
: { entries: progressionEntries(progressionGames), phrases },
|
||||
);
|
||||
}
|
||||
|
||||
/** Une analyse rangée : la partie, si l'écran la montre, se met à
|
||||
* jour. */
|
||||
function progressionAnalysed(id: string, journal: GameJournal): void {
|
||||
if (progressionGames?.some((g) => g.id === id)) {
|
||||
progressionGames = progressionGames.map((g) => (g.id === id ? { ...g, journal } : g));
|
||||
renderProgression();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lance l'analyse de la prochaine partie de l'écran qui n'est pas
|
||||
* analysée (rien si une analyse tourne déjà : à sa fin, elle rappelle
|
||||
* cette fonction). */
|
||||
function reviewProgression(): void {
|
||||
if (
|
||||
navigation.route.screen !== "progression" ||
|
||||
progressionGames === null ||
|
||||
reviewRunner.running !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Une partie dont l'analyse a échoué n'est pas relancée sans fin :
|
||||
// une fois par visite de l'écran.
|
||||
const next = progressionGames.find(
|
||||
(g) => !progressionTried.has(g.id) && missingAnalyses(g.journal).length > 0,
|
||||
);
|
||||
if (next !== undefined) {
|
||||
progressionTried.add(next.id);
|
||||
review(next.id, next.journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'analyse en cours d'une partie qui n'est ni regardée ni à l'écran
|
||||
@@ -1763,6 +1904,8 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave((r) => r.screen === "lesson" || r.screen === "lessonEnd");
|
||||
} else if (route.screen === "lessons") {
|
||||
navigation.leave((r) => r.screen === "lessons");
|
||||
} else if (route.screen === "progression") {
|
||||
navigation.leave((r) => r.screen === "progression");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1924,8 +2067,17 @@ export function startApp(options: AppOptions): void {
|
||||
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
|
||||
const choosing = below.screen === "newGame";
|
||||
const analysing = below.screen === "analysis";
|
||||
const progression = below.screen === "progression";
|
||||
const page =
|
||||
help || position || home || tutorial || lessonList || lesson || choosing || analysing;
|
||||
help ||
|
||||
position ||
|
||||
home ||
|
||||
tutorial ||
|
||||
lessonList ||
|
||||
lesson ||
|
||||
choosing ||
|
||||
analysing ||
|
||||
progression;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1936,6 +2088,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.lessons.hidden = !lessonList;
|
||||
ui.lesson.hidden = !lesson;
|
||||
ui.analysis.hidden = !analysing;
|
||||
ui.progression.hidden = !progression;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
@@ -1978,6 +2131,11 @@ export function startApp(options: AppOptions): void {
|
||||
if (home) {
|
||||
homeProgress = null;
|
||||
}
|
||||
// La progression se relit à chaque arrivée sur l'écran (pas en
|
||||
// ouvrant ou en fermant la feuille « Créer des exercices »).
|
||||
if (progression && wasScreen !== "progression") {
|
||||
showProgression();
|
||||
}
|
||||
|
||||
// La marche à suivre pour installer : une feuille, comme le menu.
|
||||
if (route.screen === "install" && ui.installVeil.hidden) {
|
||||
@@ -2038,7 +2196,7 @@ export function startApp(options: AppOptions): void {
|
||||
() => openReview(id),
|
||||
() => undefined,
|
||||
);
|
||||
} else {
|
||||
} else if (route.screen !== "progression") {
|
||||
stopForeignReview();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
|
||||
@@ -21,10 +21,15 @@ export const ASK_AI_TEXTS = {
|
||||
summary: "Bilan",
|
||||
analysis: "Analyse",
|
||||
glossary: "Glossaire",
|
||||
progression: "Progression",
|
||||
},
|
||||
failed: "Le prompt n'a pas pu être préparé.",
|
||||
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
|
||||
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
missingTerm: "Ce terme n'est pas dans le glossaire.",
|
||||
noAnalysedGame:
|
||||
"Pas encore d'exercices : le maître n'a analysé aucune partie terminée. Ils seront prêts avec la première.",
|
||||
noMistake:
|
||||
"Pas d'exercices à proposer : sur vos parties analysées, vous avez joué comme le maître.",
|
||||
} as const;
|
||||
|
||||
@@ -36,6 +36,7 @@ export const FEATURES = [
|
||||
"docs/scenarios/demander-a-une-ia.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/suivre-une-lecon.feature",
|
||||
"docs/scenarios/suivre-ma-progression.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
|
||||
@@ -42,6 +42,8 @@ export const HOME_TEXTS = {
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
/** La ligne de progression, lien vers l'écran de progression (1h). */
|
||||
progressLink: (line: string) => `Votre progression : ${line}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
|
||||
+10
-4
@@ -6,8 +6,8 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La progression détaillée (1h) n'existe pas encore : l'accueil n'affiche
|
||||
// que ce qui existe.
|
||||
// La ligne de progression résumée mène à l'écran de progression
|
||||
// (#/progression, étape 1h).
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
@@ -219,10 +219,14 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
// La ligne de progression mène à l'écran de progression (1h).
|
||||
const progress = element(main, "a", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
progress.href = "#/progression";
|
||||
const progressText = element(progress, "span", { className: "home-progress-text" });
|
||||
progress.append(icon("chevron"));
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
@@ -293,7 +297,9 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
if (state.progress !== null && state.progress.played > 0) {
|
||||
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
|
||||
}
|
||||
progress.textContent = parts.join(" · ");
|
||||
progressText.textContent = parts.join(" · ");
|
||||
progress.hidden = parts.length === 0;
|
||||
progress.setAttribute("aria-label", HOME_TEXTS.progressLink(parts.join(" · ")));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Les textes de l'écran de progression (étape 1h ; ADR 0007, point 5). La
|
||||
// phrase de synthèse, elle, est une phrase du maître : elle vit dans
|
||||
// docs/maitre/phrases.md (sections progression.…, une seule source,
|
||||
// ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameResult } from "../domain/game/state.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
|
||||
|
||||
const RESULT_KIND = {
|
||||
single: "partie simple",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
} as const;
|
||||
|
||||
export const PROGRESSION_TEXTS = {
|
||||
title: "Votre progression",
|
||||
back: "Retour",
|
||||
loading: "Lecture de vos parties…",
|
||||
failed: "Vos parties n'ont pas pu être lues.",
|
||||
/** Aucune partie terminée. */
|
||||
empty: "Aucune partie terminée pour l'instant : votre progression commencera avec la première.",
|
||||
newGame: "Nouvelle partie",
|
||||
curveTitle: "Votre taux d'erreur · plus bas, c'est mieux",
|
||||
curveLabel: (rates: readonly number[]) =>
|
||||
`Votre taux d'erreur, partie après partie, de la plus ancienne à la plus récente : ${rates.join(", ")}.`,
|
||||
whatIsIt: "Qu'est-ce que le taux d'erreur ?",
|
||||
analysing: (n: number) =>
|
||||
`Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`,
|
||||
exercises: "Créer des exercices",
|
||||
listTitle: "Vos parties",
|
||||
/** « Gagnée, gammon (2 points) ». */
|
||||
result: (result: GameResult | null) => {
|
||||
if (result === null) {
|
||||
return "Partie terminée";
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
return `${who}, ${RESULT_KIND[result.kind]} (${plural(result.points, "point", "points")})`;
|
||||
},
|
||||
level: (name: string) => `contre ${name}`,
|
||||
rateLabel: "taux d'erreur",
|
||||
rateLoading: "en analyse",
|
||||
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
|
||||
analysis: "Analyse",
|
||||
analysisLabel: (date: string) => `Analyse de la partie du ${date}`,
|
||||
/** La date d'une partie : « 3 nov. 2026 ». */
|
||||
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
|
||||
} as const;
|
||||
@@ -0,0 +1,319 @@
|
||||
// L'écran de progression (#/progression, étape 1h ; charte,
|
||||
// ecrans-etape-1.md, « 1h ») : une phrase du maître, la courbe du taux
|
||||
// d'erreur partie après partie (SVG fait main), « Créer des exercices », et
|
||||
// la liste des parties terminées, chacune menant à son bilan et à son
|
||||
// analyse.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La vue ne connaît ni le stockage ni le moteur : l'écran de jeu (app.ts)
|
||||
// lui passe les parties et leur taux, lus dans les analyses rangées
|
||||
// (domain/analysis/progression.ts), et la redessine quand une analyse en
|
||||
// arrière-plan se termine.
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import { progressionSentence } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
errorRateMillis,
|
||||
type ProgressionEntry,
|
||||
progressionCurve,
|
||||
progressionTrend,
|
||||
} from "../domain/analysis/progression.js";
|
||||
import { computerLevelOf } from "../domain/game/level.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { PROGRESSION_TEXTS } from "./progression-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Ce que montre l'écran. */
|
||||
export interface ProgressionData {
|
||||
/** Les parties terminées, de la plus ancienne à la plus récente. */
|
||||
readonly entries: readonly ProgressionEntry[];
|
||||
/** Les phrases du maître (null : pas encore lues). */
|
||||
readonly phrases: MasterPhrases | null;
|
||||
}
|
||||
|
||||
export interface ProgressionViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
/** Branche le lien « Retour » (il revient dans l'historique). */
|
||||
readonly back: (link: HTMLAnchorElement) => void;
|
||||
}
|
||||
|
||||
export interface ProgressionView {
|
||||
/** Dessine l'écran (null : les parties se lisent ; failed : échec). */
|
||||
render(data: ProgressionData | null, failed?: boolean): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** Hauteur de la courbe, et ses marges intérieures (étiquettes de l'axe à
|
||||
* gauche, valeurs au-dessus des points). */
|
||||
const CURVE_HEIGHT = 168;
|
||||
const PAD = { left: 34, right: 16, top: 24, bottom: 12 };
|
||||
/** Au-delà, les valeurs ne s'écrivent plus au-dessus des points. */
|
||||
const LABELLED_POINTS = 10;
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attributes: Record<string, string | number>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const child = document.createElementNS(SVG_NS, tag);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
child.setAttribute(name, String(value));
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Le nom du niveau de l'ordinateur d'une partie. */
|
||||
function levelName(entry: ProgressionEntry): string {
|
||||
const level = computerLevelOf(entry.level);
|
||||
return level === null ? entry.level : MATCH_TEXTS.levels[level].name;
|
||||
}
|
||||
|
||||
export function createProgressionView(options: ProgressionViewOptions): ProgressionView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "div", { className: "analysis-head" });
|
||||
const back = element(head, "a", {
|
||||
id: "progression-back",
|
||||
className: "button secondary",
|
||||
text: PROGRESSION_TEXTS.back,
|
||||
});
|
||||
back.href = "#/accueil";
|
||||
options.back(back);
|
||||
element(head, "h2", {
|
||||
id: "progression-title",
|
||||
className: "analysis-title",
|
||||
text: PROGRESSION_TEXTS.title,
|
||||
});
|
||||
const summary = element(section, "p", {
|
||||
id: "progression-summary",
|
||||
className: "progression-summary",
|
||||
});
|
||||
summary.setAttribute("role", "status");
|
||||
const body = element(section, "div", { className: "progression-body" });
|
||||
// La courbe se redessine à la largeur de la page (rotation).
|
||||
let drawnCurve: (() => void) | null = null;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!section.hidden) {
|
||||
drawnCurve?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Ce qui est dessiné : chaque analyse rangée en arrière-plan redemande
|
||||
// l'écran, qui ne change que quand le taux d'une partie arrive.
|
||||
let drawn = "";
|
||||
|
||||
function render(data: ProgressionData | null, failed = false): void {
|
||||
const key = JSON.stringify(
|
||||
data === null
|
||||
? { failed }
|
||||
: {
|
||||
phrases: data.phrases !== null,
|
||||
entries: data.entries.map((e) => [
|
||||
e.id,
|
||||
e.result,
|
||||
e.level,
|
||||
e.errorRate?.perDecision ?? null,
|
||||
]),
|
||||
},
|
||||
);
|
||||
if (key === drawn) {
|
||||
return;
|
||||
}
|
||||
drawn = key;
|
||||
body.replaceChildren();
|
||||
drawnCurve = null;
|
||||
delete section.dataset.ready;
|
||||
if (data === null) {
|
||||
summary.textContent = failed ? PROGRESSION_TEXTS.failed : PROGRESSION_TEXTS.loading;
|
||||
return;
|
||||
}
|
||||
if (data.entries.length === 0) {
|
||||
summary.textContent = PROGRESSION_TEXTS.empty;
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const start = element(actions, "a", {
|
||||
id: "progression-new-game",
|
||||
className: "button",
|
||||
text: PROGRESSION_TEXTS.newGame,
|
||||
});
|
||||
start.href = routeHash({ screen: "newGame" });
|
||||
section.dataset.ready = "true";
|
||||
return;
|
||||
}
|
||||
const points = progressionCurve(data.entries);
|
||||
summary.textContent =
|
||||
data.phrases === null
|
||||
? ""
|
||||
: progressionSentence(data.phrases, progressionTrend(points));
|
||||
|
||||
if (points.length >= CURVE_MIN_GAMES) {
|
||||
const card = element(body, "section", {
|
||||
id: "progression-curve-card",
|
||||
className: "analysis-card",
|
||||
});
|
||||
element(card, "h3", { className: "overline", text: PROGRESSION_TEXTS.curveTitle });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "progression-curve";
|
||||
svg.setAttribute("class", "progression-curve");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
PROGRESSION_TEXTS.curveLabel(points.map((p) => errorRateMillis(p.perDecision))),
|
||||
);
|
||||
card.append(svg);
|
||||
const link = element(card, "a", {
|
||||
id: "progression-glossary",
|
||||
className: "link-button",
|
||||
text: PROGRESSION_TEXTS.whatIsIt,
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
|
||||
drawnCurve = () => drawCurve(svg, points);
|
||||
drawnCurve();
|
||||
}
|
||||
|
||||
const waiting = data.entries.filter((e) => e.errorRate === null).length;
|
||||
if (waiting > 0) {
|
||||
element(body, "p", {
|
||||
id: "progression-analysing",
|
||||
className: "muted",
|
||||
text: PROGRESSION_TEXTS.analysing(waiting),
|
||||
}).setAttribute("role", "status");
|
||||
}
|
||||
|
||||
// « Créer des exercices » : la feuille du prompt (1f), secondaire.
|
||||
if (points.length > 0) {
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const exercises = element(actions, "a", {
|
||||
id: "progression-exercises",
|
||||
className: "button secondary ask-ai",
|
||||
});
|
||||
exercises.href = routeHash({ screen: "progression", ai: true });
|
||||
exercises.append(icon("copy"), PROGRESSION_TEXTS.exercises);
|
||||
}
|
||||
|
||||
element(body, "h3", { className: "overline", text: PROGRESSION_TEXTS.listTitle });
|
||||
const list = element(body, "ol", { id: "progression-list", className: "progression-list" });
|
||||
// La plus récente d'abord, comme « Mes parties ».
|
||||
for (const entry of [...data.entries].reverse()) {
|
||||
list.append(item(entry));
|
||||
}
|
||||
section.dataset.ready = String(waiting === 0);
|
||||
}
|
||||
|
||||
function item(entry: ProgressionEntry): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "progression-item";
|
||||
li.dataset.id = entry.id;
|
||||
const date = PROGRESSION_TEXTS.date(entry.startedAt);
|
||||
const result = PROGRESSION_TEXTS.result(entry.result);
|
||||
const game = element(li, "a", { className: "progression-game" });
|
||||
game.href = routeHash({ screen: "summary", game: entry.id });
|
||||
game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date));
|
||||
const text = element(game, "span", { className: "progression-text" });
|
||||
element(text, "strong", { text: result });
|
||||
element(text, "span", {
|
||||
className: "muted",
|
||||
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
|
||||
});
|
||||
const rate = element(game, "span", { className: "progression-rate" });
|
||||
if (entry.errorRate === null) {
|
||||
rate.classList.add("pending");
|
||||
rate.textContent = PROGRESSION_TEXTS.rateLoading;
|
||||
} else {
|
||||
rate.dataset.rate = String(entry.errorRate.perDecision);
|
||||
element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) });
|
||||
element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel });
|
||||
}
|
||||
const analysis = element(li, "a", {
|
||||
className: "button secondary progression-analysis",
|
||||
text: PROGRESSION_TEXTS.analysis,
|
||||
});
|
||||
analysis.href = routeHash({ screen: "analysis", game: entry.id, turn: null });
|
||||
analysis.setAttribute("aria-label", PROGRESSION_TEXTS.analysisLabel(date));
|
||||
return li;
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
|
||||
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
|
||||
* à droite ; l'axe va de 0 au plus haut taux, en millièmes ; le dernier
|
||||
* point en verdict-good (charte). */
|
||||
function drawCurve(
|
||||
svg: SVGSVGElement,
|
||||
points: readonly { readonly id: string; readonly perDecision: number }[],
|
||||
): void {
|
||||
const width = Math.round(svg.getBoundingClientRect().width || 360);
|
||||
const height = CURVE_HEIGHT;
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.replaceChildren();
|
||||
const millis = points.map((p) => errorRateMillis(p.perDecision));
|
||||
// Le haut de l'axe : le plus haut taux, arrondi à la dizaine au-dessus.
|
||||
const top = Math.max(10, Math.ceil(Math.max(...millis) / 10) * 10);
|
||||
const inner = width - PAD.left - PAD.right;
|
||||
const step = points.length > 1 ? inner / (points.length - 1) : 0;
|
||||
const x = (i: number) => PAD.left + i * step;
|
||||
const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom);
|
||||
for (const level of [top, 0]) {
|
||||
svgElement(svg, "line", {
|
||||
x1: PAD.left,
|
||||
x2: width - PAD.right,
|
||||
y1: y(level),
|
||||
y2: y(level),
|
||||
class: "curve-axis",
|
||||
});
|
||||
const label = svgElement(svg, "text", {
|
||||
x: PAD.left - 6,
|
||||
y: y(level) + 4,
|
||||
class: "curve-label",
|
||||
"text-anchor": "end",
|
||||
});
|
||||
label.textContent = String(level);
|
||||
}
|
||||
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
|
||||
svgElement(svg, "polyline", { points: line, class: "curve-line" });
|
||||
points.forEach((point, i) => {
|
||||
const last = i === points.length - 1;
|
||||
const dot = svgElement(svg, "circle", {
|
||||
cx: x(i),
|
||||
cy: y(point.perDecision * 1000),
|
||||
r: last ? 6 : 4,
|
||||
class: last ? "progression-dot last" : "progression-dot",
|
||||
"data-id": point.id,
|
||||
"data-rate": String(point.perDecision),
|
||||
});
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
if (points.length <= LABELLED_POINTS) {
|
||||
const value = svgElement(svg, "text", {
|
||||
x: x(i),
|
||||
y: y(point.perDecision * 1000) - 10,
|
||||
class: last ? "progression-value last" : "progression-value",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
value.textContent = String(millis[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ConceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
exercisesPrompt,
|
||||
summaryPrompt,
|
||||
} from "../domain/prompts/prompts.js";
|
||||
import {
|
||||
@@ -40,6 +41,9 @@ export interface PromptsAccess {
|
||||
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
|
||||
/** La position d'exemple d'un concept et son évaluation. */
|
||||
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
|
||||
/** La position de la plus coûteuse des erreurs de ces parties (null :
|
||||
* aucune imprécision ni erreur). */
|
||||
readonly exercises: (journals: readonly GameJournal[]) => Promise<PromptPosition | null>;
|
||||
}
|
||||
|
||||
export interface PromptSourceOptions {
|
||||
@@ -56,6 +60,9 @@ export interface PromptSource {
|
||||
summary(journal: GameJournal): Promise<PromptResult>;
|
||||
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
|
||||
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h), sur les
|
||||
* parties analysées, de la plus ancienne à la plus récente. */
|
||||
exercises(journals: readonly GameJournal[]): Promise<PromptResult>;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -109,6 +116,17 @@ export function createPromptSource(options: PromptSourceOptions): PromptSource {
|
||||
]);
|
||||
return { text: summaryPrompt(all, phrases, { journal, position }) };
|
||||
},
|
||||
async exercises(journals) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.exercises(journals),
|
||||
]);
|
||||
if (position === null) {
|
||||
return { message: ASK_AI_TEXTS.noMistake };
|
||||
}
|
||||
return { text: exercisesPrompt(all, phrases, { journals, position }) };
|
||||
},
|
||||
async concept(term, journal) {
|
||||
const [all, entry, example] = await Promise.all([
|
||||
templates(),
|
||||
|
||||
+14
-1
@@ -54,6 +54,10 @@ export type Route =
|
||||
readonly turn: number | null;
|
||||
readonly ai?: boolean;
|
||||
}
|
||||
/** #/progression : l'historique des parties et la courbe du taux
|
||||
* d'erreur (étape 1h) ; le prompt « Créer des exercices » :
|
||||
* #/progression/ia. */
|
||||
| { readonly screen: "progression"; readonly ai?: boolean }
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
@@ -130,6 +134,12 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "about" } : unknown;
|
||||
case "menu":
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "progression":
|
||||
return name === null
|
||||
? { screen: "progression" }
|
||||
: name === "ia"
|
||||
? { screen: "progression", ai: true }
|
||||
: unknown;
|
||||
case "nouvelle-partie":
|
||||
return name === null ? { screen: "newGame" } : unknown;
|
||||
case "explication":
|
||||
@@ -146,7 +156,7 @@ export function parseRoute(hash: string): Route {
|
||||
/** Les écrans qui ont une feuille de prompt pour une IA. */
|
||||
export type AiRoute = Extract<
|
||||
Route,
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" | "progression" }
|
||||
>;
|
||||
|
||||
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
|
||||
@@ -155,6 +165,7 @@ function aiRoute(route: Route): AiRoute | null {
|
||||
case "explanation":
|
||||
case "summary":
|
||||
case "analysis":
|
||||
case "progression":
|
||||
return route.ai === true ? null : { ...route, ai: true };
|
||||
case "glossary":
|
||||
return route.term === null || route.ai === true ? null : { ...route, ai: true };
|
||||
@@ -303,6 +314,8 @@ function screenHash(route: Route): string {
|
||||
return route.turn === null
|
||||
? `#/parties/${route.game}/analyse`
|
||||
: `#/parties/${route.game}/analyse/${route.turn}`;
|
||||
case "progression":
|
||||
return "#/progression";
|
||||
case "menu":
|
||||
return "#/menu";
|
||||
case "newGame":
|
||||
|
||||
@@ -225,6 +225,9 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
|
||||
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
|
||||
play.setAttribute("role", "img");
|
||||
// Le taux d'erreur du bilan, que la courbe de la progression (1h)
|
||||
// reprend tel quel (vérifié par tests/browser/progression.mjs).
|
||||
play.dataset.errorRate = String(summary.player.errorRate.perDecision);
|
||||
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
|
||||
const track = element(play, "span", { className: "gauge-track" });
|
||||
element(track, "span", {
|
||||
|
||||
Reference in New Issue
Block a user