Recette 1h : courbe, lignes de partie, en-tête, bouton des exercices, carte de l'accueil
Dernier point plus gros, vert seulement si le taux baisse ; valeurs au premier et au dernier point, axe réduit à la ligne du 0. Une ligne de partie = un lien vers le bilan, résultat court, date et niveau en caption. Plus de « Retour », titre en h1 ; « Nouvelle partie » pleine largeur en portrait ; « Demander des exercices à une IA ». La carte de l'accueil sans partie en cours ne montre plus la partie finie (testé). Phrase progression.hausse sans « un peu ». L'écran relit ses parties à chaque visite sans montrer celles de la précédente. Test navigateur sur trois courses jouées jusqu'au bout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2664486541
commit
c2c5d13651
+6
-5
@@ -1553,10 +1553,7 @@ export function startApp(options: AppOptions): void {
|
||||
// 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"),
|
||||
});
|
||||
const progressionView = createProgressionView({ section: ui.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;
|
||||
@@ -1589,10 +1586,14 @@ export function startApp(options: AppOptions): void {
|
||||
return games.filter((g): g is FinishedGame => g !== null);
|
||||
}
|
||||
|
||||
/** Ouvre l'écran : ce qui est déjà lu, puis les parties relues. */
|
||||
/** Ouvre l'écran : les parties relues à chaque visite (une partie a pu
|
||||
* finir entre-temps) ; d'ici là, l'écran dit qu'il les lit, sans
|
||||
* montrer la liste de la visite précédente comme si elle était à
|
||||
* jour. */
|
||||
function showProgression(): void {
|
||||
const request = ++progressionRequest;
|
||||
progressionTried.clear();
|
||||
progressionGames = null;
|
||||
renderProgression();
|
||||
options.engineReady
|
||||
.then(() => loadProgression())
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface HomeModel {
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** La miniature du plateau et le compte de points : seulement pour une
|
||||
* partie à reprendre. Sans partie en cours, la carte ne montre que son
|
||||
* titre et « Nouvelle partie », jamais la position de la partie finie
|
||||
* (recette 1h). */
|
||||
readonly board: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
@@ -45,6 +50,7 @@ export function homeModel(
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
board: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
@@ -243,7 +243,12 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
miniBox.hidden = !model.board;
|
||||
pips.hidden = !model.board;
|
||||
againstLine.hidden = !model.board;
|
||||
if (model.board) {
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
}
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
|
||||
@@ -11,14 +11,13 @@ 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",
|
||||
single: "",
|
||||
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. */
|
||||
@@ -30,22 +29,22 @@ export const PROGRESSION_TEXTS = {
|
||||
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",
|
||||
exercises: "Demander des exercices à une IA",
|
||||
listTitle: "Vos parties",
|
||||
/** « Gagnée, gammon (2 points) ». */
|
||||
/** Le résultat court : « Gagnée, 1 point », « Perdue, gammon, 2 points »
|
||||
* (jamais « partie simple »). */
|
||||
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")})`;
|
||||
const kind = result.kind === "single" ? "" : `${RESULT_KIND[result.kind]}, `;
|
||||
return `${who}, ${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;
|
||||
|
||||
@@ -37,8 +37,6 @@ export interface ProgressionData {
|
||||
|
||||
export interface ProgressionViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
/** Branche le lien « Retour » (il revient dans l'historique). */
|
||||
readonly back: (link: HTMLAnchorElement) => void;
|
||||
}
|
||||
|
||||
export interface ProgressionView {
|
||||
@@ -47,12 +45,10 @@ export interface ProgressionView {
|
||||
}
|
||||
|
||||
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). */
|
||||
/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus
|
||||
* des points, le dernier point plus gros). */
|
||||
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;
|
||||
const PAD = { left: 12, right: 12, top: 30, bottom: 12 };
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
@@ -95,17 +91,11 @@ function levelName(entry: ProgressionEntry): string {
|
||||
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", {
|
||||
// Pas de « Retour » (charte 1h) : la barre du haut et le retour du
|
||||
// téléphone suffisent, comme sur la liste des leçons.
|
||||
element(section, "h1", {
|
||||
id: "progression-title",
|
||||
className: "analysis-title",
|
||||
className: "progression-title",
|
||||
text: PROGRESSION_TEXTS.title,
|
||||
});
|
||||
const summary = element(section, "p", {
|
||||
@@ -153,7 +143,9 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
}
|
||||
if (data.entries.length === 0) {
|
||||
summary.textContent = PROGRESSION_TEXTS.empty;
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const actions = element(body, "div", {
|
||||
className: "progression-actions progression-empty",
|
||||
});
|
||||
const start = element(actions, "a", {
|
||||
id: "progression-new-game",
|
||||
className: "button",
|
||||
@@ -164,10 +156,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
return;
|
||||
}
|
||||
const points = progressionCurve(data.entries);
|
||||
summary.textContent =
|
||||
data.phrases === null
|
||||
? ""
|
||||
: progressionSentence(data.phrases, progressionTrend(points));
|
||||
const trend = progressionTrend(points);
|
||||
summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend);
|
||||
|
||||
if (points.length >= CURVE_MIN_GAMES) {
|
||||
const card = element(body, "section", {
|
||||
@@ -190,7 +180,10 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
text: PROGRESSION_TEXTS.whatIsIt,
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
|
||||
drawnCurve = () => drawCurve(svg, points);
|
||||
// Le vert dit « mieux », jamais « dernier » : seulement si le
|
||||
// taux baisse.
|
||||
const better = trend.kind === "down";
|
||||
drawnCurve = () => drawCurve(svg, points, better);
|
||||
drawnCurve();
|
||||
}
|
||||
|
||||
@@ -203,7 +196,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
}).setAttribute("role", "status");
|
||||
}
|
||||
|
||||
// « Créer des exercices » : la feuille du prompt, étape 1f ; bouton secondaire.
|
||||
// « Demander des exercices à une IA » : la feuille du prompt, étape
|
||||
// 1f ; bouton secondaire avec l'icône de copie.
|
||||
if (points.length > 0) {
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const exercises = element(actions, "a", {
|
||||
@@ -229,13 +223,15 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
li.dataset.id = entry.id;
|
||||
const date = PROGRESSION_TEXTS.date(entry.startedAt);
|
||||
const result = PROGRESSION_TEXTS.result(entry.result);
|
||||
// Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis
|
||||
// lui), chevron à droite.
|
||||
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",
|
||||
className: "caption",
|
||||
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
|
||||
});
|
||||
const rate = element(game, "span", { className: "progression-rate" });
|
||||
@@ -247,12 +243,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
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));
|
||||
game.append(icon("chevron"));
|
||||
return li;
|
||||
}
|
||||
|
||||
@@ -260,58 +251,55 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
* à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le
|
||||
* dernier point est plus gros, à l'encre du texte, en verdict-good si le
|
||||
* taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du
|
||||
* premier et du dernier point seulement (charte 1h). */
|
||||
function drawCurve(
|
||||
svg: SVGSVGElement,
|
||||
points: readonly { readonly id: string; readonly perDecision: number }[],
|
||||
better: boolean,
|
||||
): 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 top = Math.max(1, ...millis);
|
||||
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);
|
||||
}
|
||||
svgElement(svg, "line", {
|
||||
x1: PAD.left,
|
||||
x2: width - PAD.right,
|
||||
y1: y(0),
|
||||
y2: y(0),
|
||||
class: "curve-axis",
|
||||
});
|
||||
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
|
||||
svgElement(svg, "polyline", { points: line, class: "curve-line" });
|
||||
const lastIndex = points.length - 1;
|
||||
points.forEach((point, i) => {
|
||||
const last = i === points.length - 1;
|
||||
const last = i === lastIndex;
|
||||
const cy = y(point.perDecision * 1000);
|
||||
const dot = svgElement(svg, "circle", {
|
||||
cx: x(i),
|
||||
cy: y(point.perDecision * 1000),
|
||||
r: last ? 6 : 4,
|
||||
class: last ? "progression-dot last" : "progression-dot",
|
||||
cy,
|
||||
r: last ? 7 : 4,
|
||||
class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot",
|
||||
"data-id": point.id,
|
||||
"data-rate": String(point.perDecision),
|
||||
});
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
if (points.length <= LABELLED_POINTS) {
|
||||
if (i === 0 || last) {
|
||||
const value = svgElement(svg, "text", {
|
||||
x: x(i),
|
||||
y: y(point.perDecision * 1000) - 10,
|
||||
class: last ? "progression-value last" : "progression-value",
|
||||
"text-anchor": "middle",
|
||||
y: cy - (last ? 14 : 11),
|
||||
class: last
|
||||
? `progression-value last${better ? " better" : ""}`
|
||||
: "progression-value",
|
||||
"text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle",
|
||||
});
|
||||
value.textContent = String(millis[i]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user