judu/ex-egopublic Fork
⑂ 892cf36
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

fix(taches): le stepper de date ne ment plus sur le libellé du jourUnverified

Le JS des ± 1 jour réécrivait le libellé de son côté ; il ne fait plus que
remplir le motif et les noms fournis par le serveur (period::DateVocab).
Ajoute au passage l'outillage de screencast de démo (docs/slides/demo).
Julien Durillon committed 2026-08-17T00:11:08+02:00 Browse files
892cf36 parent: 5d9c31d
modified .gitignore +1 -0
@@ -1,3 +1,4 @@
11 /target
22 .clever.json
33 /_site
4+/docs/slides/demo/out
@@ -1,3 +1,4 @@
1 /target1 /target
2 .clever.json2 .clever.json
3 /_site3 /_site
4+/docs/slides/demo/out
modified docs/slides/README.md +34 -0
@@ -42,3 +42,37 @@ de `clever env`.
4242
4343 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture
4444 `stats*` vise la semaine ISO précédente, toujours pleine.
45+
46+## Enregistrer la vidéo de démo (`demo/out/`)
47+
48+`demo/video.mjs` rejoue la prise en main d'une **équipe neuve** et l'enregistre
49+en vidéo (~4 min 40, 780×1688) : connexion, création des quatre catégories,
50+quatre saisies de temps (stepper, presets, clavier, date), les statistiques et
51+le détail d'un segment, le minuteur lancé puis la création de tâches pendant
52+qu'il tourne, l'arrêt depuis la pill de l'onglet Tâches et l'enregistrement de
53+la mesure. Chaque geste est joué à vitesse humaine, curseur tactile incrusté,
54+avec une pause à chaque changement d'écran pour la voix off au montage.
55+
56+Le jeu de données n'est pas celui des captures : équipe `Chez nous`, comptes
57+`alex` (filmé) et `lou` — le scénario suppose l'équipe **vide** au départ.
58+
59+1. Équipe et comptes (une fois) — voir l'en-tête de `demo/video-reset.sql`.
60+2. Avant **chaque** prise, remettre l'équipe à zéro :
61+
62+ ```bash
63+ psql -X "$DATABASE_URL" -f demo/video-reset.sql
64+ ```
65+
66+3. Serveur local puis enregistrement :
67+
68+ ```bash
69+ cd docs/slides/demo
70+ TT_USER=alex TT_PASS=… node video.mjs
71+ ```
72+
73+Sortie : `out/demo.webm` (brut de capture, VP9) et `out/demo.mp4` (H.264,
74+c'est celui qu'on monte). `out/` est ignoré par git.
75+
76+`TT_SPEED=6` rejoue tout le scénario en ~1 min 20 pour vérifier qu'il passe
77+sans attendre la prise complète — les durées de commentaire sont divisées,
78+les attentes mécaniques (navigations, fins de défilement) ne le sont pas.
@@ -42,3 +42,37 @@ de `clever env`.
42 42
43 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture43 10 écrans sont capturés en 390×844 @2x (thème Organic). La capture
44 `stats*` vise la semaine ISO précédente, toujours pleine.44 `stats*` vise la semaine ISO précédente, toujours pleine.
45+
46+## Enregistrer la vidéo de démo (`demo/out/`)
47+
48+`demo/video.mjs` rejoue la prise en main d'une **équipe neuve** et l'enregistre
49+en vidéo (~4 min 40, 780×1688) : connexion, création des quatre catégories,
50+quatre saisies de temps (stepper, presets, clavier, date), les statistiques et
51+le détail d'un segment, le minuteur lancé puis la création de tâches pendant
52+qu'il tourne, l'arrêt depuis la pill de l'onglet Tâches et l'enregistrement de
53+la mesure. Chaque geste est joué à vitesse humaine, curseur tactile incrusté,
54+avec une pause à chaque changement d'écran pour la voix off au montage.
55+
56+Le jeu de données n'est pas celui des captures : équipe `Chez nous`, comptes
57+`alex` (filmé) et `lou` — le scénario suppose l'équipe **vide** au départ.
58+
59+1. Équipe et comptes (une fois) — voir l'en-tête de `demo/video-reset.sql`.
60+2. Avant **chaque** prise, remettre l'équipe à zéro :
61+
62+ ```bash
63+ psql -X "$DATABASE_URL" -f demo/video-reset.sql
64+ ```
65+
66+3. Serveur local puis enregistrement :
67+
68+ ```bash
69+ cd docs/slides/demo
70+ TT_USER=alex TT_PASS=… node video.mjs
71+ ```
72+
73+Sortie : `out/demo.webm` (brut de capture, VP9) et `out/demo.mp4` (H.264,
74+c'est celui qu'on monte). `out/` est ignoré par git.
75+
76+`TT_SPEED=6` rejoue tout le scénario en ~1 min 20 pour vérifier qu'il passe
77+sans attendre la prise complète — les durées de commentaire sont divisées,
78+les attentes mécaniques (navigations, fins de défilement) ne le sont pas.
added docs/slides/demo/video-reset.sql +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+-- Remet l'équipe de démo vidéo à l'état « équipe neuve » : deux membres,
2+-- rien d'autre. À jouer sur la base DEV avant chaque prise (jamais la prod !) :
3+--
4+-- psql -X "$DATABASE_URL" -f video-reset.sql
5+--
6+-- Prérequis (une seule fois, via la CLI qui hash les mots de passe) :
7+--
8+-- exego team add "Chez nous"
9+-- exego user add alex --team "Chez nous" --password <mdp>
10+-- exego user add lou --team "Chez nous" --password <mdp>
11+--
12+-- `alex` est le compte filmé, `lou` le binôme (il apparaît dans les membres
13+-- de l'équipe et dans les puces d'assignation des tâches).
14+
15+BEGIN;
16+
17+DELETE FROM time_entries
18+ WHERE category_id IN (SELECT id FROM categories
19+ WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'));
20+
21+-- todo_state_changes et todo_assignees partent en cascade.
22+DELETE FROM todos
23+ WHERE category_id IN (SELECT id FROM categories
24+ WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'));
25+
26+DELETE FROM categories WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous');
27+
28+-- Minuteur ou mesure en attente d'une prise précédente.
29+DELETE FROM timers WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous');
30+
31+-- Invitations reçues : elles poseraient un badge sur l'onglet Réglages.
32+DELETE FROM invitations WHERE invitee_id IN (SELECT id FROM users WHERE username IN ('alex', 'lou'));
33+
34+COMMIT;
new file mode 100644
@@ -0,0 +1,34 @@
1+-- Remet l'équipe de démo vidéo à l'état « équipe neuve » : deux membres,
2+-- rien d'autre. À jouer sur la base DEV avant chaque prise (jamais la prod !) :
3+--
4+-- psql -X "$DATABASE_URL" -f video-reset.sql
5+--
6+-- Prérequis (une seule fois, via la CLI qui hash les mots de passe) :
7+--
8+-- exego team add "Chez nous"
9+-- exego user add alex --team "Chez nous" --password <mdp>
10+-- exego user add lou --team "Chez nous" --password <mdp>
11+--
12+-- `alex` est le compte filmé, `lou` le binôme (il apparaît dans les membres
13+-- de l'équipe et dans les puces d'assignation des tâches).
14+
15+BEGIN;
16+
17+DELETE FROM time_entries
18+ WHERE category_id IN (SELECT id FROM categories
19+ WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'));
20+
21+-- todo_state_changes et todo_assignees partent en cascade.
22+DELETE FROM todos
23+ WHERE category_id IN (SELECT id FROM categories
24+ WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous'));
25+
26+DELETE FROM categories WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous');
27+
28+-- Minuteur ou mesure en attente d'une prise précédente.
29+DELETE FROM timers WHERE team_id = (SELECT id FROM teams WHERE name = 'Chez nous');
30+
31+-- Invitations reçues : elles poseraient un badge sur l'onglet Réglages.
32+DELETE FROM invitations WHERE invitee_id IN (SELECT id FROM users WHERE username IN ('alex', 'lou'));
33+
34+COMMIT;
added docs/slides/demo/video.mjs +498 -0
new file mode 100644
@@ -0,0 +1,498 @@
1+// Vidéo de démo : une équipe neuve prise en main de bout en bout.
2+//
3+// Rejoue le parcours complet dans un Chrome piloté (viewport mobile 390×844)
4+// et l'enregistre en vidéo. Le rythme est celui d'un humain : chaque geste est
5+// précédé d'un déplacement de curseur visible, chaque changement d'écran laisse
6+// une pause de commentaire (voix off ou sous-titres au montage).
7+//
8+// Prérequis :
9+// 1. un serveur exego local sur la base DEV (jamais la prod) ;
10+// 2. l'équipe et les comptes de démo créés (cf. video-reset.sql) ;
11+// 3. `psql -X "$DATABASE_URL" -f video-reset.sql` juste avant la prise ;
12+// 4. les dépendances node d'a11y/ (puppeteer-core) et ffmpeg.
13+//
14+// Usage :
15+// cd docs/slides/demo
16+// TT_USER=alex TT_PASS=… node video.mjs
17+//
18+// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS,
19+// CHROME (chemin ABSOLU du binaire), FFMPEG, TT_OUT (dossier de sortie,
20+// défaut ./out), TT_SPEED (1 = vitesse humaine ; 2 = deux fois plus vite,
21+// pratique pour vérifier le scénario sans attendre).
22+//
23+// Sortie : out/demo.webm (VP9, brut de capture) et out/demo.mp4 (H.264,
24+// partageable). Le .mp4 est ce qu'on monte.
25+
26+import { execSync, spawnSync } from 'node:child_process';
27+import { mkdirSync } from 'node:fs';
28+import { createRequire } from 'node:module';
29+import path from 'node:path';
30+import { fileURLToPath } from 'node:url';
31+
32+// puppeteer-core est installé dans a11y/ — on le résout depuis là-bas.
33+const require = createRequire(new URL('../../../a11y/package.json', import.meta.url));
34+const puppeteer = require('puppeteer-core');
35+
36+const BASE = process.env.TT_URL ?? 'http://localhost:8090';
37+const USER = process.env.TT_USER;
38+const PASS = process.env.TT_PASS;
39+if (!USER || !PASS) {
40+ console.error('TT_USER et TT_PASS sont requis (compte de démo, jamais un compte réel).');
41+ process.exit(2);
42+}
43+const CHROME =
44+ process.env.CHROME ??
45+ execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim();
46+const FFMPEG = process.env.FFMPEG ?? execSync('command -v ffmpeg').toString().trim();
47+
48+const HERE = path.dirname(fileURLToPath(import.meta.url));
49+const OUT = process.env.TT_OUT ?? path.join(HERE, 'out');
50+mkdirSync(OUT, { recursive: true });
51+
52+// ─────────────────────────────────────────────────────────── rythme
53+
54+// Toutes les durées ci-dessous sont en millisecondes « humaines ». TT_SPEED
55+// les divise : la relecture de contrôle se fait à 3, la prise finale à 1.
56+const SPEED = Number(process.env.TT_SPEED ?? 1);
57+const scale = (n) => Math.max(1, Math.round(n / SPEED));
58+const sleep = (n) => new Promise((r) => setTimeout(r, scale(n)));
59+
60+const BEAT = 500; // entre deux gestes de la même séquence
61+const READ = 1300; // le temps de lire ce qui vient de changer
62+const SCENE = 2500; // changement d'écran : place pour un commentaire
63+const TOAST = 1900; // durée de vie du toast (tt-fade : 1,5 s + 0,4 s)
64+const KEY = 95; // délai entre deux frappes clavier
65+const DWELL = 2400; // scène des tâches : on prend son temps, le minuteur tourne
66+const GLIDE = 420; // déplacement du curseur d'une cible à l'autre
67+
68+// ────────────────────────────────────────────── curseur tactile incrusté
69+
70+// Le screencast CDP ne dessine pas le pointeur : on incruste une pastille qui
71+// suit les gestes (déplacement en transition CSS, écrasement + onde au tap).
72+// Réinjecté à chaque document — une navigation emporte l'incrustation.
73+const CURSOR_BOOTSTRAP = () => {
74+ const S = { x: 195, y: 620, el: null };
75+ const CSS = `
76+ .demo-cursor, .demo-ripple { position: fixed; left: 0; top: 0; pointer-events: none; }
77+ .demo-cursor { z-index: 2147483647; }
78+ .demo-ripple { z-index: 2147483646; }
79+ .demo-cursor > i, .demo-ripple > i {
80+ display: block; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%;
81+ }
82+ .demo-cursor > i {
83+ background: rgba(26, 20, 14, .26);
84+ border: 2px solid rgba(255, 255, 255, .95);
85+ box-shadow: 0 3px 12px rgba(0, 0, 0, .38);
86+ }
87+ .demo-cursor.tap > i { animation: demo-tap .34s ease-out; }
88+ .demo-ripple > i { border: 2.5px solid rgba(214, 109, 62, .95); animation: demo-ripple .62s ease-out forwards; }
89+ @keyframes demo-tap { 0% { transform: scale(1) } 45% { transform: scale(.6) } 100% { transform: scale(1) } }
90+ @keyframes demo-ripple { from { transform: scale(.9); opacity: .95 } to { transform: scale(2.9); opacity: 0 } }
91+ `;
92+ function host() {
93+ return document.body || document.documentElement;
94+ }
95+ function ensure() {
96+ if (S.el && S.el.isConnected) return S.el;
97+ if (!host()) return null;
98+ const style = document.createElement('style');
99+ style.textContent = CSS;
100+ host().appendChild(style);
101+ const el = document.createElement('div');
102+ el.className = 'demo-cursor';
103+ el.appendChild(document.createElement('i'));
104+ el.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`;
105+ host().appendChild(el);
106+ S.el = el;
107+ return el;
108+ }
109+ window.__demo = {
110+ // Repositionne sans animation (après une navigation : le curseur reprend
111+ // là où il était sur la page précédente).
112+ place(x, y) {
113+ S.x = x;
114+ S.y = y;
115+ const el = ensure();
116+ if (!el) return;
117+ el.style.transition = 'none';
118+ el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
119+ void el.offsetWidth;
120+ el.style.transition = '';
121+ },
122+ move(x, y, dur) {
123+ S.x = x;
124+ S.y = y;
125+ const el = ensure();
126+ if (!el) return;
127+ el.style.transition = `transform ${dur}ms cubic-bezier(.33, .02, .2, 1)`;
128+ el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
129+ },
130+ tap() {
131+ const el = ensure();
132+ if (!el) return;
133+ el.classList.remove('tap');
134+ void el.offsetWidth;
135+ el.classList.add('tap');
136+ const r = document.createElement('div');
137+ r.className = 'demo-ripple';
138+ r.appendChild(document.createElement('i'));
139+ r.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`;
140+ host().appendChild(r);
141+ setTimeout(() => r.remove(), 800);
142+ },
143+ };
144+};
145+
146+// ─────────────────────────────────────────────────────────── pilotage
147+
148+const browser = await puppeteer.launch({
149+ executablePath: CHROME,
150+ headless: true,
151+ args: ['--no-sandbox', '--disable-dev-shm-usage', '--force-device-scale-factor=1'],
152+});
153+// « Lancer le minuteur » demande la permission notifications au premier
154+// clic (app.html) : on l'accorde d'avance, la demande n'a pas lieu et le
155+// formulaire part directement.
156+await browser.defaultBrowserContext().overridePermissions(BASE, ['notifications']);
157+
158+const page = await browser.newPage();
159+await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
160+await page.evaluateOnNewDocument(CURSOR_BOOTSTRAP);
161+
162+// Position du curseur côté node : elle survit aux navigations, l'incrustation non.
163+const cursor = { x: 195, y: 620 };
164+
165+async function restoreCursor() {
166+ await page.evaluate((p) => window.__demo && window.__demo.place(p.x, p.y), cursor).catch(() => {});
167+}
168+
169+async function moveTo(x, y, dur = GLIDE) {
170+ cursor.x = x;
171+ cursor.y = y;
172+ const d = scale(dur);
173+ await page.evaluate((a) => window.__demo.move(a.x, a.y, a.d), { x, y, d });
174+ await page.mouse.move(x, y);
175+ await sleep(dur);
176+}
177+
178+async function ripple() {
179+ await page.evaluate(() => window.__demo.tap());
180+}
181+
182+/** Élément par sélecteur, éventuellement filtré sur son texte. */
183+async function locate(selector, text) {
184+ const handle = text
185+ ? await page
186+ .evaluateHandle(
187+ (sel, t) => [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().includes(t)) ?? null,
188+ selector,
189+ text,
190+ )
191+ .then((h) => h.asElement())
192+ : await page.$(selector);
193+ if (!handle) throw new Error(`introuvable : ${selector}${text ? ` « ${text} »` : ''}`);
194+ return handle;
195+}
196+
197+/**
198+ * Attend la fin du défilement doux en cours. Attente mécanique : elle n'est
199+ * PAS soumise à TT_SPEED — viser une cible encore en mouvement rate le clic.
200+ */
201+async function settleScroll() {
202+ await page.evaluate(
203+ () =>
204+ new Promise((resolve) => {
205+ const el = document.querySelector('.app-main') ?? document.scrollingElement;
206+ const bail = setTimeout(resolve, 2500);
207+ let last = NaN;
208+ let same = 0;
209+ (function tick() {
210+ same = el.scrollTop === last ? same + 1 : 0;
211+ last = el.scrollTop;
212+ if (same >= 3) {
213+ clearTimeout(bail);
214+ return resolve();
215+ }
216+ requestAnimationFrame(tick);
217+ })();
218+ }),
219+ );
220+}
221+
222+/** Amène l'élément dans la zone confortable (sous l'en-tête, au-dessus de la tab bar). */
223+async function bringIntoView(handle) {
224+ const scrolled = await handle.evaluate((el) => {
225+ const r = el.getBoundingClientRect();
226+ if (r.top >= 96 && r.bottom <= window.innerHeight - 132) return false;
227+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
228+ return true;
229+ });
230+ if (scrolled) await settleScroll();
231+}
232+
233+/**
234+ * Tape sur un élément. `nav: true` attend la navigation qui suit (et remet
235+ * l'incrustation en place sur la nouvelle page). `fixed: true` pour ce qui ne
236+ * scrolle pas (tab bar, pill du minuteur, bottom sheets) : les amener « dans
237+ * la vue » ferait bouger la page sous eux pour rien.
238+ */
239+async function tap(selector, { text, nav = false, fixed = false, pause = BEAT } = {}) {
240+ const handle = await locate(selector, text);
241+ if (!fixed) await bringIntoView(handle);
242+ const box = await handle.boundingBox();
243+ if (!box) throw new Error(`invisible : ${selector}${text ? ` « ${text} »` : ''}`);
244+ const x = Math.round(box.x + box.width / 2);
245+ const y = Math.round(box.y + box.height / 2);
246+ await moveTo(x, y);
247+ await sleep(170);
248+ await ripple();
249+ if (nav) {
250+ await Promise.all([
251+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
252+ page.mouse.click(x, y, { delay: 55 }),
253+ ]);
254+ await restoreCursor();
255+ } else {
256+ await page.mouse.click(x, y, { delay: 55 });
257+ }
258+ await sleep(pause);
259+ return handle;
260+}
261+
262+/** Tape dans un champ puis y saisit le texte lettre à lettre. */
263+async function fill(selector, value, { pause = BEAT } = {}) {
264+ const handle = await tap(selector, { pause: 260 });
265+ await handle.type(value, { delay: scale(KEY) });
266+ await sleep(pause);
267+}
268+
269+async function scrollTop({ pause = 900 } = {}) {
270+ await page.evaluate(() => {
271+ (document.querySelector('.app-main') ?? document.scrollingElement).scrollTo({ top: 0, behavior: 'smooth' });
272+ });
273+ await settleScroll();
274+ await sleep(pause);
275+}
276+
277+/**
278+ * Descend la page par paliers de 62 % de hauteur, en marquant un temps à
279+ * chaque palier — c'est le geste de quelqu'un qui parcourt un écran long.
280+ * S'arrête au bas de page (nombre de paliers borné par `max`).
281+ */
282+async function scrollThrough({ max = 8, pause = 1500 } = {}) {
283+ for (let i = 0; i < max; i++) {
284+ const more = await page.evaluate(() => {
285+ const el = document.querySelector('.app-main') ?? document.scrollingElement;
286+ const bottom = el.scrollHeight - el.clientHeight - el.scrollTop;
287+ if (bottom < 24) return false;
288+ el.scrollBy({ top: Math.min(bottom, el.clientHeight * 0.62), behavior: 'smooth' });
289+ return true;
290+ });
291+ if (!more) return;
292+ await settleScroll();
293+ await sleep(pause);
294+ }
295+}
296+
297+/** Onglets de la barre du bas. */
298+const TAB = { add: '/temps/nouveau', stats: '/stats', todos: '/taches', settings: '/parametres' };
299+async function tab(name, { pause = SCENE } = {}) {
300+ await tap(`.tabbar a[href="${TAB[name]}"]`, { nav: true, fixed: true, pause });
301+}
302+
303+function scene(title) {
304+ console.log(`▸ ${title}`);
305+}
306+
307+// ─────────────────────────────────────────────────────────── le film
308+
309+await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
310+await restoreCursor();
311+
312+const recorder = await page.screencast({
313+ path: path.join(OUT, 'demo.webm'),
314+ ffmpegPath: FFMPEG,
315+ fps: 30,
316+ scale: 2, // 780×1688 : lisible en plein écran comme en vignette
317+});
318+
319+try {
320+ // ─── 1. Connexion ───────────────────────────────────────────────────────
321+ scene('Connexion');
322+ await sleep(2200); // plan d'ouverture sur le logo et la tagline
323+ await fill('input[name="username"]', USER);
324+ await fill('input[name="password"]', PASS, { pause: 700 });
325+ await tap('button[type="submit"]', { nav: true, pause: SCENE });
326+
327+ if (page.url().includes('/login')) throw new Error('connexion échouée — vérifie TT_USER / TT_PASS.');
328+
329+ // ─── 2. Une équipe vide ─────────────────────────────────────────────────
330+ // Atterrissage sur l'onglet Ajouter : minuteur ou saisie manuelle.
331+ scene('Équipe vide');
332+ await tap('.choice-manual', { nav: true, pause: SCENE }); // « Ajouter un temps »
333+ // L'étape 1 n'a rien à proposer : pas encore de catégorie.
334+ await sleep(READ);
335+
336+ // ─── 3. Créer les catégories ────────────────────────────────────────────
337+ scene('Création des catégories');
338+ await tap('a[href="/parametres/categories"]', { nav: true, pause: BEAT });
339+
340+ for (const nom of ['Ménage', 'Cuisine', 'Vaisselle', 'Administratif']) {
341+ await fill('.category-add-row .input', nom, { pause: 150 });
342+ await tap('.category-add-row button[type="submit"]', { nav: true, pause: BEAT });
343+ await scrollTop({ pause: 150 });
344+ }
345+ await sleep(SCENE); // les quatre catégories et leurs couleurs
346+
347+ // ─── 4. Enregistrer des temps ───────────────────────────────────────────
348+ // Chaque enregistrement renvoie sur l'écran de choix de l'onglet Ajouter
349+ // (redirection `?ok=`), d'où le passage par « Ajouter un temps » à chaque tour.
350+ scene('Saisie de temps');
351+ await tap('a[href="/temps/nouveau"]', { text: 'Retour', nav: true, pause: READ });
352+
353+ // Chaque saisie montre une façon différente de poser la durée.
354+ const saisies = [
355+ // Le stepper ± 5 min, puis un preset : les deux gestes du sélecteur.
356+ async () => {
357+ await tap('.stepper-btn[data-step="5"]', { pause: 380 });
358+ await tap('.stepper-btn[data-step="5"]', { pause: 900 });
359+ await tap('.preset[data-minutes="45"]', { pause: READ });
360+ },
361+ // Un preset, et la veille (les flèches de date rechargent la page).
362+ async () => {
363+ await tap('.preset[data-minutes="30"]', { pause: READ });
364+ await tap('.date-row a[aria-label]', { nav: true, pause: READ });
365+ },
366+ // La durée exacte, au clavier.
367+ async () => {
368+ await tap('#minutes', { pause: 260 });
369+ await page.evaluate(() => document.querySelector('#minutes').select());
370+ await page.keyboard.type('20', { delay: scale(KEY) });
371+ await page.evaluate(() =>
372+ document.querySelector('#minutes').dispatchEvent(new Event('input', { bubbles: true })),
373+ );
374+ await sleep(READ);
375+ },
376+ async () => {
377+ await tap('.preset[data-minutes="60"]', { pause: READ });
378+ },
379+ ];
380+ const categoriesSaisies = ['Cuisine', 'Ménage', 'Vaisselle', 'Administratif'];
381+ for (const [i, nom] of categoriesSaisies.entries()) {
382+ await tap('.choice-manual', { nav: true, pause: i === 0 ? SCENE : READ });
383+ await tap('.category-pick a', { text: nom, nav: true, pause: READ });
384+ await saisies[i]();
385+ await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST });
386+ }
387+
388+ // ─── 5. Les statistiques ────────────────────────────────────────────────
389+ scene('Statistiques');
390+ await tab('stats');
391+ await scrollThrough({ pause: 1400 }); // jour, aujourd'hui, semaine, mois, année, tout
392+ await sleep(READ);
393+ await scrollTop({ pause: 1000 });
394+
395+ // Détail d'un segment : bottom sheet en :target, zéro JS. On vise le plus
396+ // gros segment visible — le petit ne se touche pas confortablement.
397+ const segment = (
398+ await page.evaluateHandle(() => {
399+ let best = null;
400+ let area = 0;
401+ for (const s of document.querySelectorAll('.col-bar a')) {
402+ const r = s.getBoundingClientRect();
403+ if (r.top < 96 || r.bottom > window.innerHeight - 132) continue; // hors de la zone confortable
404+ if (r.width * r.height > area) {
405+ area = r.width * r.height;
406+ best = s;
407+ }
408+ }
409+ return best;
410+ })
411+ ).asElement();
412+ if (segment) {
413+ const box = await segment.boundingBox();
414+ await moveTo(Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2));
415+ await sleep(200);
416+ await ripple();
417+ await page.mouse.click(cursor.x, cursor.y, { delay: 55 });
418+ await sleep(SCENE);
419+ if (await page.$('.sheet-wrap:target')) {
420+ await tap('.sheet-wrap:target .close', { fixed: true, pause: READ });
421+ } else {
422+ console.warn('⚠ la feuille de détail ne s’est pas ouverte');
423+ }
424+ } else {
425+ console.warn('⚠ aucun segment à ouvrir — le détail est sauté');
426+ }
427+
428+ // ─── 6. Lancer le minuteur ──────────────────────────────────────────────
429+ scene('Minuteur');
430+ await tab('add', { pause: READ });
431+ await tap('.choice-timer', { nav: true, pause: 5000 }); // les secondes défilent
432+
433+ // ─── 7. Créer des tâches (le minuteur tourne pendant ce temps) ──────────
434+ scene('Tâches');
435+ await tab('todos');
436+
437+ // Toutes datées d'aujourd'hui : les trois restent visibles dans « À faire »
438+ // (une date plus lointaine les enverrait dans le bloc replié « À venir »).
439+ const taches = [
440+ { nom: 'Vider le lave-vaisselle', cat: 'Vaisselle', assigne: 'alex' },
441+ { nom: 'Passer l’aspirateur', cat: 'Ménage', assigne: 'lou' },
442+ { nom: 'Trier le courrier', cat: 'Administratif', assigne: null },
443+ ];
444+ // La scène est volontairement posée : c'est elle qui fait tourner le
445+ // minuteur, et la mesure enregistrée à la fin doit être crédible.
446+ for (const t of taches) {
447+ await tap('a[href="/taches/nouvelle"]', { nav: true, pause: SCENE });
448+ await fill('#todo-nom', t.nom, { pause: DWELL });
449+ await tap('.chips[role="radiogroup"] .chip', { text: t.cat, pause: DWELL });
450+ if (t.assigne) await tap('.chips[role="group"] .chip', { text: t.assigne, pause: DWELL });
451+ await tap('form button[type="submit"]', { text: 'Créer', nav: true, pause: SCENE });
452+ await sleep(BEAT); // la tâche vient s'ajouter à la liste
453+ }
454+
455+ // Le filtre « Mes tâches » du bloc À faire, aller-retour : « Passer
456+ // l'aspirateur », assignée à lou, disparaît puis revient.
457+ await tap('.blk-toggle', { nav: true, pause: 3600 });
458+ await tap('.blk-toggle', { nav: true, pause: 3600 });
459+
460+ // ─── 8. Arrêter le minuteur depuis la pill ──────────────────────────────
461+ scene('Arrêt du minuteur');
462+ await tap('.timer-banner.timer-running', { nav: true, fixed: true, pause: SCENE });
463+
464+ // ─── 9. Ranger la mesure dans une catégorie ─────────────────────────────
465+ scene('Enregistrement de la mesure');
466+ await tap('.category-pick a', { text: 'Ménage', nav: true, pause: SCENE });
467+ await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST });
468+
469+ // ─── 10. Plan de fin sur les stats ──────────────────────────────────────
470+ scene('Plan de fin');
471+ await tab('stats', { pause: SCENE });
472+ await moveTo(195, 880, 700); // le curseur sort du cadre
473+ await sleep(1600);
474+} finally {
475+ // Le screencast ne comble un blanc qu'à l'arrivée de l'image suivante :
476+ // un dernier repaint garantit que la fin de plan est bien dans le fichier.
477+ await page.evaluate(() => window.__demo && window.__demo.tap()).catch(() => {});
478+ await sleep(600);
479+ await recorder.stop();
480+ await browser.close();
481+}
482+
483+// ─────────────────────────────────────────────────────── mp4 partageable
484+
485+// Le .webm sort en VP9 ; le H.264 passe partout (montage, Slack, keynote).
486+const webm = path.join(OUT, 'demo.webm');
487+const mp4 = path.join(OUT, 'demo.mp4');
488+const conv = spawnSync(
489+ FFMPEG,
490+ ['-y', '-loglevel', 'error', '-i', webm, '-c:v', 'libx264', '-preset', 'slow', '-crf', '20',
491+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4],
492+ { stdio: 'inherit' },
493+);
494+if (conv.status !== 0) {
495+ console.warn(`⚠ conversion mp4 échouée (code ${conv.status}) — le .webm est utilisable tel quel`);
496+} else {
497+ console.log(`✓ ${webm}\n✓ ${mp4}`);
498+}
new file mode 100644
@@ -0,0 +1,498 @@
1+// Vidéo de démo : une équipe neuve prise en main de bout en bout.
2+//
3+// Rejoue le parcours complet dans un Chrome piloté (viewport mobile 390×844)
4+// et l'enregistre en vidéo. Le rythme est celui d'un humain : chaque geste est
5+// précédé d'un déplacement de curseur visible, chaque changement d'écran laisse
6+// une pause de commentaire (voix off ou sous-titres au montage).
7+//
8+// Prérequis :
9+// 1. un serveur exego local sur la base DEV (jamais la prod) ;
10+// 2. l'équipe et les comptes de démo créés (cf. video-reset.sql) ;
11+// 3. `psql -X "$DATABASE_URL" -f video-reset.sql` juste avant la prise ;
12+// 4. les dépendances node d'a11y/ (puppeteer-core) et ffmpeg.
13+//
14+// Usage :
15+// cd docs/slides/demo
16+// TT_USER=alex TT_PASS=… node video.mjs
17+//
18+// Variables : TT_URL (défaut http://localhost:8090), TT_USER, TT_PASS,
19+// CHROME (chemin ABSOLU du binaire), FFMPEG, TT_OUT (dossier de sortie,
20+// défaut ./out), TT_SPEED (1 = vitesse humaine ; 2 = deux fois plus vite,
21+// pratique pour vérifier le scénario sans attendre).
22+//
23+// Sortie : out/demo.webm (VP9, brut de capture) et out/demo.mp4 (H.264,
24+// partageable). Le .mp4 est ce qu'on monte.
25+
26+import { execSync, spawnSync } from 'node:child_process';
27+import { mkdirSync } from 'node:fs';
28+import { createRequire } from 'node:module';
29+import path from 'node:path';
30+import { fileURLToPath } from 'node:url';
31+
32+// puppeteer-core est installé dans a11y/ — on le résout depuis là-bas.
33+const require = createRequire(new URL('../../../a11y/package.json', import.meta.url));
34+const puppeteer = require('puppeteer-core');
35+
36+const BASE = process.env.TT_URL ?? 'http://localhost:8090';
37+const USER = process.env.TT_USER;
38+const PASS = process.env.TT_PASS;
39+if (!USER || !PASS) {
40+ console.error('TT_USER et TT_PASS sont requis (compte de démo, jamais un compte réel).');
41+ process.exit(2);
42+}
43+const CHROME =
44+ process.env.CHROME ??
45+ execSync('command -v google-chrome-stable || command -v google-chrome || command -v chromium').toString().trim();
46+const FFMPEG = process.env.FFMPEG ?? execSync('command -v ffmpeg').toString().trim();
47+
48+const HERE = path.dirname(fileURLToPath(import.meta.url));
49+const OUT = process.env.TT_OUT ?? path.join(HERE, 'out');
50+mkdirSync(OUT, { recursive: true });
51+
52+// ─────────────────────────────────────────────────────────── rythme
53+
54+// Toutes les durées ci-dessous sont en millisecondes « humaines ». TT_SPEED
55+// les divise : la relecture de contrôle se fait à 3, la prise finale à 1.
56+const SPEED = Number(process.env.TT_SPEED ?? 1);
57+const scale = (n) => Math.max(1, Math.round(n / SPEED));
58+const sleep = (n) => new Promise((r) => setTimeout(r, scale(n)));
59+
60+const BEAT = 500; // entre deux gestes de la même séquence
61+const READ = 1300; // le temps de lire ce qui vient de changer
62+const SCENE = 2500; // changement d'écran : place pour un commentaire
63+const TOAST = 1900; // durée de vie du toast (tt-fade : 1,5 s + 0,4 s)
64+const KEY = 95; // délai entre deux frappes clavier
65+const DWELL = 2400; // scène des tâches : on prend son temps, le minuteur tourne
66+const GLIDE = 420; // déplacement du curseur d'une cible à l'autre
67+
68+// ────────────────────────────────────────────── curseur tactile incrusté
69+
70+// Le screencast CDP ne dessine pas le pointeur : on incruste une pastille qui
71+// suit les gestes (déplacement en transition CSS, écrasement + onde au tap).
72+// Réinjecté à chaque document — une navigation emporte l'incrustation.
73+const CURSOR_BOOTSTRAP = () => {
74+ const S = { x: 195, y: 620, el: null };
75+ const CSS = `
76+ .demo-cursor, .demo-ripple { position: fixed; left: 0; top: 0; pointer-events: none; }
77+ .demo-cursor { z-index: 2147483647; }
78+ .demo-ripple { z-index: 2147483646; }
79+ .demo-cursor > i, .demo-ripple > i {
80+ display: block; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%;
81+ }
82+ .demo-cursor > i {
83+ background: rgba(26, 20, 14, .26);
84+ border: 2px solid rgba(255, 255, 255, .95);
85+ box-shadow: 0 3px 12px rgba(0, 0, 0, .38);
86+ }
87+ .demo-cursor.tap > i { animation: demo-tap .34s ease-out; }
88+ .demo-ripple > i { border: 2.5px solid rgba(214, 109, 62, .95); animation: demo-ripple .62s ease-out forwards; }
89+ @keyframes demo-tap { 0% { transform: scale(1) } 45% { transform: scale(.6) } 100% { transform: scale(1) } }
90+ @keyframes demo-ripple { from { transform: scale(.9); opacity: .95 } to { transform: scale(2.9); opacity: 0 } }
91+ `;
92+ function host() {
93+ return document.body || document.documentElement;
94+ }
95+ function ensure() {
96+ if (S.el && S.el.isConnected) return S.el;
97+ if (!host()) return null;
98+ const style = document.createElement('style');
99+ style.textContent = CSS;
100+ host().appendChild(style);
101+ const el = document.createElement('div');
102+ el.className = 'demo-cursor';
103+ el.appendChild(document.createElement('i'));
104+ el.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`;
105+ host().appendChild(el);
106+ S.el = el;
107+ return el;
108+ }
109+ window.__demo = {
110+ // Repositionne sans animation (après une navigation : le curseur reprend
111+ // là où il était sur la page précédente).
112+ place(x, y) {
113+ S.x = x;
114+ S.y = y;
115+ const el = ensure();
116+ if (!el) return;
117+ el.style.transition = 'none';
118+ el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
119+ void el.offsetWidth;
120+ el.style.transition = '';
121+ },
122+ move(x, y, dur) {
123+ S.x = x;
124+ S.y = y;
125+ const el = ensure();
126+ if (!el) return;
127+ el.style.transition = `transform ${dur}ms cubic-bezier(.33, .02, .2, 1)`;
128+ el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
129+ },
130+ tap() {
131+ const el = ensure();
132+ if (!el) return;
133+ el.classList.remove('tap');
134+ void el.offsetWidth;
135+ el.classList.add('tap');
136+ const r = document.createElement('div');
137+ r.className = 'demo-ripple';
138+ r.appendChild(document.createElement('i'));
139+ r.style.transform = `translate3d(${S.x}px, ${S.y}px, 0)`;
140+ host().appendChild(r);
141+ setTimeout(() => r.remove(), 800);
142+ },
143+ };
144+};
145+
146+// ─────────────────────────────────────────────────────────── pilotage
147+
148+const browser = await puppeteer.launch({
149+ executablePath: CHROME,
150+ headless: true,
151+ args: ['--no-sandbox', '--disable-dev-shm-usage', '--force-device-scale-factor=1'],
152+});
153+// « Lancer le minuteur » demande la permission notifications au premier
154+// clic (app.html) : on l'accorde d'avance, la demande n'a pas lieu et le
155+// formulaire part directement.
156+await browser.defaultBrowserContext().overridePermissions(BASE, ['notifications']);
157+
158+const page = await browser.newPage();
159+await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });
160+await page.evaluateOnNewDocument(CURSOR_BOOTSTRAP);
161+
162+// Position du curseur côté node : elle survit aux navigations, l'incrustation non.
163+const cursor = { x: 195, y: 620 };
164+
165+async function restoreCursor() {
166+ await page.evaluate((p) => window.__demo && window.__demo.place(p.x, p.y), cursor).catch(() => {});
167+}
168+
169+async function moveTo(x, y, dur = GLIDE) {
170+ cursor.x = x;
171+ cursor.y = y;
172+ const d = scale(dur);
173+ await page.evaluate((a) => window.__demo.move(a.x, a.y, a.d), { x, y, d });
174+ await page.mouse.move(x, y);
175+ await sleep(dur);
176+}
177+
178+async function ripple() {
179+ await page.evaluate(() => window.__demo.tap());
180+}
181+
182+/** Élément par sélecteur, éventuellement filtré sur son texte. */
183+async function locate(selector, text) {
184+ const handle = text
185+ ? await page
186+ .evaluateHandle(
187+ (sel, t) => [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().includes(t)) ?? null,
188+ selector,
189+ text,
190+ )
191+ .then((h) => h.asElement())
192+ : await page.$(selector);
193+ if (!handle) throw new Error(`introuvable : ${selector}${text ? ` « ${text} »` : ''}`);
194+ return handle;
195+}
196+
197+/**
198+ * Attend la fin du défilement doux en cours. Attente mécanique : elle n'est
199+ * PAS soumise à TT_SPEED — viser une cible encore en mouvement rate le clic.
200+ */
201+async function settleScroll() {
202+ await page.evaluate(
203+ () =>
204+ new Promise((resolve) => {
205+ const el = document.querySelector('.app-main') ?? document.scrollingElement;
206+ const bail = setTimeout(resolve, 2500);
207+ let last = NaN;
208+ let same = 0;
209+ (function tick() {
210+ same = el.scrollTop === last ? same + 1 : 0;
211+ last = el.scrollTop;
212+ if (same >= 3) {
213+ clearTimeout(bail);
214+ return resolve();
215+ }
216+ requestAnimationFrame(tick);
217+ })();
218+ }),
219+ );
220+}
221+
222+/** Amène l'élément dans la zone confortable (sous l'en-tête, au-dessus de la tab bar). */
223+async function bringIntoView(handle) {
224+ const scrolled = await handle.evaluate((el) => {
225+ const r = el.getBoundingClientRect();
226+ if (r.top >= 96 && r.bottom <= window.innerHeight - 132) return false;
227+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
228+ return true;
229+ });
230+ if (scrolled) await settleScroll();
231+}
232+
233+/**
234+ * Tape sur un élément. `nav: true` attend la navigation qui suit (et remet
235+ * l'incrustation en place sur la nouvelle page). `fixed: true` pour ce qui ne
236+ * scrolle pas (tab bar, pill du minuteur, bottom sheets) : les amener « dans
237+ * la vue » ferait bouger la page sous eux pour rien.
238+ */
239+async function tap(selector, { text, nav = false, fixed = false, pause = BEAT } = {}) {
240+ const handle = await locate(selector, text);
241+ if (!fixed) await bringIntoView(handle);
242+ const box = await handle.boundingBox();
243+ if (!box) throw new Error(`invisible : ${selector}${text ? ` « ${text} »` : ''}`);
244+ const x = Math.round(box.x + box.width / 2);
245+ const y = Math.round(box.y + box.height / 2);
246+ await moveTo(x, y);
247+ await sleep(170);
248+ await ripple();
249+ if (nav) {
250+ await Promise.all([
251+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
252+ page.mouse.click(x, y, { delay: 55 }),
253+ ]);
254+ await restoreCursor();
255+ } else {
256+ await page.mouse.click(x, y, { delay: 55 });
257+ }
258+ await sleep(pause);
259+ return handle;
260+}
261+
262+/** Tape dans un champ puis y saisit le texte lettre à lettre. */
263+async function fill(selector, value, { pause = BEAT } = {}) {
264+ const handle = await tap(selector, { pause: 260 });
265+ await handle.type(value, { delay: scale(KEY) });
266+ await sleep(pause);
267+}
268+
269+async function scrollTop({ pause = 900 } = {}) {
270+ await page.evaluate(() => {
271+ (document.querySelector('.app-main') ?? document.scrollingElement).scrollTo({ top: 0, behavior: 'smooth' });
272+ });
273+ await settleScroll();
274+ await sleep(pause);
275+}
276+
277+/**
278+ * Descend la page par paliers de 62 % de hauteur, en marquant un temps à
279+ * chaque palier — c'est le geste de quelqu'un qui parcourt un écran long.
280+ * S'arrête au bas de page (nombre de paliers borné par `max`).
281+ */
282+async function scrollThrough({ max = 8, pause = 1500 } = {}) {
283+ for (let i = 0; i < max; i++) {
284+ const more = await page.evaluate(() => {
285+ const el = document.querySelector('.app-main') ?? document.scrollingElement;
286+ const bottom = el.scrollHeight - el.clientHeight - el.scrollTop;
287+ if (bottom < 24) return false;
288+ el.scrollBy({ top: Math.min(bottom, el.clientHeight * 0.62), behavior: 'smooth' });
289+ return true;
290+ });
291+ if (!more) return;
292+ await settleScroll();
293+ await sleep(pause);
294+ }
295+}
296+
297+/** Onglets de la barre du bas. */
298+const TAB = { add: '/temps/nouveau', stats: '/stats', todos: '/taches', settings: '/parametres' };
299+async function tab(name, { pause = SCENE } = {}) {
300+ await tap(`.tabbar a[href="${TAB[name]}"]`, { nav: true, fixed: true, pause });
301+}
302+
303+function scene(title) {
304+ console.log(`▸ ${title}`);
305+}
306+
307+// ─────────────────────────────────────────────────────────── le film
308+
309+await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
310+await restoreCursor();
311+
312+const recorder = await page.screencast({
313+ path: path.join(OUT, 'demo.webm'),
314+ ffmpegPath: FFMPEG,
315+ fps: 30,
316+ scale: 2, // 780×1688 : lisible en plein écran comme en vignette
317+});
318+
319+try {
320+ // ─── 1. Connexion ───────────────────────────────────────────────────────
321+ scene('Connexion');
322+ await sleep(2200); // plan d'ouverture sur le logo et la tagline
323+ await fill('input[name="username"]', USER);
324+ await fill('input[name="password"]', PASS, { pause: 700 });
325+ await tap('button[type="submit"]', { nav: true, pause: SCENE });
326+
327+ if (page.url().includes('/login')) throw new Error('connexion échouée — vérifie TT_USER / TT_PASS.');
328+
329+ // ─── 2. Une équipe vide ─────────────────────────────────────────────────
330+ // Atterrissage sur l'onglet Ajouter : minuteur ou saisie manuelle.
331+ scene('Équipe vide');
332+ await tap('.choice-manual', { nav: true, pause: SCENE }); // « Ajouter un temps »
333+ // L'étape 1 n'a rien à proposer : pas encore de catégorie.
334+ await sleep(READ);
335+
336+ // ─── 3. Créer les catégories ────────────────────────────────────────────
337+ scene('Création des catégories');
338+ await tap('a[href="/parametres/categories"]', { nav: true, pause: BEAT });
339+
340+ for (const nom of ['Ménage', 'Cuisine', 'Vaisselle', 'Administratif']) {
341+ await fill('.category-add-row .input', nom, { pause: 150 });
342+ await tap('.category-add-row button[type="submit"]', { nav: true, pause: BEAT });
343+ await scrollTop({ pause: 150 });
344+ }
345+ await sleep(SCENE); // les quatre catégories et leurs couleurs
346+
347+ // ─── 4. Enregistrer des temps ───────────────────────────────────────────
348+ // Chaque enregistrement renvoie sur l'écran de choix de l'onglet Ajouter
349+ // (redirection `?ok=`), d'où le passage par « Ajouter un temps » à chaque tour.
350+ scene('Saisie de temps');
351+ await tap('a[href="/temps/nouveau"]', { text: 'Retour', nav: true, pause: READ });
352+
353+ // Chaque saisie montre une façon différente de poser la durée.
354+ const saisies = [
355+ // Le stepper ± 5 min, puis un preset : les deux gestes du sélecteur.
356+ async () => {
357+ await tap('.stepper-btn[data-step="5"]', { pause: 380 });
358+ await tap('.stepper-btn[data-step="5"]', { pause: 900 });
359+ await tap('.preset[data-minutes="45"]', { pause: READ });
360+ },
361+ // Un preset, et la veille (les flèches de date rechargent la page).
362+ async () => {
363+ await tap('.preset[data-minutes="30"]', { pause: READ });
364+ await tap('.date-row a[aria-label]', { nav: true, pause: READ });
365+ },
366+ // La durée exacte, au clavier.
367+ async () => {
368+ await tap('#minutes', { pause: 260 });
369+ await page.evaluate(() => document.querySelector('#minutes').select());
370+ await page.keyboard.type('20', { delay: scale(KEY) });
371+ await page.evaluate(() =>
372+ document.querySelector('#minutes').dispatchEvent(new Event('input', { bubbles: true })),
373+ );
374+ await sleep(READ);
375+ },
376+ async () => {
377+ await tap('.preset[data-minutes="60"]', { pause: READ });
378+ },
379+ ];
380+ const categoriesSaisies = ['Cuisine', 'Ménage', 'Vaisselle', 'Administratif'];
381+ for (const [i, nom] of categoriesSaisies.entries()) {
382+ await tap('.choice-manual', { nav: true, pause: i === 0 ? SCENE : READ });
383+ await tap('.category-pick a', { text: nom, nav: true, pause: READ });
384+ await saisies[i]();
385+ await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST });
386+ }
387+
388+ // ─── 5. Les statistiques ────────────────────────────────────────────────
389+ scene('Statistiques');
390+ await tab('stats');
391+ await scrollThrough({ pause: 1400 }); // jour, aujourd'hui, semaine, mois, année, tout
392+ await sleep(READ);
393+ await scrollTop({ pause: 1000 });
394+
395+ // Détail d'un segment : bottom sheet en :target, zéro JS. On vise le plus
396+ // gros segment visible — le petit ne se touche pas confortablement.
397+ const segment = (
398+ await page.evaluateHandle(() => {
399+ let best = null;
400+ let area = 0;
401+ for (const s of document.querySelectorAll('.col-bar a')) {
402+ const r = s.getBoundingClientRect();
403+ if (r.top < 96 || r.bottom > window.innerHeight - 132) continue; // hors de la zone confortable
404+ if (r.width * r.height > area) {
405+ area = r.width * r.height;
406+ best = s;
407+ }
408+ }
409+ return best;
410+ })
411+ ).asElement();
412+ if (segment) {
413+ const box = await segment.boundingBox();
414+ await moveTo(Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2));
415+ await sleep(200);
416+ await ripple();
417+ await page.mouse.click(cursor.x, cursor.y, { delay: 55 });
418+ await sleep(SCENE);
419+ if (await page.$('.sheet-wrap:target')) {
420+ await tap('.sheet-wrap:target .close', { fixed: true, pause: READ });
421+ } else {
422+ console.warn('⚠ la feuille de détail ne s’est pas ouverte');
423+ }
424+ } else {
425+ console.warn('⚠ aucun segment à ouvrir — le détail est sauté');
426+ }
427+
428+ // ─── 6. Lancer le minuteur ──────────────────────────────────────────────
429+ scene('Minuteur');
430+ await tab('add', { pause: READ });
431+ await tap('.choice-timer', { nav: true, pause: 5000 }); // les secondes défilent
432+
433+ // ─── 7. Créer des tâches (le minuteur tourne pendant ce temps) ──────────
434+ scene('Tâches');
435+ await tab('todos');
436+
437+ // Toutes datées d'aujourd'hui : les trois restent visibles dans « À faire »
438+ // (une date plus lointaine les enverrait dans le bloc replié « À venir »).
439+ const taches = [
440+ { nom: 'Vider le lave-vaisselle', cat: 'Vaisselle', assigne: 'alex' },
441+ { nom: 'Passer l’aspirateur', cat: 'Ménage', assigne: 'lou' },
442+ { nom: 'Trier le courrier', cat: 'Administratif', assigne: null },
443+ ];
444+ // La scène est volontairement posée : c'est elle qui fait tourner le
445+ // minuteur, et la mesure enregistrée à la fin doit être crédible.
446+ for (const t of taches) {
447+ await tap('a[href="/taches/nouvelle"]', { nav: true, pause: SCENE });
448+ await fill('#todo-nom', t.nom, { pause: DWELL });
449+ await tap('.chips[role="radiogroup"] .chip', { text: t.cat, pause: DWELL });
450+ if (t.assigne) await tap('.chips[role="group"] .chip', { text: t.assigne, pause: DWELL });
451+ await tap('form button[type="submit"]', { text: 'Créer', nav: true, pause: SCENE });
452+ await sleep(BEAT); // la tâche vient s'ajouter à la liste
453+ }
454+
455+ // Le filtre « Mes tâches » du bloc À faire, aller-retour : « Passer
456+ // l'aspirateur », assignée à lou, disparaît puis revient.
457+ await tap('.blk-toggle', { nav: true, pause: 3600 });
458+ await tap('.blk-toggle', { nav: true, pause: 3600 });
459+
460+ // ─── 8. Arrêter le minuteur depuis la pill ──────────────────────────────
461+ scene('Arrêt du minuteur');
462+ await tap('.timer-banner.timer-running', { nav: true, fixed: true, pause: SCENE });
463+
464+ // ─── 9. Ranger la mesure dans une catégorie ─────────────────────────────
465+ scene('Enregistrement de la mesure');
466+ await tap('.category-pick a', { text: 'Ménage', nav: true, pause: SCENE });
467+ await tap('form[data-duration] button[type="submit"]', { nav: true, pause: TOAST });
468+
469+ // ─── 10. Plan de fin sur les stats ──────────────────────────────────────
470+ scene('Plan de fin');
471+ await tab('stats', { pause: SCENE });
472+ await moveTo(195, 880, 700); // le curseur sort du cadre
473+ await sleep(1600);
474+} finally {
475+ // Le screencast ne comble un blanc qu'à l'arrivée de l'image suivante :
476+ // un dernier repaint garantit que la fin de plan est bien dans le fichier.
477+ await page.evaluate(() => window.__demo && window.__demo.tap()).catch(() => {});
478+ await sleep(600);
479+ await recorder.stop();
480+ await browser.close();
481+}
482+
483+// ─────────────────────────────────────────────────────── mp4 partageable
484+
485+// Le .webm sort en VP9 ; le H.264 passe partout (montage, Slack, keynote).
486+const webm = path.join(OUT, 'demo.webm');
487+const mp4 = path.join(OUT, 'demo.mp4');
488+const conv = spawnSync(
489+ FFMPEG,
490+ ['-y', '-loglevel', 'error', '-i', webm, '-c:v', 'libx264', '-preset', 'slow', '-crf', '20',
491+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4],
492+ { stdio: 'inherit' },
493+);
494+if (conv.status !== 0) {
495+ console.warn(`⚠ conversion mp4 échouée (code ${conv.status}) — le .webm est utilisable tel quel`);
496+} else {
497+ console.log(`✓ ${webm}\n✓ ${mp4}`);
498+}
modified docs/slides/slides.pdf +66 -62
@@ -3870,14 +3870,18 @@ endobj
38703870
38713871 325 0 obj
38723872 <<
3873- /Length 729
3873+ /Length 728
38743874 /Filter /FlateDecode
38753875 >>
38763876 stream
3877-x��UKo1 ��W���:��J�q��J(�2ҶH� l���Gy�kٶt.��g������v�mu�����R�TޤE<k���Z4ͣB� ���N<:@ģ ��٨_� F� ���yl��Uj�>��K����ngg
3878-`qs��
3879-P����UJ���Q$�`����gj���Ӄ��6��6b�Tҽ�G��� H�uZ=iF�<e34�X��g�pBҁF �����5:i�/ΛZ��2�G�T�_W���M���p*p:���e��O���D"����`�d����V���J���NbU]�:X��"�N6��,r�H���8M &ZEQs�&ȏ�I��{�M���ɍ8̝��`��a�����#/.̲>�^/)�5��TJ�lz!K|���.+�)qK]��b �(b� �L 9L%�.��u>+��g�����(���?$2�-��[<��/_I��Y��(Qv_+T�<�2�o��?2�!�fk���3d%"�4 ��I4����b��[�X!����狰\��'��x�h�Ju�=��՞~��{u�|� `�f6���Cʃ��A����,>r
3880- '{�����~�����ݔSvV{� ~���O�7E��~�I8�ڟ�J����T��<�cZy��2�Cf�a����K&����9���47��_O>t�B��1�K���Oc�Or��1�M@�J�Q�v.��\���4%���f�
3877+x��U�n1��S�E��<�@| �"��q
3878+g�$%�U��r��,۱�4�98o� �����������չ�m �ˋ�����5ͽA�<�( h� ^z@%���p߬M_�(�Ƙ" ,�C\�}i���|2�W�f����NN ������9=���\{�I$� [��)�����`�4����|1�L�:��,�|��I��n$���k ˈʃ�:Ac��OIB� ;$oh�@���h�hs��߂7��/�x�p{of.~�Zw0��Z~-�
3879+� J�C��"+%qeP�,)��<֞m���xo����lTT�Y�;Ӌ�"r�$���T<45�xXJ�[4�@a���ދ�, ������k���8Z�W@�>z
3880+�K˔�+;�L�J�ҥ�O���g[��?gW�b.]�kwV4�XN^�uSJs
3881+d��/������"+5K/� 
3882+���+���?a$΁���&�ڣ��#O�����dr6�#��
3883+i� ;G* ��Y�D]���
3884+���A�o�l����Ì0_��G׈x���Ju���U�������?Ƅ�0I�2;�q����:��#��p��Ժ���I���ݖ���ֳ�o�7;(a���i���+&�(k��*-�*S��}�tΎi�ݩ��U��e� �.�L��ُ۟մ6����N�w�b��)���t��\�?���_�>R׫�k�s;���{��,� Bj�
38813885 endstream
38823886 endobj
38833887
@@ -4445,9 +4449,9 @@ endobj
44454449 /Filter /FlateDecode
44464450 >>
44474451 stream
4448-x��WKo7 ��W��>XK�� H��vR���^��8g�W�k��M�}!i�yxv׏&{�)����pgo�VW��
4449-��ĭP���_�HK�,ŬAh��`�y&@����J����͵��K�YI��EG� �i_�Kq.ދ�Gbv���^����_��װ8p�-����5<�Mi�5�T�oJ������\ ��������p�K���Dt�q��}#���M��i��$�0��$z�J��VC+Iz�)�A�����u`{J��G��&��ס�֠�Hc���*D�A�K����j�����#�.������p̲tA�q�)�\�\��]��MMIG�'>~h!֓ �i:&�G��AƊ�"2�o�� j52:���:�|lQ/6y���nXH�#;�? �) �� �ĩ�;�{��8����f ��wc��?��2���/j��l%��_��6ԻQ�#Q,�d��1��(�~iwS5Z����f[�y��i�'>߳� 9���W�c0'.|In�t)7�/v��J����O}����~�-.Д�aINU�t����yO�+0�~��*�s��k\��x��/۪�n(&"��7t�a�ؙ�c
4450-ּ��?���&PKVj�CD/.�S��[�<>�](��_�p$����S�,� &��T-�ggA� � ƃ�>�W��J��D(Ua�</��*/z�M�[8Q��J.D5[X�ӈ�b#����$]e��Dh�q��p�'NJ��QK"��2I&���e�:x�FI�V#�G�i��̅p>zJ��t�H�ϵ*��ш�rAR!ĸ:��Z[ U�ei-T/:V#��s!j\�_ t��F#.ş���mt��Xs����-� ��M������:߬V����_�a��Z�,?F��?�V_?��������.��������tuusݹ�8�����p�G�Ԁ��v@� oJʿX;{=z��i���. NKB4�-�f��M҆�.�4����EڊY����ʛ����P��|Q9oO�YX?K��0�D�_"l�#�� ����H�z�t-��Jˡ%mr�4�`�'Q����(�s��q�c;��86$emq<t=�H���r���pr��������(r��p��@I�[��&� ����d !�mɘ�� ��M��� F��.0G�D�C^��/�0���2-K���{bh�S��J]��w�t�ۼ��i} X�/ Ǥ<�G�Է��@b(u�r�JR�ſQR�:���93&��1����p4�9j�
4452+x��WKo7 ��W��>XK�� H��vR���^��8g�]�#^o���Ҍ4����d3"G|}�(���zs;��6p|v"����bG�X�j!&Bu/P��3ʠ�e�Pzvh�'���"�/�g%��-�5�E�}.n�L�o�N�����^����_O���p|�s�-����5��Mi�5�T�oJ������T L�b2C ��.�t!>�x��.>.P5o���u�&��tGF�S�C�ؤ��@} Y�V�<Χ� u��;������6���Ic�1=�\��"\q�5X pS��nu�Htӿw8�G�,]P[�{JR�9�$��n��M�:����h������eR�@C���,ƒ:�-Y/F�xu<�c�z�ɇ@M��~���l���s'c��s0��Ʀ��A�O�t=n?�p�}7�g_���*��K��ֱ���P���ޒ�&�����(���l�%}#���/�n�Fs�-���;��=m��u�=۞�Q����9s������U�/v��r�ǻ�O]���u��fh�D�$Ǫw�ώ�����V`���K��l��<��q�ñ����r�������w��Æqл:���w����y�,j�J�Pc(#�5��}
4453+��|o��'� %5�+΄2�Ә���v��4�*����, (�u�x����tXI�-��J�l�祵^��Y/�I��v3'j�\ȹ(f3 ;�X�D�Ur���LY4*qޠ|'"�>�I���E�bԒ�z�L��20z��Q�֛2�Σ�4��\8=%�a�`���5
4454+'�Q��rAR!ĸZ��Z[ E�ei-/ZV%��-s.J\-�� ���F%nğb��4��i,��u��V���-� �����N��:�l6����_�ar��lV���{�ϧ���W0y�Zm�֑5M��׷����jٺ�8�����p�C�Ԁ�-ɶG�A� �T�b� u����7 8�48- �P��? ��$m����qC%���H�b�:��n��ʫ����V��|Quޞ(�ka�,a���%��a���OX��6@F�ԃ�k���VZ i��ޠ��5c�Y����%E Y��َ�9����&���Z[]G4�����e�@+�������pq<=��4/1(�z���!�B��@� $��m 5f&^��u�������H�(�b��)��K��b �L˜�Dt��uTD��R�)�#���&�&^�/k�!ᘔgb�����:�J�����Tp�o�ԝ�y�zΌ��g�w�q� ��Ml�
44514455 endstream
44524456 endobj
44534457
@@ -6874,8 +6878,8 @@ endobj
68746878 372 0 obj
68756879 <<
68766880 /Creator (Typst 0.14.2)
6877- /ModDate (D:20260815190718+02'00)
6878- /CreationDate (D:20260815190718+02'00)
6881+ /ModDate (D:20260816160944+02'00)
6882+ /CreationDate (D:20260816160944+02'00)
68796883 >>
68806884 endobj
68816885
@@ -6886,7 +6890,7 @@ endobj
68866890 /Subtype /XML
68876891 >>
68886892 stream
6889-<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmp-writer"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" ><xmp:CreatorTool>Typst 0.14.2</xmp:CreatorTool><dc:language><rdf:Bag><rdf:li>fr</rdf:li></rdf:Bag></dc:language><xmp:ModifyDate>2026-08-15T19:07:18+02:00</xmp:ModifyDate><xmp:CreateDate>2026-08-15T19:07:18+02:00</xmp:CreateDate><xmpTPg:NPages>19</xmpTPg:NPages><dc:format>application/pdf</dc:format><xmpMM:InstanceID>QBYHXYJfNlJSII9ckIWJYw==</xmpMM:InstanceID><xmpMM:DocumentID>QBYHXYJfNlJSII9ckIWJYw==</xmpMM:DocumentID><xmpMM:RenditionClass>proof</xmpMM:RenditionClass><pdf:PDFVersion>1.7</pdf:PDFVersion></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="r"?>
6893+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmp-writer"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" ><xmp:CreatorTool>Typst 0.14.2</xmp:CreatorTool><dc:language><rdf:Bag><rdf:li>fr</rdf:li></rdf:Bag></dc:language><xmp:ModifyDate>2026-08-16T16:09:44+02:00</xmp:ModifyDate><xmp:CreateDate>2026-08-16T16:09:44+02:00</xmp:CreateDate><xmpTPg:NPages>19</xmpTPg:NPages><dc:format>application/pdf</dc:format><xmpMM:InstanceID>BQmGlvt63+DoDQ63N8pMdQ==</xmpMM:InstanceID><xmpMM:DocumentID>BQmGlvt63+DoDQ63N8pMdQ==</xmpMM:DocumentID><xmpMM:RenditionClass>proof</xmpMM:RenditionClass><pdf:PDFVersion>1.7</pdf:PDFVersion></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="r"?>
68906894 endstream
68916895 endobj
68926896
@@ -7235,62 +7239,62 @@ xref
72357239 0000066976 00000 n
72367240 0000068193 00000 n
72377241 0000068694 00000 n
7238-0000069502 00000 n
7239-0000070021 00000 n
7240-0000071537 00000 n
7241-0000072056 00000 n
7242-0000073790 00000 n
7243-0000074309 00000 n
7244-0000075920 00000 n
7245-0000076439 00000 n
7246-0000077265 00000 n
7247-0000077784 00000 n
7248-0000079197 00000 n
7249-0000079716 00000 n
7250-0000081331 00000 n
7251-0000081868 00000 n
7252-0000083737 00000 n
7253-0000084071 00000 n
7254-0000084490 00000 n
7255-0000085045 00000 n
7256-0000086514 00000 n
7257-0000087070 00000 n
7258-0000088555 00000 n
7259-0000089093 00000 n
7260-0000090533 00000 n
7261-0000091071 00000 n
7262-0000092409 00000 n
7263-0000092983 00000 n
7264-0000094545 00000 n
7265-0000095101 00000 n
7266-0000096536 00000 n
7267-0000097092 00000 n
7268-0000098610 00000 n
7269-0000099166 00000 n
7270-0000100689 00000 n
7271-0000101209 00000 n
7272-0000102633 00000 n
7273-0000103135 00000 n
7274-0000104374 00000 n
7275-0000107624 00000 n
7276-0000175030 00000 n
7277-0000245818 00000 n
7278-0000328701 00000 n
7279-0000414439 00000 n
7280-0000501805 00000 n
7281-0000595311 00000 n
7282-0000693558 00000 n
7283-0000788351 00000 n
7284-0000868911 00000 n
7285-0000869038 00000 n
7286-0000870125 00000 n
7242+0000069501 00000 n
7243+0000070020 00000 n
7244+0000071536 00000 n
7245+0000072055 00000 n
7246+0000073789 00000 n
7247+0000074308 00000 n
7248+0000075919 00000 n
7249+0000076438 00000 n
7250+0000077264 00000 n
7251+0000077783 00000 n
7252+0000079196 00000 n
7253+0000079715 00000 n
7254+0000081330 00000 n
7255+0000081867 00000 n
7256+0000083736 00000 n
7257+0000084070 00000 n
7258+0000084489 00000 n
7259+0000085044 00000 n
7260+0000086513 00000 n
7261+0000087069 00000 n
7262+0000088554 00000 n
7263+0000089092 00000 n
7264+0000090532 00000 n
7265+0000091070 00000 n
7266+0000092408 00000 n
7267+0000092982 00000 n
7268+0000094544 00000 n
7269+0000095100 00000 n
7270+0000096535 00000 n
7271+0000097091 00000 n
7272+0000098609 00000 n
7273+0000099165 00000 n
7274+0000100688 00000 n
7275+0000101208 00000 n
7276+0000102632 00000 n
7277+0000103134 00000 n
7278+0000104373 00000 n
7279+0000107623 00000 n
7280+0000175029 00000 n
7281+0000245817 00000 n
7282+0000328700 00000 n
7283+0000414438 00000 n
7284+0000501804 00000 n
7285+0000595310 00000 n
7286+0000693557 00000 n
7287+0000788350 00000 n
7288+0000868910 00000 n
7289+0000869037 00000 n
7290+0000870124 00000 n
72877291 trailer
72887292 <<
72897293 /Size 375
72907294 /Root 374 0 R
72917295 /Info 372 0 R
7292- /ID [(QBYHXYJfNlJSII9ckIWJYw==) (QBYHXYJfNlJSII9ckIWJYw==)]
7296+ /ID [(BQmGlvt63+DoDQ63N8pMdQ==) (BQmGlvt63+DoDQ63N8pMdQ==)]
72937297 >>
72947298 startxref
7295-870344
7299+870343
72967300 %%EOF
\ No newline at end of file
@@ -3870,14 +3870,18 @@ endobj
3870 3870
3871 325 0 obj3871 325 0 obj
3872 <<3872 <<
3873- /Length 7293873+ /Length 728
3874 /Filter /FlateDecode3874 /Filter /FlateDecode
3875 >>3875 >>
3876 stream3876 stream
3877-x��UKo1 ��W���:��J�q��J(�2ҶH� l���Gy�kٶt.��g������v�mu�����R�TޤE<k���Z4ͣB� ���N<:@ģ ��٨_� F� ���yl��Uj�>��K����ngg3877+x��U�n1��S�E��<�@| �"��q
3878-`qs��3878+g�$%�U��r��,۱�4�98o� �����������չ�m �ˋ�����5ͽA�<�( h� ^z@%���p߬M_�(�Ƙ" ,�C\�}i���|2�W�f����NN ������9=���\{�I$� [��)�����`�4����|1�L�:��,�|��I��n$���k ˈʃ�:Ac��OIB� ;$oh�@���h�hs��߂7��/�x�p{of.~�Zw0��Z~-�
3879-P����UJ���Q$�`����gj���Ӄ��6��6b�Tҽ�G��� H�uZ=iF�<e34�X��g�pBҁF �����5:i�/ΛZ��2�G�T�_W���M���p*p:���e��O���D"����`�d����V���J���NbU]�:X��"�N6��,r�H���8M &ZEQs�&ȏ�I��{�M���ɍ8̝��`��a�����#/.̲>�^/)�5��TJ�lz!K|���.+�)qK]��b �(b� �L 9L%�.��u>+��g�����(���?$2�-��[<��/_I��Y��(Qv_+T�<�2�o��?2�!�fk���3d%"�4 ��I4����b��[�X!����狰\��'��x�h�Ju�=��՞~��{u�|� `�f6���Cʃ��A����,>r3879+� J�C��"+%qeP�,)��<֞m���xo����lTT�Y�;Ӌ�"r�$���T<45�xXJ�[4�@a���ދ�, ������k���8Z�W@�>z
3880- '{�����~�����ݔSvV{� ~���O�7E��~�I8�ڟ�J����T��<�cZy��2�Cf�a����K&����9���47��_O>t�B��1�K���Oc�Or��1�M@�J�Q�v.��\���4%���f�3880+�K˔�+;�L�J�ҥ�O���g[��?gW�b.]�kwV4�XN^�uSJs
3881+d��/������"+5K/� 
3882+���+���?a$΁���&�ڣ��#O�����dr6�#��
3883+i� ;G* ��Y�D]���
3884+���A�o�l����Ì0_��G׈x���Ju���U�������?Ƅ�0I�2;�q����:��#��p��Ժ���I���ݖ���ֳ�o�7;(a���i���+&�(k��*-�*S��}�tΎi�ݩ��U��e� �.�L��ُ۟մ6����N�w�b��)���t��\�?���_�>R׫�k�s;���{��,� Bj�
3881 endstream3885 endstream
3882 endobj3886 endobj
3883 3887
@@ -4445,9 +4449,9 @@ endobj
4445 /Filter /FlateDecode4449 /Filter /FlateDecode
4446 >>4450 >>
4447 stream4451 stream
4448-x��WKo7 ��W��>XK�� H��vR���^��8g�W�k��M�}!i�yxv׏&{�)����pgo�VW��4452+x��WKo7 ��W��>XK�� H��vR���^��8g�]�#^o���Ҍ4����d3"G|}�(���zs;��6p|v"����bG�X�j!&Bu/P��3ʠ�e�Pzvh�'���"�/�g%��-�5�E�}.n�L�o�N�����^����_O���p|�s�-����5��Mi�5�T�oJ������T L�b2C ��.�t!>�x��.>.P5o���u�&��tGF�S�C�ؤ��@} Y�V�<Χ� u��;������6���Ic�1=�\��"\q�5X pS��nu�Htӿw8�G�,]P[�{JR�9�$��n��M�:����h������eR�@C���,ƒ:�-Y/F�xu<�c�z�ɇ@M��~���l���s'c��s0��Ʀ��A�O�t=n?�p�}7�g_���*��K��ֱ���P���ޒ�&�����(���l�%}#���/�n�Fs�-���;��=m��u�=۞�Q����9s������U�/v��r�ǻ�O]���u��fh�D�$Ǫw�ώ�����V`���K��l��<��q�ñ����r�������w��Æqл:���w����y�,j�J�Pc(#�5��}
4449-��ĭP���_�HK�,ŬAh��`�y&@����J����͵��K�YI��EG� �i_�Kq.ދ�Gbv���^����_��װ8p�-����5<�Mi�5�T�oJ������\ ��������p�K���Dt�q��}#���M��i��$�0��$z�J��VC+Iz�)�A�����u`{J��G��&��ס�֠�Hc���*D�A�K����j�����#�.������p̲tA�q�)�\�\��]��MMIG�'>~h!֓ �i:&�G��AƊ�"2�o�� j52:���:�|lQ/6y���nXH�#;�? �) �� �ĩ�;�{��8����f ��wc��?��2���/j��l%��_��6ԻQ�#Q,�d��1��(�~iwS5Z����f[�y��i�'>߳� 9���W�c0'.|In�t)7�/v��J����O}����~�-.Д�aINU�t����yO�+0�~��*�s��k\��x��/۪�n(&"��7t�a�ؙ�c4453+��|o��'� %5�+΄2�Ә���v��4�*����, (�u�x����tXI�-��J�l�祵^��Y/�I��v3'j�\ȹ(f3 ;�X�D�Ur���LY4*qޠ|'"�>�I���E�bԒ�z�L��20z��Q�֛2�Σ�4��\8=%�a�`���5
4450-ּ��?���&PKVj�CD/.�S��[�<>�](��_�p$����S�,� &��T-�ggA� � ƃ�>�W��J��D(Ua�</��*/z�M�[8Q��J.D5[X�ӈ�b#����$]e��Dh�q��p�'NJ��QK"��2I&���e�:x�FI�V#�G�i��̅p>zJ��t�H�ϵ*��ш�rAR!ĸ:��Z[ U�ei-T/:V#��s!j\�_ t��F#.ş���mt��Xs����-� ��M������:߬V����_�a��Z�,?F��?�V_?��������.��������tuusݹ�8�����p�G�Ԁ��v@� oJʿX;{=z��i���. NKB4�-�f��M҆�.�4����EڊY����ʛ����P��|Q9oO�YX?K��0�D�_"l�#�� ����H�z�t-��Jˡ%mr�4�`�'Q����(�s��q�c;��86$emq<t=�H���r���pr��������(r��p��@I�[��&� ����d !�mɘ�� ��M��� F��.0G�D�C^��/�0���2-K���{bh�S��J]��w�t�ۼ��i} X�/ Ǥ<�G�Է��@b(u�r�JR�ſQR�:���93&��1����p4�9j�4454+'�Q��rAR!ĸZ��Z[ E�ei-/ZV%��-s.J\-�� ���F%nğb��4��i,��u��V���-� �����N��:�l6����_�ar��lV���{�ϧ���W0y�Zm�֑5M��׷����jٺ�8�����p�C�Ԁ�-ɶG�A� �T�b� u����7 8�48- �P��? ��$m����qC%���H�b�:��n��ʫ����V��|Quޞ(�ka�,a���%��a���OX��6@F�ԃ�k���VZ i��ޠ��5c�Y����%E Y��َ�9����&���Z[]G4�����e�@+�������pq<=��4/1(�z���!�B��@� $��m 5f&^��u�������H�(�b��)��K��b �L˜�Dt��uTD��R�)�#���&�&^�/k�!ᘔgb�����:�J�����Tp�o�ԝ�y�zΌ��g�w�q� ��Ml�
4451 endstream4455 endstream
4452 endobj4456 endobj
4453 4457
@@ -6874,8 +6878,8 @@ endobj
6874 372 0 obj6878 372 0 obj
6875 <<6879 <<
6876 /Creator (Typst 0.14.2)6880 /Creator (Typst 0.14.2)
6877- /ModDate (D:20260815190718+02'00)6881+ /ModDate (D:20260816160944+02'00)
6878- /CreationDate (D:20260815190718+02'00)6882+ /CreationDate (D:20260816160944+02'00)
6879 >>6883 >>
6880 endobj6884 endobj
6881 6885
@@ -6886,7 +6890,7 @@ endobj
6886 /Subtype /XML6890 /Subtype /XML
6887 >>6891 >>
6888 stream6892 stream
6889-<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmp-writer"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" ><xmp:CreatorTool>Typst 0.14.2</xmp:CreatorTool><dc:language><rdf:Bag><rdf:li>fr</rdf:li></rdf:Bag></dc:language><xmp:ModifyDate>2026-08-15T19:07:18+02:00</xmp:ModifyDate><xmp:CreateDate>2026-08-15T19:07:18+02:00</xmp:CreateDate><xmpTPg:NPages>19</xmpTPg:NPages><dc:format>application/pdf</dc:format><xmpMM:InstanceID>QBYHXYJfNlJSII9ckIWJYw==</xmpMM:InstanceID><xmpMM:DocumentID>QBYHXYJfNlJSII9ckIWJYw==</xmpMM:DocumentID><xmpMM:RenditionClass>proof</xmpMM:RenditionClass><pdf:PDFVersion>1.7</pdf:PDFVersion></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="r"?>6893+<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmp-writer"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" ><xmp:CreatorTool>Typst 0.14.2</xmp:CreatorTool><dc:language><rdf:Bag><rdf:li>fr</rdf:li></rdf:Bag></dc:language><xmp:ModifyDate>2026-08-16T16:09:44+02:00</xmp:ModifyDate><xmp:CreateDate>2026-08-16T16:09:44+02:00</xmp:CreateDate><xmpTPg:NPages>19</xmpTPg:NPages><dc:format>application/pdf</dc:format><xmpMM:InstanceID>BQmGlvt63+DoDQ63N8pMdQ==</xmpMM:InstanceID><xmpMM:DocumentID>BQmGlvt63+DoDQ63N8pMdQ==</xmpMM:DocumentID><xmpMM:RenditionClass>proof</xmpMM:RenditionClass><pdf:PDFVersion>1.7</pdf:PDFVersion></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="r"?>
6890 endstream6894 endstream
6891 endobj6895 endobj
6892 6896
@@ -7235,62 +7239,62 @@ xref
7235 0000066976 00000 n 7239 0000066976 00000 n
7236 0000068193 00000 n 7240 0000068193 00000 n
7237 0000068694 00000 n 7241 0000068694 00000 n
7238-0000069502 00000 n 7242+0000069501 00000 n
7239-0000070021 00000 n 7243+0000070020 00000 n
7240-0000071537 00000 n 7244+0000071536 00000 n
7241-0000072056 00000 n 7245+0000072055 00000 n
7242-0000073790 00000 n 7246+0000073789 00000 n
7243-0000074309 00000 n 7247+0000074308 00000 n
7244-0000075920 00000 n 7248+0000075919 00000 n
7245-0000076439 00000 n 7249+0000076438 00000 n
7246-0000077265 00000 n 7250+0000077264 00000 n
7247-0000077784 00000 n 7251+0000077783 00000 n
7248-0000079197 00000 n 7252+0000079196 00000 n
7249-0000079716 00000 n 7253+0000079715 00000 n
7250-0000081331 00000 n 7254+0000081330 00000 n
7251-0000081868 00000 n 7255+0000081867 00000 n
7252-0000083737 00000 n 7256+0000083736 00000 n
7253-0000084071 00000 n 7257+0000084070 00000 n
7254-0000084490 00000 n 7258+0000084489 00000 n
7255-0000085045 00000 n 7259+0000085044 00000 n
7256-0000086514 00000 n 7260+0000086513 00000 n
7257-0000087070 00000 n 7261+0000087069 00000 n
7258-0000088555 00000 n 7262+0000088554 00000 n
7259-0000089093 00000 n 7263+0000089092 00000 n
7260-0000090533 00000 n 7264+0000090532 00000 n
7261-0000091071 00000 n 7265+0000091070 00000 n
7262-0000092409 00000 n 7266+0000092408 00000 n
7263-0000092983 00000 n 7267+0000092982 00000 n
7264-0000094545 00000 n 7268+0000094544 00000 n
7265-0000095101 00000 n 7269+0000095100 00000 n
7266-0000096536 00000 n 7270+0000096535 00000 n
7267-0000097092 00000 n 7271+0000097091 00000 n
7268-0000098610 00000 n 7272+0000098609 00000 n
7269-0000099166 00000 n 7273+0000099165 00000 n
7270-0000100689 00000 n 7274+0000100688 00000 n
7271-0000101209 00000 n 7275+0000101208 00000 n
7272-0000102633 00000 n 7276+0000102632 00000 n
7273-0000103135 00000 n 7277+0000103134 00000 n
7274-0000104374 00000 n 7278+0000104373 00000 n
7275-0000107624 00000 n 7279+0000107623 00000 n
7276-0000175030 00000 n 7280+0000175029 00000 n
7277-0000245818 00000 n 7281+0000245817 00000 n
7278-0000328701 00000 n 7282+0000328700 00000 n
7279-0000414439 00000 n 7283+0000414438 00000 n
7280-0000501805 00000 n 7284+0000501804 00000 n
7281-0000595311 00000 n 7285+0000595310 00000 n
7282-0000693558 00000 n 7286+0000693557 00000 n
7283-0000788351 00000 n 7287+0000788350 00000 n
7284-0000868911 00000 n 7288+0000868910 00000 n
7285-0000869038 00000 n 7289+0000869037 00000 n
7286-0000870125 00000 n 7290+0000870124 00000 n
7287 trailer7291 trailer
7288 <<7292 <<
7289 /Size 3757293 /Size 375
7290 /Root 374 0 R7294 /Root 374 0 R
7291 /Info 372 0 R7295 /Info 372 0 R
7292- /ID [(QBYHXYJfNlJSII9ckIWJYw==) (QBYHXYJfNlJSII9ckIWJYw==)]7296+ /ID [(BQmGlvt63+DoDQ63N8pMdQ==) (BQmGlvt63+DoDQ63N8pMdQ==)]
7293 >>7297 >>
7294 startxref7298 startxref
7295-8703447299+870343
7296 %%EOF7300 %%EOF
\ No newline at end of file\ No newline at end of file
modified docs/slides/slides.typ +1 -3
@@ -10,11 +10,9 @@
1010 align(center, image("../logo.svg", width: 110pt))
1111 v(18pt)
1212 align(center, text(font: font-heading, size: 58pt, "Ex Ego"))
13- v(8pt)
1413 align(center, text(size: 22pt, fill: c-muted, "Le temps qu'on passe, rendu visible, pour en parler tranquillement."))
1514 v(1fr)
1615 align(center, text(size: 15pt, fill: c-muted, "Août 2026"))
17- v(20pt)
1816 })
1917
2018 // ------------------------------------------------- 2 · le point de départ
@@ -160,7 +158,7 @@
160158
161159 // --------------------------------------------------------------- 12 · détail
162160 #phone-slide(
163- title: [Le détail, au doigt],
161+ title: [Le détail en un clic],
164162 shot: "shots/stats-detail.png",
165163 )[
166164 #v(10pt)
@@ -10,11 +10,9 @@
10 align(center, image("../logo.svg", width: 110pt))10 align(center, image("../logo.svg", width: 110pt))
11 v(18pt)11 v(18pt)
12 align(center, text(font: font-heading, size: 58pt, "Ex Ego"))12 align(center, text(font: font-heading, size: 58pt, "Ex Ego"))
13- v(8pt)
14 align(center, text(size: 22pt, fill: c-muted, "Le temps qu'on passe, rendu visible, pour en parler tranquillement."))13 align(center, text(size: 22pt, fill: c-muted, "Le temps qu'on passe, rendu visible, pour en parler tranquillement."))
15 v(1fr)14 v(1fr)
16 align(center, text(size: 15pt, fill: c-muted, "Août 2026"))15 align(center, text(size: 15pt, fill: c-muted, "Août 2026"))
17- v(20pt)
18 })16 })
19 17
20 // ------------------------------------------------- 2 · le point de départ18 // ------------------------------------------------- 2 · le point de départ
@@ -160,7 +158,7 @@
160 158
161 // --------------------------------------------------------------- 12 · détail159 // --------------------------------------------------------------- 12 · détail
162 #phone-slide(160 #phone-slide(
163- title: [Le détail, au doigt],161+ title: [Le détail en un clic],
164 shot: "shots/stats-detail.png",162 shot: "shots/stats-detail.png",
165 )[163 )[
166 #v(10pt)164 #v(10pt)
modified src/period.rs +66 -3
@@ -29,12 +29,51 @@ pub fn day_month(lang: Lang, date: NaiveDate) -> String {
2929 }
3030 }
3131
32+/// Motif d'un libellé de jour : `{w}` jour de semaine, `{d}` quantième,
33+/// `{m}` mois. Unique source de vérité — `day_full` le remplit ici, et le JS
34+/// des steppers ± 1 jour le remplit côté client (cf. `DateVocab`), sans jamais
35+/// inventer de texte.
36+fn day_pattern(lang: Lang) -> &'static str {
37+ match lang {
38+ Lang::De => "{w}, {d}. {m}",
39+ Lang::PtBr => "{w}, {d} de {m}",
40+ _ => "{w} {d} {m}",
41+ }
42+}
43+
3244 /// « Mercredi 6 août » / "Wednesday 6 August" / „Mittwoch, 6. August“ /
3345 /// « Quarta-feira, 6 de agosto »
3446 pub fn day_full(lang: Lang, date: NaiveDate) -> String {
35- let weekday = lang.tr().weekdays[date.weekday().num_days_from_monday() as usize];
36- let sep = if matches!(lang, Lang::De | Lang::PtBr) { ", " } else { " " };
37- capitalize(&format!("{weekday}{sep}{}", day_month(lang, date)))
47+ let tr = lang.tr();
48+ let filled = day_pattern(lang)
49+ .replace("{w}", tr.weekdays[date.weekday().num_days_from_monday() as usize])
50+ .replace("{d}", &date.day().to_string())
51+ .replace("{m}", tr.months[(date.month() - 1) as usize]);
52+ capitalize(&filled)
53+}
54+
55+/// De quoi laisser le JS refaire un `day_label` quand il change la date sans
56+/// recharger : le motif et les noms viennent de `Tr`, le client ne fait que
57+/// substituer. Les listes sont jointes par `|` pour tenir dans un `data-`.
58+pub struct DateVocab {
59+ pub pattern: &'static str,
60+ pub weekdays: String,
61+ pub months: String,
62+ pub today: NaiveDate,
63+ pub today_suffix: &'static str,
64+}
65+
66+impl DateVocab {
67+ pub fn of(lang: Lang) -> Self {
68+ let tr = lang.tr();
69+ Self {
70+ pattern: day_pattern(lang),
71+ weekdays: tr.weekdays.join("|"),
72+ months: tr.months.join("|"),
73+ today: paris_today(),
74+ today_suffix: tr.today_suffix,
75+ }
76+ }
3877 }
3978
4079 /// « Mercredi 6 août — aujourd'hui » / "Wednesday 6 August — today" /
@@ -331,4 +370,28 @@ mod tests {
331370 assert_eq!(day_full(Lang::En, d(2026, 3, 5)), "Thursday 5 March");
332371 assert_eq!(day_full(Lang::Fr, paris_today()), day_label(Lang::Fr, paris_today()).replace(Lang::Fr.tr().today_suffix, ""));
333372 }
373+
374+ /// Le JS des steppers ± 1 jour ne connaît que `DateVocab` : s'il en tire
375+ /// autre chose que `day_label`, le libellé mentira comme avant le correctif.
376+ #[test]
377+ fn date_vocab_rebuilds_day_label() {
378+ for lang in [Lang::Fr, Lang::En, Lang::De, Lang::PtBr] {
379+ let v = DateVocab::of(lang);
380+ let weekdays: Vec<&str> = v.weekdays.split('|').collect();
381+ let months: Vec<&str> = v.months.split('|').collect();
382+ for date in [d(2026, 3, 5), d(2026, 8, 3), v.today] {
383+ let filled = v
384+ .pattern
385+ .replace("{w}", weekdays[date.weekday().num_days_from_monday() as usize])
386+ .replace("{d}", &date.day().to_string())
387+ .replace("{m}", months[(date.month() - 1) as usize]);
388+ let mut c = filled.chars();
389+ let mut out = c.next().unwrap().to_uppercase().collect::<String>() + c.as_str();
390+ if date == v.today {
391+ out.push_str(v.today_suffix);
392+ }
393+ assert_eq!(out, day_label(lang, date), "{lang:?} {date}");
394+ }
395+ }
396+ }
334397 }
@@ -29,12 +29,51 @@ pub fn day_month(lang: Lang, date: NaiveDate) -> String {
29 }29 }
30 }30 }
31 31
32+/// Motif d'un libellé de jour : `{w}` jour de semaine, `{d}` quantième,
33+/// `{m}` mois. Unique source de vérité — `day_full` le remplit ici, et le JS
34+/// des steppers ± 1 jour le remplit côté client (cf. `DateVocab`), sans jamais
35+/// inventer de texte.
36+fn day_pattern(lang: Lang) -> &'static str {
37+ match lang {
38+ Lang::De => "{w}, {d}. {m}",
39+ Lang::PtBr => "{w}, {d} de {m}",
40+ _ => "{w} {d} {m}",
41+ }
42+}
43+
32 /// « Mercredi 6 août » / "Wednesday 6 August" / „Mittwoch, 6. August“ /44 /// « Mercredi 6 août » / "Wednesday 6 August" / „Mittwoch, 6. August“ /
33 /// « Quarta-feira, 6 de agosto »45 /// « Quarta-feira, 6 de agosto »
34 pub fn day_full(lang: Lang, date: NaiveDate) -> String {46 pub fn day_full(lang: Lang, date: NaiveDate) -> String {
35- let weekday = lang.tr().weekdays[date.weekday().num_days_from_monday() as usize];47+ let tr = lang.tr();
36- let sep = if matches!(lang, Lang::De | Lang::PtBr) { ", " } else { " " };48+ let filled = day_pattern(lang)
37- capitalize(&format!("{weekday}{sep}{}", day_month(lang, date)))49+ .replace("{w}", tr.weekdays[date.weekday().num_days_from_monday() as usize])
50+ .replace("{d}", &date.day().to_string())
51+ .replace("{m}", tr.months[(date.month() - 1) as usize]);
52+ capitalize(&filled)
53+}
54+
55+/// De quoi laisser le JS refaire un `day_label` quand il change la date sans
56+/// recharger : le motif et les noms viennent de `Tr`, le client ne fait que
57+/// substituer. Les listes sont jointes par `|` pour tenir dans un `data-`.
58+pub struct DateVocab {
59+ pub pattern: &'static str,
60+ pub weekdays: String,
61+ pub months: String,
62+ pub today: NaiveDate,
63+ pub today_suffix: &'static str,
64+}
65+
66+impl DateVocab {
67+ pub fn of(lang: Lang) -> Self {
68+ let tr = lang.tr();
69+ Self {
70+ pattern: day_pattern(lang),
71+ weekdays: tr.weekdays.join("|"),
72+ months: tr.months.join("|"),
73+ today: paris_today(),
74+ today_suffix: tr.today_suffix,
75+ }
76+ }
38 }77 }
39 78
40 /// « Mercredi 6 août — aujourd'hui » / "Wednesday 6 August — today" /79 /// « Mercredi 6 août — aujourd'hui » / "Wednesday 6 August — today" /
@@ -331,4 +370,28 @@ mod tests {
331 assert_eq!(day_full(Lang::En, d(2026, 3, 5)), "Thursday 5 March");370 assert_eq!(day_full(Lang::En, d(2026, 3, 5)), "Thursday 5 March");
332 assert_eq!(day_full(Lang::Fr, paris_today()), day_label(Lang::Fr, paris_today()).replace(Lang::Fr.tr().today_suffix, ""));371 assert_eq!(day_full(Lang::Fr, paris_today()), day_label(Lang::Fr, paris_today()).replace(Lang::Fr.tr().today_suffix, ""));
333 }372 }
373+
374+ /// Le JS des steppers ± 1 jour ne connaît que `DateVocab` : s'il en tire
375+ /// autre chose que `day_label`, le libellé mentira comme avant le correctif.
376+ #[test]
377+ fn date_vocab_rebuilds_day_label() {
378+ for lang in [Lang::Fr, Lang::En, Lang::De, Lang::PtBr] {
379+ let v = DateVocab::of(lang);
380+ let weekdays: Vec<&str> = v.weekdays.split('|').collect();
381+ let months: Vec<&str> = v.months.split('|').collect();
382+ for date in [d(2026, 3, 5), d(2026, 8, 3), v.today] {
383+ let filled = v
384+ .pattern
385+ .replace("{w}", weekdays[date.weekday().num_days_from_monday() as usize])
386+ .replace("{d}", &date.day().to_string())
387+ .replace("{m}", months[(date.month() - 1) as usize]);
388+ let mut c = filled.chars();
389+ let mut out = c.next().unwrap().to_uppercase().collect::<String>() + c.as_str();
390+ if date == v.today {
391+ out.push_str(v.today_suffix);
392+ }
393+ assert_eq!(out, day_label(lang, date), "{lang:?} {date}");
394+ }
395+ }
396+ }
334 }397 }
modified src/routes/todos.rs +5 -1
@@ -15,7 +15,7 @@ use crate::db::teams;
1515 use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};
1616 use crate::error::AppError;
1717 use crate::i18n::{Tr, p1, p2};
18-use crate::period::{day_label, day_month, paris_today};
18+use crate::period::{DateVocab, day_label, day_month, paris_today};
1919 use crate::render::{HtmlTemplate, Shell};
2020 use crate::routes::{Preset, duration_presets, shell};
2121 use crate::schedule;
@@ -238,6 +238,9 @@ pub struct TodoFormPage {
238238 pub cats: Vec<CatChip>,
239239 pub date: NaiveDate,
240240 pub date_label: String,
241+ /// Motif + noms de jours/mois : le JS des steppers ± 1 jour recalcule
242+ /// `date_label` sans recharger, sans inventer de texte.
243+ pub vocab: DateVocab,
241244 pub members: Vec<MemberChip>,
242245 pub error: Option<&'static str>,
243246 }
@@ -298,6 +301,7 @@ async fn form_page(
298301 .collect(),
299302 date,
300303 date_label: day_label(ctx.lang, date),
304+ vocab: DateVocab::of(ctx.lang),
301305 members: teams::members(&state.pool, ctx.team_id)
302306 .await?
303307 .into_iter()
@@ -15,7 +15,7 @@ use crate::db::teams;
15 use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};15 use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};
16 use crate::error::AppError;16 use crate::error::AppError;
17 use crate::i18n::{Tr, p1, p2};17 use crate::i18n::{Tr, p1, p2};
18-use crate::period::{day_label, day_month, paris_today};18+use crate::period::{DateVocab, day_label, day_month, paris_today};
19 use crate::render::{HtmlTemplate, Shell};19 use crate::render::{HtmlTemplate, Shell};
20 use crate::routes::{Preset, duration_presets, shell};20 use crate::routes::{Preset, duration_presets, shell};
21 use crate::schedule;21 use crate::schedule;
@@ -238,6 +238,9 @@ pub struct TodoFormPage {
238 pub cats: Vec<CatChip>,238 pub cats: Vec<CatChip>,
239 pub date: NaiveDate,239 pub date: NaiveDate,
240 pub date_label: String,240 pub date_label: String,
241+ /// Motif + noms de jours/mois : le JS des steppers ± 1 jour recalcule
242+ /// `date_label` sans recharger, sans inventer de texte.
243+ pub vocab: DateVocab,
241 pub members: Vec<MemberChip>,244 pub members: Vec<MemberChip>,
242 pub error: Option<&'static str>,245 pub error: Option<&'static str>,
243 }246 }
@@ -298,6 +301,7 @@ async fn form_page(
298 .collect(),301 .collect(),
299 date,302 date,
300 date_label: day_label(ctx.lang, date),303 date_label: day_label(ctx.lang, date),
304+ vocab: DateVocab::of(ctx.lang),
301 members: teams::members(&state.pool, ctx.team_id)305 members: teams::members(&state.pool, ctx.team_id)
302 .await?306 .await?
303 .into_iter()307 .into_iter()
modified templates/todo_form.html +35 -4
@@ -39,7 +39,10 @@
3939 </button>
4040 </div>
4141 </div>
42- <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p>
42+ <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)"
43+ data-pattern="{{ vocab.pattern }}" data-weekdays="{{ vocab.weekdays }}"
44+ data-months="{{ vocab.months }}" data-today="{{ vocab.today }}"
45+ data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p>
4346
4447 <div class="field" style="margin-bottom:var(--space-2)">
4548 <label id="todo-asg-label">{{ shell.tr.assign_to }}</label>
@@ -68,16 +71,44 @@
6871
6972 <script>
7073 // Amélioration progressive : steppers ± 1 jour (sans JS, l'input date suffit).
74+// Le libellé sous le champ suit la date, y compris quand elle est tapée à la
75+// main : il est refait avec le motif et les noms rendus par le serveur
76+// (data-*), jamais avec du texte écrit ici.
7177 (function () {
7278 var input = document.getElementById('todo-date');
79+ var label = document.getElementById('todo-date-label');
80+ var weekdays = label.dataset.weekdays.split('|');
81+ var months = label.dataset.months.split('|');
82+
83+ // Tout en UTC : les steppers restent justes en travers d'un changement d'heure.
84+ function parse(v) {
85+ var p = String(v || '').split('-');
86+ return p.length === 3 ? new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])) : null;
87+ }
88+ function iso(d) { return d.toISOString().slice(0, 10); }
89+
90+ function render() {
91+ var d = parse(input.value);
92+ if (!d) { label.textContent = ''; return; }
93+ var s = label.dataset.pattern
94+ .replace('{w}', weekdays[(d.getUTCDay() + 6) % 7])
95+ .replace('{d}', d.getUTCDate())
96+ .replace('{m}', months[d.getUTCMonth()]);
97+ label.textContent = s.charAt(0).toUpperCase() + s.slice(1)
98+ + (input.value === label.dataset.today ? label.dataset.todaySuffix : '');
99+ }
100+
73101 document.querySelectorAll('button[data-day]').forEach(function (b) {
74102 b.hidden = false;
75103 b.addEventListener('click', function () {
76- var d = input.valueAsDate || new Date();
77- d.setDate(d.getDate() + (+b.dataset.day));
78- input.valueAsDate = d;
104+ var d = parse(input.value) || parse(label.dataset.today);
105+ d.setUTCDate(d.getUTCDate() + (+b.dataset.day));
106+ input.value = iso(d);
107+ render();
79108 });
80109 });
110+ input.addEventListener('input', render);
111+ input.addEventListener('change', render);
81112 })();
82113 </script>
83114 {% endblock %}
@@ -39,7 +39,10 @@
39 </button>39 </button>
40 </div>40 </div>
41 </div>41 </div>
42- <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p>42+ <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)"
43+ data-pattern="{{ vocab.pattern }}" data-weekdays="{{ vocab.weekdays }}"
44+ data-months="{{ vocab.months }}" data-today="{{ vocab.today }}"
45+ data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p>
43 46
44 <div class="field" style="margin-bottom:var(--space-2)">47 <div class="field" style="margin-bottom:var(--space-2)">
45 <label id="todo-asg-label">{{ shell.tr.assign_to }}</label>48 <label id="todo-asg-label">{{ shell.tr.assign_to }}</label>
@@ -68,16 +71,44 @@
68 71
69 <script>72 <script>
70 // Amélioration progressive : steppers ± 1 jour (sans JS, l'input date suffit).73 // Amélioration progressive : steppers ± 1 jour (sans JS, l'input date suffit).
74+// Le libellé sous le champ suit la date, y compris quand elle est tapée à la
75+// main : il est refait avec le motif et les noms rendus par le serveur
76+// (data-*), jamais avec du texte écrit ici.
71 (function () {77 (function () {
72 var input = document.getElementById('todo-date');78 var input = document.getElementById('todo-date');
79+ var label = document.getElementById('todo-date-label');
80+ var weekdays = label.dataset.weekdays.split('|');
81+ var months = label.dataset.months.split('|');
82+
83+ // Tout en UTC : les steppers restent justes en travers d'un changement d'heure.
84+ function parse(v) {
85+ var p = String(v || '').split('-');
86+ return p.length === 3 ? new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])) : null;
87+ }
88+ function iso(d) { return d.toISOString().slice(0, 10); }
89+
90+ function render() {
91+ var d = parse(input.value);
92+ if (!d) { label.textContent = ''; return; }
93+ var s = label.dataset.pattern
94+ .replace('{w}', weekdays[(d.getUTCDay() + 6) % 7])
95+ .replace('{d}', d.getUTCDate())
96+ .replace('{m}', months[d.getUTCMonth()]);
97+ label.textContent = s.charAt(0).toUpperCase() + s.slice(1)
98+ + (input.value === label.dataset.today ? label.dataset.todaySuffix : '');
99+ }
100+
73 document.querySelectorAll('button[data-day]').forEach(function (b) {101 document.querySelectorAll('button[data-day]').forEach(function (b) {
74 b.hidden = false;102 b.hidden = false;
75 b.addEventListener('click', function () {103 b.addEventListener('click', function () {
76- var d = input.valueAsDate || new Date();104+ var d = parse(input.value) || parse(label.dataset.today);
77- d.setDate(d.getDate() + (+b.dataset.day));105+ d.setUTCDate(d.getUTCDate() + (+b.dataset.day));
78- input.valueAsDate = d;106+ input.value = iso(d);
107+ render();
79 });108 });
80 });109 });
110+ input.addEventListener('input', render);
111+ input.addEventListener('change', render);
81 })();112 })();
82 </script>113 </script>
83 {% endblock %}114 {% endblock %}