From 502cbf80398f5a476298a1737291309f5b0700a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:27:32 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(profils):=20mode=20multi-profils=20?= =?UTF-8?q?=E2=80=94=20plusieurs=20enfants=20par=20appareil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque enfant a désormais son propre profil (progression, badges, série, images mystère), stocké sous sa propre clé localStorage (multiplix-profile:) avec un index léger (multiplix-profiles) qui liste les profils et le profil actif. L'ancien schéma mono-clé est migré automatiquement au premier lancement, sans perte. - Nouvel écran « Qui joue ? » au lancement dès 2 profils (pastille-initiale colorée stable par prénom) ; le parcours mono-profil reste inchangé. - « Ajouter un enfant » depuis l'espace parent (section Profils) ou depuis « Qui joue ? » : rejoue l'onboarding complet, annulable. - Bouton « changer de joueur » en haut de l'accueil (multi-profils only). - « Réinitialiser le profil » devient « Supprimer ce profil » : supprime le profil actif et route vers l'écran adapté (accueil de l'autre enfant, « Qui joue ? », ou onboarding s'il n'en reste aucun). - Import depuis Welcome = nouveau profil (jamais d'écrasement) ; import depuis l'espace parent = restauration du profil actif (inchangé). - inline script index.html, ErrorBoundary (sauvegarde de secours) et generate-user-guide adaptés au nouveau schéma ; specs §12 + changelog. Tests : multiProfile.test.tsx (migration legacy, ajout/sélection/annulation/ suppression en DOM réel) + mise à jour userJourney. https://claude.ai/code/session_01C2TMaVQwKBai1xMrEE4KmL --- index.html | 4 +- public/specs/index.html | 29 +++- scripts/generate-user-guide.mjs | 14 +- src/App.tsx | 131 ++++++++++++++---- src/__tests__/multiProfile.test.tsx | 195 +++++++++++++++++++++++++++ src/__tests__/userJourney.test.tsx | 6 +- src/components/ErrorBoundary.tsx | 4 +- src/lib/changelog.ts | 6 + src/lib/storage.ts | 202 +++++++++++++++++++++++++--- src/screens/HomeScreen.tsx | 24 ++++ src/screens/ParentDashboard.tsx | 23 +++- src/screens/ProfileSelectScreen.css | 88 ++++++++++++ src/screens/ProfileSelectScreen.tsx | 53 ++++++++ src/screens/WelcomeScreen.tsx | 11 +- 14 files changed, 723 insertions(+), 67 deletions(-) create mode 100644 src/__tests__/multiProfile.test.tsx create mode 100644 src/screens/ProfileSelectScreen.css create mode 100644 src/screens/ProfileSelectScreen.tsx diff --git a/index.html b/index.html index 9260f93d..b783dd28 100644 --- a/index.html +++ b/index.html @@ -45,7 +45,9 @@ var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true; var profile = false, skipped = false, hasImport = false; - try { profile = !!localStorage.getItem('multiplix-profile'); } catch (e) {} + // Deux clés : `multiplix-profiles` (index multi-profils) et + // `multiplix-profile` (ancien schéma mono-profil, migré au boot). + try { profile = !!(localStorage.getItem('multiplix-profiles') || localStorage.getItem('multiplix-profile')); } catch (e) {} try { skipped = localStorage.getItem('multiplix-skip-install') === '1'; } catch (e) {} // Migration cross-origin : on arrive de l'ancien domaine avec le profil // dans le fragment (#import=…) → on boote l'app directement (pas la diff --git a/public/specs/index.html b/public/specs/index.html index 35f507d1..55820e1c 100644 --- a/public/specs/index.html +++ b/public/specs/index.html @@ -345,6 +345,7 @@

Tablito.

  • Périmètre — ce que l'app ne fait PAS
  • Métriques de succès
  • Niveau 2 — Réviser par la division
  • +
  • Multi-profils — plusieurs enfants par appareil
  • Évolutions possibles (V2)
  • Références
  • @@ -831,7 +832,8 @@

    7.1Écrans

    - + + @@ -981,16 +983,33 @@

    11.7Réserve méthodologique

    Des interventions de fluence ciblant directement la division existent et fonctionnent (cover-copy-compare, taped problems, detect-practice-repair). En revanche, les bénéfices de la répétition espacée sont démontrés sur la multiplication et la rétention en général, mais pas directement sur les faits de division : les méta-analyses traitent la division au sein des « faits de base » sans isoler son effet, et l'opération s'avère être un modérateur significatif. Appliquer Leitner à la division est donc une généralisation plausible mais non directement prouvée — un pari raisonné, assumé comme tel, à surveiller si l'usage révélait un comportement différent de la multiplication.

    +
    +

    12Multi-profils — plusieurs enfants par appareil

    +

    Une tablette familiale sert souvent à plusieurs enfants. Chaque enfant dispose de son propre profil : faits, boîtes Leitner, badges, séries et images mystère totalement indépendants. Aucune notion de compte ni de mot de passe — tout reste local sur l'appareil (§9).

    + +

    12.1Parcours

    +
      +
    • Mono-profil (cas majoritaire) : rien ne change. Pas d'écran intermédiaire, pas de bouton supplémentaire — zéro friction ajoutée à la boucle quotidienne.
    • +
    • Ajout d'un enfant : bouton « Ajouter un enfant » dans l'espace parent (section Profils) ou depuis l'écran « Qui joue ? ». Rejoue l'onboarding complet : prénom, test de placement, intro des règles ×1/×10.
    • +
    • Dès 2 profils : l'app s'ouvre sur l'écran « Qui joue ? » (liste des prénoms, pastille-initiale colorée stable par prénom). On ne devine jamais quel enfant tient l'appareil — choisir coûte un tap, se tromper de profil polluerait les boîtes Leitner des deux enfants.
    • +
    • Changer de joueur : bouton dédié en haut de l'écran d'accueil (visible uniquement en multi-profils), qui ramène à « Qui joue ? ».
    • +
    • Suppression : « Supprimer ce profil » dans l'espace parent, avec confirmation explicite. S'il reste un seul profil → retour direct à son accueil ; plusieurs → « Qui joue ? » ; aucun → onboarding.
    • +
    + +

    12.2Stockage et migration

    +

    Chaque profil vit sous sa propre clé localStorage (multiplix-profile:<id>) ; un index léger (multiplix-profiles) liste les profils (id, prénom) et le profil actif. L'ancien schéma mono-clé (multiplix-profile) est migré automatiquement et silencieusement au premier lancement : le profil existant devient le premier profil de l'index, rien n'est perdu.

    +

    L'export/import de sauvegarde reste par-profil : l'export produit le JSON du profil actif (format inchangé, compatible avec les sauvegardes antérieures) ; l'import depuis l'écran d'accueil crée un nouveau profil (jamais d'écrasement d'un autre enfant), tandis que l'import depuis l'espace parent restaure le profil actif.

    +
    +
    -

    12Évolutions possibles (V2)

    +

    13Évolutions possibles (V2)

    • Mode défi : séance bonus optionnelle le week-end
    • -
    • Mode multi-enfant (plusieurs profils sur le même appareil)
    -

    13Références

    +

    14Références

    1. Axtell, P. K., McCallum, R. S., Bell, S. M., & Poncy, B. (2009). Developing math automaticity using a classwide fluency building procedure for middle school students: A preliminary study. Psychology in the Schools, 46(6), 526–538.
    2. Brendefur, J., Strother, S., Thiede, K., & Appleton, S. (2015). Developing multiplication fact fluency. Advances in Social Sciences Research Journal, 2(8). doi:10.14738/assrj.28.1396
    3. @@ -1021,7 +1040,7 @@

      13Références

      -

      Document de spécifications — v1.2 — Juin 2026

      +

      Document de spécifications — v1.3 — Juin 2026

      Code source sur GitHub

      diff --git a/scripts/generate-user-guide.mjs b/scripts/generate-user-guide.mjs index a646f046..d293351b 100644 --- a/scripts/generate-user-guide.mjs +++ b/scripts/generate-user-guide.mjs @@ -257,8 +257,12 @@ async function seedProfile(page, profile) { }; if (p === null) { + // Purge les deux schémas (legacy mono-profil + index multi-profils). localStorage.removeItem('multiplix-profile'); + localStorage.removeItem('multiplix-profiles'); } else { + // On seede via la clé legacy : l'app la migre vers le schéma + // multi-profils au boot, ce qui exerce aussi le chemin de migration. localStorage.setItem('multiplix-profile', JSON.stringify(p)); } // Bypass the install landing : on est en headless, l'install PWA n'a @@ -270,7 +274,15 @@ async function seedProfile(page, profile) { /** Returns the Leitner box of the currently displayed question's fact. */ async function readCurrentFactBox(page, q) { return page.evaluate((qq) => { - const raw = localStorage.getItem('multiplix-profile'); + // Post-boot, le profil vit sous le schéma multi-profils ; on garde le + // fallback legacy au cas où l'app n'a pas encore migré. + let raw = null; + const idx = localStorage.getItem('multiplix-profiles'); + if (idx) { + const { activeId } = JSON.parse(idx); + if (activeId) raw = localStorage.getItem('multiplix-profile:' + activeId); + } + if (!raw) raw = localStorage.getItem('multiplix-profile'); if (!raw) return null; const profile = JSON.parse(raw); const a = Math.min(qq.a, qq.b); diff --git a/src/App.tsx b/src/App.tsx index 001b74e4..933315c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,18 @@ import { isRule11Unlocked, isDivisionUnlocked, } from './lib/badges'; -import { loadProfile, saveProfile, clearStoredProfile, createNewProfile, exportProfile, importProfile } from './lib/storage'; +import { + loadProfile, + loadProfileById, + saveProfile, + addProfile, + deleteActiveProfile, + setActiveProfile, + listProfiles, + createNewProfile, + exportProfile, + importProfile, +} from './lib/storage'; import { getFactKey } from './lib/facts'; import { getDivisionFactKey } from './lib/divisionFacts'; import { seedFromPlacement } from './lib/placement'; @@ -33,6 +44,7 @@ import { isVoiceMode } from './hooks/useInputMode'; // boutons sont wired par l'inline script à la fin de body. Quand App // monte, on est déjà passé la landing (skip flag, profil, ou standalone). import WelcomeScreen from './screens/WelcomeScreen'; +import ProfileSelectScreen from './screens/ProfileSelectScreen'; import RulesIntroScreen from './screens/RulesIntroScreen'; import HomeScreen from './screens/HomeScreen'; import SessionScreen from './screens/SessionScreen'; @@ -50,6 +62,7 @@ const ChangelogScreen = lazy(() => import('./screens/ChangelogScreen')); type Screen = | 'welcome' + | 'profiles' | 'rulesIntro' | 'home' | 'session' @@ -61,15 +74,27 @@ type Screen = | 'privacy' | 'changelog'; -function initialScreen(profile: UserProfile | null): Screen { +// Écran d'arrivée d'un profil donné (post-sélection ou post-import). +function profileHome(profile: UserProfile): Screen { + return profile.hasSeenRulesIntro ? 'home' : 'rulesIntro'; +} + +function initialScreen(profile: UserProfile | null, profileCount: number): Screen { + // Dès 2 profils sur l'appareil, le boot passe par « Qui joue ? » : on ne + // devine jamais quel enfant tient la tablette. Mono-profil : parcours + // inchangé, zéro friction ajoutée. + if (profileCount > 1) return 'profiles'; if (!profile) return 'welcome'; - if (!profile.hasSeenRulesIntro) return 'rulesIntro'; - return 'home'; + return profileHome(profile); } export default function App() { const [profile, setProfile] = useState(() => loadProfile()); - const [screen, setScreen] = useState(() => initialScreen(profile)); + const [screen, setScreen] = useState(() => initialScreen(profile, listProfiles().length)); + // Pilote l'affichage du bouton « changer de joueur » sur Home et le retour + // du Welcome « ajout d'un enfant ». Lu à chaque render : l'index est + // minuscule et ne change que via des flows qui re-rendent déjà App. + const profileCount = listProfiles().length; // Liste unifiée de la séance en cours : 100% multiplication avant déblocage, // mixte (division + entretien des tables) après (specs §11.6). const [sessionItems, setSessionItems] = useState([]); @@ -167,13 +192,13 @@ export default function App() { }, [screen]); // Signale au pwa-register si on est dans un écran "safe" pour appliquer - // une mise à jour SW (= reload). `home` ET `welcome` le sont : ailleurs, un - // reload casserait l'état mémoire en cours (séance, recap animations, - // navigation parent, etc.). `welcome` est inclus car une install neuve (sans - // profil) y reste bloquée — sans ça, ces utilisateurs ne recevraient JAMAIS - // de mise à jour (ex. l'écran d'import lui-même). Rien de précieux à perdre - // en rechargeant l'accueil/l'onboarding. - const safeForReload = screen === 'home' || screen === 'welcome'; + // une mise à jour SW (= reload). `home`, `welcome` ET `profiles` le sont : + // ailleurs, un reload casserait l'état mémoire en cours (séance, recap + // animations, navigation parent, etc.). `welcome` est inclus car une install + // neuve (sans profil) y reste bloquée — sans ça, ces utilisateurs ne + // recevraient JAMAIS de mise à jour (ex. l'écran d'import lui-même). Rien de + // précieux à perdre en rechargeant l'accueil/l'onboarding/le choix du joueur. + const safeForReload = screen === 'home' || screen === 'welcome' || screen === 'profiles'; useEffect(() => { setSwBusy(!safeForReload); }, [safeForReload]); @@ -194,14 +219,37 @@ export default function App() { }; }, []); - // Welcome: create new profile with optional placement test results + // Welcome: create new profile with optional placement test results. + // addProfile persiste tout de suite sous un NOUVEL id (qui devient actif) : + // sans ça, l'effet de sauvegarde écraserait le profil de l'enfant précédent + // quand on ajoute un deuxième enfant. const handleWelcomeComplete = useCallback((name: string, placementResults: PlacementResult[]) => { const newProfile = createNewProfile(name); seedFromPlacement(newProfile.facts, placementResults, todayISO()); + addProfile(newProfile); setProfile(newProfile); setScreen('rulesIntro'); }, []); + // Sélection d'un joueur depuis l'écran « Qui joue ? ». + const handleSelectProfile = useCallback((id: string) => { + const selected = loadProfileById(id); + if (!selected) return; + setActiveProfile(id); + setProfile(selected); + setScreen(profileHome(selected)); + }, []); + + // « Ajouter un enfant » (depuis « Qui joue ? » ou l'espace parent) : on + // rejoue l'onboarding Welcome complet, prénom + test de placement. + const handleAddProfile = useCallback(() => setScreen('welcome'), []); + + // Annulation de l'ajout d'un enfant : retour au choix du joueur s'il y a + // plusieurs profils, sinon à l'accueil de l'enfant actif. + const handleWelcomeCancel = useCallback(() => { + setScreen(listProfiles().length > 1 ? 'profiles' : 'home'); + }, []); + const handleRulesIntroComplete = useCallback(() => { setProfile((prev) => (prev ? { ...prev, hasSeenRulesIntro: true } : prev)); setScreen('home'); @@ -487,25 +535,34 @@ export default function App() { return imported; }, []); - // Variante pour l'écran d'accueil (migration / nouvel appareil) : importe ET - // navigue vers l'écran adapté au profil restauré — sinon on resterait bloqué - // sur Welcome (le profil ne pilote pas `screen` tout seul). L'import depuis - // l'espace parent, lui, ne navigue pas (comportement inchangé). + // Variante pour l'écran d'accueil (migration / nouvel appareil) : importe en + // tant que NOUVEAU profil (jamais d'écrasement d'un autre enfant) ET navigue + // vers l'écran adapté au profil restauré — sinon on resterait bloqué sur + // Welcome (le profil ne pilote pas `screen` tout seul). L'import depuis + // l'espace parent, lui, écrase le profil actif (restauration de sauvegarde) + // et ne navigue pas (comportement inchangé). const handleWelcomeImport = useCallback((json: string): boolean => { - const imported = handleImport(json); - if (imported) setScreen(initialScreen(imported)); - return imported !== null; - }, [handleImport]); + const imported = importProfile(json); + if (!imported) return false; + addProfile(imported); + setProfile(imported); + setScreen(profileHome(imported)); + return true; + }, []); - const handleResetProfile = useCallback(() => { + const handleDeleteProfile = useCallback(() => { + if (!profile) return; const ok = window.confirm( - 'Réinitialiser le profil ?\n\nLe prénom, les séances, les badges, la série et le test de placement seront effacés. Cette action est irréversible.', + `Supprimer le profil de ${profile.name} ?\n\nLe prénom, les séances, les badges et la série seront effacés de cet appareil. Cette action est irréversible.`, ); if (!ok) return; - clearStoredProfile(); - setProfile(null); - setScreen('welcome'); - }, []); + deleteActiveProfile(); + // S'il reste plusieurs enfants → « Qui joue ? » ; un seul → son accueil + // directement ; aucun → onboarding complet. + const next = loadProfile(); + setProfile(next); + setScreen(next ? (listProfiles().length > 1 ? 'profiles' : profileHome(next)) : 'welcome'); + }, [profile]); return (
      @@ -515,7 +572,21 @@ export default function App() { qui flashe. */} {screen === 'welcome' && ( - + + )} + + {screen === 'profiles' && ( + )} {screen === 'rulesIntro' && profile && ( @@ -538,6 +609,7 @@ export default function App() { onShowBadges={() => setScreen('badges')} onShowRules={handleShowRules} onShowParent={() => setScreen('parent')} + onSwitchProfile={profileCount > 1 ? () => setScreen('profiles') : undefined} /> )} @@ -594,7 +666,8 @@ export default function App() { onBack={() => setScreen('home')} onExport={handleExport} onImport={handleImport} - onResetProfile={handleResetProfile} + onAddProfile={handleAddProfile} + onDeleteProfile={handleDeleteProfile} onShowPrivacy={() => setScreen('privacy')} onShowChangelog={() => setScreen('changelog')} /> diff --git a/src/__tests__/multiProfile.test.tsx b/src/__tests__/multiProfile.test.tsx new file mode 100644 index 00000000..03152cd7 --- /dev/null +++ b/src/__tests__/multiProfile.test.tsx @@ -0,0 +1,195 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/preact'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import App from '../App'; +import { + PROFILES_INDEX_KEY, + addProfile, + createNewProfile, + listProfiles, + loadProfile, +} from '../lib/storage'; +// Préchauffe le chunk de ParentDashboard pour que le React.lazy() côté App.tsx +// se résolve en synchrone dans les tests qui ouvrent le dashboard. +import '../screens/ParentDashboard'; + +// --------------------------------------------------------------------------- +// Mode multi-profils : plusieurs enfants partagent le même appareil. Tests +// d'intégration DOM (vrai , vrais clics) couvrant la migration depuis +// l'ancien schéma mono-profil, l'ajout d'un second enfant, l'écran +// « Qui joue ? » au boot, le changement de joueur et la suppression. +// --------------------------------------------------------------------------- + +const START_DATE = new Date('2026-01-05T08:00:00.000Z'); + +// PRNG déterministe (mulberry32) — même rationale que userJourney.test.tsx. +function seededRandom(seed: number): () => number { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function findButton(label: RegExp | string): HTMLButtonElement | null { + const buttons = Array.from(document.querySelectorAll('button')); + return ( + (buttons.find((b) => { + const text = (b.textContent ?? '').trim(); + return typeof label === 'string' ? text === label : label.test(text); + }) as HTMLButtonElement | null) ?? null + ); +} + +function readGreeting(): string { + return document.querySelector('.home-greeting')?.textContent ?? ''; +} + +// Crée un profil via le vrai parcours Welcome (prénom + « Passer le test ») +// puis ferme l'intro des règles pour atterrir sur Home. +function completeWelcome(name: string): void { + fireEvent.click(findButton(/^Suivant/)!); + const nameInput = document.querySelector('input.welcome-input')!; + fireEvent.change(nameInput, { target: { value: name } }); + fireEvent.click(findButton(/^C'est moi/)!); + fireEvent.click(findButton('Passer le test')!); + // RulesIntroScreen (3 étapes). + fireEvent.click(findButton(/C'est parti/)!); + fireEvent.click(findButton(/Suivant/)!); + fireEvent.click(findButton(/J'ai compris/)!); +} + +// Ouvre le dashboard parent depuis Home (même helper que userJourney). +async function openParentDashboard(): Promise { + fireEvent.click(document.querySelector('.home-parent-btn')!); + const question = document.querySelector('.parent-gate-question'); + if (!question) throw new Error('ParentGate non affiché'); + const operands = Array.from(question.querySelectorAll('span')) + .map((s) => parseInt(s.textContent ?? '', 10)) + .filter((n) => Number.isFinite(n)); + if (operands.length < 2) throw new Error('Opérandes du ParentGate introuvables'); + const product = operands[0] * operands[1]; + const input = document.querySelector('.parent-gate-input')!; + fireEvent.change(input, { target: { value: String(product) } }); + fireEvent.click(findButton('Valider')!); + for (let i = 0; i < 10; i++) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe('Mode multi-profils (DOM)', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem('multiplix-skip-install', '1'); + vi.spyOn(Math, 'random').mockImplementation(seededRandom(1)); + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); + vi.setSystemTime(START_DATE); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("migre l'ancien profil mono-clé vers le schéma multi-profils sans rien perdre", () => { + // Profil au format historique, écrit directement sous l'ancienne clé. + const legacy = createNewProfile('Zoe'); + legacy.hasSeenRulesIntro = true; + legacy.totalSessions = 7; + localStorage.setItem('multiplix-profile', JSON.stringify(legacy)); + + render(); + + // Boot direct sur Home (un seul profil → pas d'écran « Qui joue ? »). + expect(readGreeting()).toContain('Zoe'); + + // L'ancienne clé a été migrée : index + clé par-profil, données intactes. + expect(localStorage.getItem('multiplix-profile')).toBeNull(); + expect(localStorage.getItem(PROFILES_INDEX_KEY)).not.toBeNull(); + expect(listProfiles()).toHaveLength(1); + const migrated = loadProfile()!; + expect(migrated.name).toBe('Zoe'); + expect(migrated.totalSessions).toBe(7); + }); + + it('ajoute un second enfant depuis l’espace parent, puis propose « Qui joue ? » au boot', async () => { + render(); + + // Enfant 1 : onboarding complet. + completeWelcome('Zoe'); + expect(readGreeting()).toContain('Zoe'); + // Mono-profil : pas de bouton « changer de joueur ». + expect(document.querySelector('.home-switch-btn')).toBeNull(); + + // Enfant 2 : ajout via l'espace parent. + await openParentDashboard(); + fireEvent.click(findButton('Ajouter un enfant')!); + completeWelcome('Max'); + expect(readGreeting()).toContain('Max'); + + // Les deux profils coexistent, chacun avec sa progression. + expect(listProfiles().map((p) => p.name).sort()).toEqual(['Max', 'Zoe']); + expect(loadProfile()!.name).toBe('Max'); + + // Relance de l'app → écran « Qui joue ? ». + cleanup(); + render(); + expect(document.querySelector('.profile-select-screen')).not.toBeNull(); + expect(findButton(/Zoe/)).not.toBeNull(); + expect(findButton(/Max/)).not.toBeNull(); + + // Sélection de Zoé → son accueil, et son profil devient l'actif. + fireEvent.click(findButton(/Zoe/)!); + expect(readGreeting()).toContain('Zoe'); + expect(loadProfile()!.name).toBe('Zoe'); + + // Le bouton « changer de joueur » ramène à l'écran de sélection. + fireEvent.click(document.querySelector('.home-switch-btn')!); + expect(document.querySelector('.profile-select-screen')).not.toBeNull(); + }); + + it("annuler l'ajout d'un enfant ne crée pas de profil", async () => { + render(); + completeWelcome('Zoe'); + + await openParentDashboard(); + fireEvent.click(findButton('Ajouter un enfant')!); + // Welcome en mode ajout → bouton Annuler présent. + fireEvent.click(findButton('Annuler')!); + + expect(listProfiles()).toHaveLength(1); + expect(readGreeting()).toContain('Zoe'); + }); + + it("supprimer le profil actif bascule sur l'autre enfant", async () => { + // Deux profils seedés directement (Max actif, dernier ajouté). + const zoe = createNewProfile('Zoe'); + zoe.hasSeenRulesIntro = true; + addProfile(zoe); + const max = createNewProfile('Max'); + max.hasSeenRulesIntro = true; + addProfile(max); + + render(); + fireEvent.click(findButton(/Max/)!); + expect(readGreeting()).toContain('Max'); + + await openParentDashboard(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + fireEvent.click(findButton('Supprimer ce profil')!); + confirmSpy.mockRestore(); + + // Il ne reste que Zoé : retour direct sur son accueil. + expect(listProfiles().map((p) => p.name)).toEqual(['Zoe']); + expect(loadProfile()!.name).toBe('Zoe'); + expect(readGreeting()).toContain('Zoe'); + }); +}); diff --git a/src/__tests__/userJourney.test.tsx b/src/__tests__/userJourney.test.tsx index f6ff2fb2..3dd8f45d 100644 --- a/src/__tests__/userJourney.test.tsx +++ b/src/__tests__/userJourney.test.tsx @@ -385,7 +385,7 @@ describe('Parcours utilisateur de bout en bout (DOM)', () => { }, ); - it("le bouton « Réinitialiser le profil » efface le profil et relance le test de placement", async () => { + it("le bouton « Supprimer ce profil » efface le profil et relance le test de placement", async () => { render(); // Setup minimal : on crée un profil en sautant le test de placement. @@ -401,7 +401,7 @@ describe('Parcours utilisateur de bout en bout (DOM)', () => { await openParentDashboard(); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - fireEvent.click(findButton('Réinitialiser le profil')!); + fireEvent.click(findButton('Supprimer ce profil')!); confirmSpy.mockRestore(); expect(loadProfile()).toBeNull(); @@ -425,7 +425,7 @@ describe('Parcours utilisateur de bout en bout (DOM)', () => { await openParentDashboard(); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - fireEvent.click(findButton('Réinitialiser le profil')!); + fireEvent.click(findButton('Supprimer ce profil')!); confirmSpy.mockRestore(); expect(loadProfile()?.name).toBe(before!.name); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index bc15832d..007a0254 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,5 +1,5 @@ import { Component, type ReactNode } from 'react'; -import { STORAGE_KEY } from '../lib/storage'; +import { getActiveProfileRaw } from '../lib/storage'; interface ErrorBoundaryProps { children: ReactNode; @@ -28,7 +28,7 @@ export default class ErrorBoundary extends Component { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = getActiveProfileRaw(); if (!raw) return; const blob = new Blob([raw], { type: 'application/json' }); const url = URL.createObjectURL(blob); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 207b12ed..64c291ef 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -9,6 +9,12 @@ export interface ChangelogEntry { // CI, lint), seulement ce qui change l'expérience visible côté enfant ou // parent. Garder court et concret. export const CHANGELOG: ChangelogEntry[] = [ + { + date: '2026-06-11', + items: [ + "Plusieurs enfants sur le même appareil : l'espace parent propose désormais « Ajouter un enfant » (section Profils). Chaque enfant a son propre profil — progression, badges, série et images mystère séparés. Dès deux profils, l'app demande « Qui joue ? » au lancement, et un bouton en haut de l'accueil permet de changer de joueur à tout moment. Les profils existants sont conservés tels quels.", + ], + }, { date: '2026-06-05', items: [ diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 3a032a83..6ea72690 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -16,18 +16,121 @@ function pickDivisionTheme(multTheme: MysteryTheme): MysteryTheme { return pickRandom(others.length > 0 ? others : MYSTERY_POOL); } -// ⚠ Cette clé est aussi référencée en dur dans l'inline script de -// index.html (pour décider si la landing statique doit s'afficher avant -// que main.js ne charge). Si tu la renommes, mets à jour les deux. -export const STORAGE_KEY = 'multiplix-profile'; +// === Multi-profils === +// Plusieurs enfants peuvent partager le même appareil (specs §12). Chaque +// profil vit sous sa propre clé (`multiplix-profile:`), et un index +// léger (`multiplix-profiles`) liste les {id, name} + le profil actif. +// Invariant : l'index n'existe en localStorage que s'il reste au moins un +// profil — l'inline script de index.html teste sa présence (en dur, comme +// l'ancienne clé) pour décider si la landing statique doit s'afficher avant +// que main.js ne charge. Si tu renommes une clé, mets à jour les deux. +export const PROFILES_INDEX_KEY = 'multiplix-profiles'; +const PROFILE_KEY_PREFIX = 'multiplix-profile:'; +// Clé historique mono-profil — migrée vers l'index à la première lecture. +const LEGACY_STORAGE_KEY = 'multiplix-profile'; -/** - * Loads the user profile from localStorage. - * Returns null if no profile exists or if parsing fails. - */ -export function loadProfile(): UserProfile | null { +export interface ProfileSummary { + id: string; + name: string; +} + +interface ProfilesIndex { + activeId: string | null; + profiles: ProfileSummary[]; +} + +function profileKey(id: string): string { + return PROFILE_KEY_PREFIX + id; +} + +function generateProfileId(): string { + try { + return crypto.randomUUID(); + } catch { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 10); + } +} + +function readIndex(): ProfilesIndex { + try { + const raw = localStorage.getItem(PROFILES_INDEX_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ProfilesIndex; + const profiles = Array.isArray(parsed.profiles) + ? parsed.profiles.filter( + (p): p is ProfileSummary => + !!p && typeof p === 'object' && typeof p.id === 'string' && typeof p.name === 'string', + ) + : []; + // Défensif : un activeId orphelin (entrée supprimée, index altéré) + // retombe sur le premier profil plutôt que de bloquer le boot. + const activeId = profiles.some((p) => p.id === parsed.activeId) + ? parsed.activeId + : profiles[0]?.id ?? null; + return { activeId, profiles }; + } + } catch { + // Index illisible → on retente la migration legacy ci-dessous. + } + return migrateLegacyProfile(); +} + +// Migration : avant le multi-profil, l'unique profil vivait sous la clé +// `multiplix-profile`. On le déplace tel quel vers le nouveau schéma +// (id généré + index) et on retire l'ancienne clé. +function migrateLegacyProfile(): ProfilesIndex { + const empty: ProfilesIndex = { activeId: null, profiles: [] }; + try { + const raw = localStorage.getItem(LEGACY_STORAGE_KEY); + if (!raw) return empty; + const parsed = JSON.parse(raw); + if (!isValidProfile(parsed)) return empty; + const id = generateProfileId(); + localStorage.setItem(profileKey(id), raw); + const index: ProfilesIndex = { + activeId: id, + profiles: [{ id, name: (parsed as UserProfile).name }], + }; + writeIndex(index); + localStorage.removeItem(LEGACY_STORAGE_KEY); + return index; + } catch { + return empty; + } +} + +// Best-effort : localStorage indisponible (mode privé strict) ne doit pas +// faire planter l'app — au pire l'état reste en mémoire pour la session. +function writeIndex(index: ProfilesIndex): void { + try { + if (index.profiles.length === 0) { + // Invariant : pas de profil → pas d'index (cf. inline script index.html). + localStorage.removeItem(PROFILES_INDEX_KEY); + } else { + localStorage.setItem(PROFILES_INDEX_KEY, JSON.stringify(index)); + } + } catch { + // ignore + } +} + +export function listProfiles(): ProfileSummary[] { + return readIndex().profiles; +} + +export function getActiveProfileId(): string | null { + return readIndex().activeId; +} + +export function setActiveProfile(id: string): void { + const index = readIndex(); + if (index.activeId === id || !index.profiles.some((p) => p.id === id)) return; + writeIndex({ ...index, activeId: id }); +} + +export function loadProfileById(id: string): UserProfile | null { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(profileKey(id)); if (!raw) return null; const parsed = JSON.parse(raw); if (!isValidProfile(parsed)) return null; @@ -38,21 +141,84 @@ export function loadProfile(): UserProfile | null { } /** - * Saves the user profile to localStorage. + * Loads the active user profile from localStorage. + * Returns null if no profile exists or if parsing fails. + */ +export function loadProfile(): UserProfile | null { + const id = getActiveProfileId(); + return id ? loadProfileById(id) : null; +} + +/** + * Saves the profile under the active id (created on the fly if the device + * has no profile yet — ex. import cross-origin avant tout onboarding). */ export function saveProfile(profile: UserProfile): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(profile)); + const index = readIndex(); + if (!index.activeId) { + addProfile(profile); + return; + } + try { + localStorage.setItem(profileKey(index.activeId), JSON.stringify(profile)); + } catch { + return; + } + // Garde le nom de l'index en phase : il peut changer via un import de + // sauvegarde depuis l'espace parent (restauration d'un autre prénom). + const entry = index.profiles.find((p) => p.id === index.activeId); + if (entry && entry.name !== profile.name) { + writeIndex({ + ...index, + profiles: index.profiles.map((p) => + p.id === index.activeId ? { ...p, name: profile.name } : p, + ), + }); + } } -// Best-effort : localStorage indisponible (mode privé strict) ne doit pas -// faire planter le reset — l'appelant repassera de toute façon par -// WelcomeScreen via setProfile(null). -export function clearStoredProfile(): void { +/** + * Persiste un profil sous un nouvel id, qui devient le profil actif. + * C'est le chemin de création (onboarding) et d'ajout d'un enfant. + */ +export function addProfile(profile: UserProfile): string { + const index = readIndex(); + const id = generateProfileId(); + try { + localStorage.setItem(profileKey(id), JSON.stringify(profile)); + } catch { + return id; + } + writeIndex({ activeId: id, profiles: [...index.profiles, { id, name: profile.name }] }); + return id; +} + +/** + * Supprime le profil actif ; s'il reste des profils, le premier devient actif. + */ +export function deleteActiveProfile(): void { + const index = readIndex(); + if (!index.activeId) return; try { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(profileKey(index.activeId)); } catch { // ignore } + const profiles = index.profiles.filter((p) => p.id !== index.activeId); + writeIndex({ activeId: profiles[0]?.id ?? null, profiles }); +} + +/** + * JSON brut du profil actif, sans validation ni migration — utilisé par + * ErrorBoundary pour proposer une sauvegarde même si le profil ne parse plus. + */ +export function getActiveProfileRaw(): string | null { + try { + const id = getActiveProfileId(); + return id ? localStorage.getItem(profileKey(id)) : null; + } catch { + return null; + } } // === Migration cross-origin (ancien domaine isc.github.io → tablito.app) === @@ -86,7 +252,7 @@ export async function importProfileFromUrl(): Promise { const match = window.location.hash.match(IMPORT_HASH_RE); if (!match) return; try { - if (localStorage.getItem(STORAGE_KEY)) return; // jamais écraser un profil présent + if (listProfiles().length > 0) return; // jamais écraser un profil présent const profile = importProfile(await decodeImportPayload(match[1])); if (profile) saveProfile(profile); } catch { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 6b080c52..d1bbfc1d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -22,6 +22,9 @@ interface HomeScreenProps { onShowBadges: () => void; onShowRules: () => void; onShowParent: () => void; + // Présent uniquement quand l'appareil héberge plusieurs profils : ouvre + // l'écran « Qui joue ? ». Absent en mono-profil (pas de bouton). + onSwitchProfile?: () => void; } function IconGear() { @@ -76,6 +79,17 @@ const MOOD_RESET_MS = 1500; let easterTickleCount = 0; let easterHiddenUntil = 0; +function IconUsers() { + return ( + + ); +} + function IconRuler() { return (
      + {onSwitchProfile && ( + + )}
      -

      Réinitialisation

      +

      Profils

      - Efface le profil et relance le test de placement. Utile pour - recommencer à zéro ou changer d'enfant. + Plusieurs enfants sur le même appareil ? Chacun a son profil : + progression, badges et images séparés. La suppression efface le profil + de {profile.name} de cet appareil — pour recommencer à zéro, supprimez + puis recréez le profil.

      +
      diff --git a/src/screens/ProfileSelectScreen.css b/src/screens/ProfileSelectScreen.css new file mode 100644 index 00000000..49a551e8 --- /dev/null +++ b/src/screens/ProfileSelectScreen.css @@ -0,0 +1,88 @@ +.profile-select-screen { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 20px; + gap: 20px; + background: var(--cream); + animation: fadeIn 0.5s ease; +} + +.profile-select-title { + font-family: var(--serif); + font-size: 32px; + font-weight: 600; + color: var(--indigo); + line-height: 1.15; + letter-spacing: -0.5px; +} + +.profile-select-list { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 320px; +} + +.profile-select-btn { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: var(--paper); + border: 1.5px solid var(--line); + border-radius: 18px; + font-family: var(--sans); + font-size: 18px; + font-weight: 700; + color: var(--ink); + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.profile-select-btn:active { + transform: scale(0.98); + border-color: var(--indigo); +} + +.profile-select-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-family: var(--serif); + font-size: 22px; + font-weight: 700; + flex-shrink: 0; +} + +.profile-select-name { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-select-add { + background: none; + border: none; + color: var(--ink-soft); + font-family: var(--sans); + font-size: 14px; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; + padding: 10px 20px; +} + +.profile-select-add:active { + color: var(--ink); +} diff --git a/src/screens/ProfileSelectScreen.tsx b/src/screens/ProfileSelectScreen.tsx new file mode 100644 index 00000000..a7c80338 --- /dev/null +++ b/src/screens/ProfileSelectScreen.tsx @@ -0,0 +1,53 @@ +import Mascot from '../components/Mascot'; +import type { ProfileSummary } from '../lib/storage'; + +interface ProfileSelectScreenProps { + profiles: ProfileSummary[]; + onSelect: (id: string) => void; + onAdd: () => void; +} + +// Couleur d'avatar stable par prénom : chaque enfant retrouve « sa » +// pastille d'un lancement à l'autre, sans rien stocker. Palette limitée aux +// teintes assez foncées pour porter une initiale blanche. +const AVATAR_COLORS = ['var(--indigo)', 'var(--sage)', 'var(--coral)']; + +function avatarColor(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; + return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; +} + +// Écran « Qui joue ? » — affiché au lancement dès qu'il y a au moins deux +// profils sur l'appareil (specs §12). Le parcours mono-profil ne le voit +// jamais : aucune friction ajoutée à la boucle quotidienne d'un enfant seul. +export default function ProfileSelectScreen({ profiles, onSelect, onAdd }: ProfileSelectScreenProps) { + return ( +
      + +
      Qui joue ?
      +
      + {profiles.map((p) => ( + + ))} +
      + +
      + ); +} diff --git a/src/screens/WelcomeScreen.tsx b/src/screens/WelcomeScreen.tsx index 640c04ac..3902336f 100644 --- a/src/screens/WelcomeScreen.tsx +++ b/src/screens/WelcomeScreen.tsx @@ -16,9 +16,13 @@ interface WelcomeScreenProps { // si le JSON collé est invalide, pour afficher une erreur. En cas de succès, // App navigue vers l'écran adapté au profil (ce composant est alors démonté). onImport: (json: string) => boolean; + // Présent uniquement en mode « ajouter un enfant » (il existe déjà au moins + // un profil) : permet de revenir en arrière sans créer de profil. Absent au + // tout premier onboarding. + onCancel?: () => void; } -export default function WelcomeScreen({ onComplete, onImport }: WelcomeScreenProps) { +export default function WelcomeScreen({ onComplete, onImport, onCancel }: WelcomeScreenProps) { const [step, setStep] = useState(0); const [name, setName] = useState(''); const [showImport, setShowImport] = useState(false); @@ -247,6 +251,11 @@ export default function WelcomeScreen({ onComplete, onImport }: WelcomeScreenPro > Déjà une progression ? L'importer + {onCancel && ( + + )} )} From afa982a89e25a7b739ff2fcad74cc559e6590522 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:12:13 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(guide):=20section=20=C2=AB=20Plusieurs?= =?UTF-8?q?=20enfants=20=C2=BB=20avec=20captures=20multi-profils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute au guide utilisateur une section dédiée au mode multi-profils : capture de l'écran « Qui joue ? » (Léa + Max) et de l'accueil avec le bouton « changer de joueur ». seedProfile purge désormais les clés du schéma multi-profils avant chaque seed — les addInitScript s'empilent entre les captures et l'index écrit par seedMultiProfile masquait le profil legacy des captures suivantes (voice notamment). https://claude.ai/code/session_01C2TMaVQwKBai1xMrEE4KmL --- scripts/generate-user-guide.mjs | 78 +++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/scripts/generate-user-guide.mjs b/scripts/generate-user-guide.mjs index d293351b..b81081d8 100644 --- a/scripts/generate-user-guide.mjs +++ b/scripts/generate-user-guide.mjs @@ -256,10 +256,16 @@ async function seedProfile(page, profile) { static parse(s) { return RealDate.parse(s); } }; + // Repart toujours d'un stockage de profils vierge : les addInitScript + // s'empilent au fil des captures et le DERNIER seed doit gagner — y + // compris sur l'index multi-profils écrit par seedMultiProfile. + localStorage.removeItem('multiplix-profiles'); + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k && k.startsWith('multiplix-profile:')) localStorage.removeItem(k); + } if (p === null) { - // Purge les deux schémas (legacy mono-profil + index multi-profils). localStorage.removeItem('multiplix-profile'); - localStorage.removeItem('multiplix-profiles'); } else { // On seede via la clé legacy : l'app la migre vers le schéma // multi-profils au boot, ce qui exerce aussi le chemin de migration. @@ -271,6 +277,27 @@ async function seedProfile(page, profile) { }, { p: profile, mockTodayIso: SEED_TODAY }); } +/** + * Seeds SEVERAL profiles directly in the multi-profile schema (per-profile + * key + index). Reuses seedProfile(null) for the seeded PRNG, frozen clock + * and landing skip; init scripts run in order so the writes below win. + */ +async function seedMultiProfile(page, entries) { + await seedProfile(page, null); + await page.addInitScript((list) => { + for (const { id, p } of list) { + localStorage.setItem('multiplix-profile:' + id, JSON.stringify(p)); + } + localStorage.setItem( + 'multiplix-profiles', + JSON.stringify({ + activeId: list[0].id, + profiles: list.map(({ id, p }) => ({ id, name: p.name })), + }), + ); + }, entries); +} + /** Returns the Leitner box of the currently displayed question's fact. */ async function readCurrentFactBox(page, q) { return page.evaluate((qq) => { @@ -723,6 +750,30 @@ async function captureDivisionScreens(page) { await shot(page, '17-division-question'); } +async function captureMultiProfileScreens(page) { + // Deux enfants sur l'appareil : Léa (le profil du reste du guide) + Max + // (prénom choisi pour tomber sur une couleur d'avatar différente de Léa — + // la couleur est un hash stable du prénom). + const lea = buildSampleProfile(); + const max = buildSampleProfile(); + max.name = 'Max'; + await seedMultiProfile(page, [ + { id: 'guide-lea', p: lea }, + { id: 'guide-max', p: max }, + ]); + await gotoHome(page); + + // Dès 2 profils, le boot passe par « Qui joue ? ». + await page.waitForSelector('.profile-select-screen'); + await shot(page, '19-profile-select'); + + // Accueil avec le bouton « changer de joueur » à côté de l'engrenage. + await page.click('.profile-select-btn:has-text("Léa")'); + await page.waitForSelector('.home-screen'); + await page.waitForSelector('.home-switch-btn'); + await shot(page, '20-home-multi'); +} + // --- HTML guide generator --------------------------------------------------- const SECTIONS = [ @@ -937,11 +988,31 @@ const SECTIONS = [ histogramme des boîtes Leitner, l'évolution du taux de réussite, les faits les plus difficiles, les temps de réponse moyens par table, l'historique des 10 dernières - séances, et les actions export / import du profil (JSON).`, + séances, les actions export / import du profil (JSON), et la gestion + des profils — ajouter un enfant ou supprimer le profil affiché (voir + « Plusieurs enfants » ci-dessous).`, shots: [ { file: '13-parent-dashboard', caption: 'Tableau de bord parent complet.' }, ], }, + { + id: 'profils', + title: 'Plusieurs enfants', + description: `Une tablette pour toute la fratrie : chaque enfant a son + propre profil — progression, badges, série et images mystère totalement + séparés. On ajoute un enfant depuis l'espace parent (« Ajouter un + enfant », section Profils) ou directement depuis l'écran de choix du + joueur. Dès deux profils, l'app demande « Qui joue ? » à l'ouverture, + et un bouton dédié en haut de l'accueil permet de changer de joueur à + tout moment. Avec un seul profil, rien ne change : pas d'écran ni de + bouton en plus. La suppression d'un profil se fait dans l'espace + parent, après confirmation — et l'export / import de sauvegarde reste + disponible profil par profil.`, + shots: [ + { file: '19-profile-select', caption: '« Qui joue ? » — l\'écran de choix affiché à l\'ouverture dès deux profils.' }, + { file: '20-home-multi', caption: 'Le bouton « changer de joueur » apparaît en haut de l\'accueil, à côté de l\'engrenage.' }, + ], + }, ]; async function buildHtml({ generatedAt }) { @@ -1330,6 +1401,7 @@ async function main() { await captureSessionScreens(page); await captureRecap(page); await captureDivisionScreens(page); + await captureMultiProfileScreens(page); // Voice capture runs LAST: it injects a SpeechRecognition stub and sets // the input mode to 'voice' via addInitScript, both of which would // pollute any subsequent capture (especially captureRecap which drives From 145367437fa0b82b34d3250ffc118b27f25a3fbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 10:45:47 +0000 Subject: [PATCH 3/5] refactor(profils): unifie le routage post-action sur initialScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression et annulation d'ajout prenaient la même décision que le boot (« Qui joue ? » si plusieurs profils, accueil sinon, onboarding si aucun) mais la ré-implémentaient chacune en ternaires locaux. Les trois chemins passent désormais par initialScreen. La visibilité du bouton Annuler du Welcome s'aligne sur profileCount (la sémantique réelle : il existe déjà un profil) plutôt que sur l'état mémoire profile. https://claude.ai/code/session_01C2TMaVQwKBai1xMrEE4KmL --- src/App.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 933315c2..72cbab86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -244,11 +244,11 @@ export default function App() { // rejoue l'onboarding Welcome complet, prénom + test de placement. const handleAddProfile = useCallback(() => setScreen('welcome'), []); - // Annulation de l'ajout d'un enfant : retour au choix du joueur s'il y a - // plusieurs profils, sinon à l'accueil de l'enfant actif. + // Annulation de l'ajout d'un enfant : même décision qu'au boot — choix du + // joueur s'il y a plusieurs profils, sinon l'accueil de l'enfant actif. const handleWelcomeCancel = useCallback(() => { - setScreen(listProfiles().length > 1 ? 'profiles' : 'home'); - }, []); + setScreen(initialScreen(profile, listProfiles().length)); + }, [profile]); const handleRulesIntroComplete = useCallback(() => { setProfile((prev) => (prev ? { ...prev, hasSeenRulesIntro: true } : prev)); @@ -557,11 +557,11 @@ export default function App() { ); if (!ok) return; deleteActiveProfile(); - // S'il reste plusieurs enfants → « Qui joue ? » ; un seul → son accueil - // directement ; aucun → onboarding complet. + // Même décision qu'au boot : plusieurs enfants → « Qui joue ? » ; un seul + // → son accueil directement ; aucun → onboarding complet. const next = loadProfile(); setProfile(next); - setScreen(next ? (listProfiles().length > 1 ? 'profiles' : profileHome(next)) : 'welcome'); + setScreen(initialScreen(next, listProfiles().length)); }, [profile]); return ( @@ -575,9 +575,10 @@ export default function App() { 0 ? handleWelcomeCancel : undefined} /> )} From 85ec8fc6393fb04756693b7785fc7a932e733847 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:59:25 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(profils):=20reproposer=20=C2=AB=20Qui?= =?UTF-8?q?=20joue=20=3F=20=C2=BB=20au=20retour=20au=20premier=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La PWA reste en mémoire des heures : l'enfant qui reprend la tablette n'est souvent pas celui qui l'a laissée, et le choix du joueur au boot ne couvre pas ce cas. Quand l'app revient au premier plan après plus de 15 minutes en arrière-plan et qu'il y a plusieurs profils, on repasse par l'écran de choix — uniquement depuis l'accueil (jamais en pleine séance, sur un récap ou dans l'espace parent), et jamais sur un aller-retour rapide (notification). Tests : seuil court vs long, exclusion mi-séance, mono-profil. Specs §12.1 mises à jour. https://claude.ai/code/session_01C2TMaVQwKBai1xMrEE4KmL --- public/specs/index.html | 1 + src/App.tsx | 29 +++++++++++ src/__tests__/multiProfile.test.tsx | 81 +++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/public/specs/index.html b/public/specs/index.html index 55820e1c..285465ce 100644 --- a/public/specs/index.html +++ b/public/specs/index.html @@ -993,6 +993,7 @@

      12.1Parcours

    4. Ajout d'un enfant : bouton « Ajouter un enfant » dans l'espace parent (section Profils) ou depuis l'écran « Qui joue ? ». Rejoue l'onboarding complet : prénom, test de placement, intro des règles ×1/×10.
    5. Dès 2 profils : l'app s'ouvre sur l'écran « Qui joue ? » (liste des prénoms, pastille-initiale colorée stable par prénom). On ne devine jamais quel enfant tient l'appareil — choisir coûte un tap, se tromper de profil polluerait les boîtes Leitner des deux enfants.
    6. Changer de joueur : bouton dédié en haut de l'écran d'accueil (visible uniquement en multi-profils), qui ramène à « Qui joue ? ».
    7. +
    8. Retour au premier plan : la PWA restant en mémoire des heures, le choix du boot ne suffit pas — quand l'app revient au premier plan après plus de 15 minutes en arrière-plan et qu'il y a plusieurs profils, « Qui joue ? » est reproposé. Uniquement depuis l'accueil : une séance, un récap ou une navigation parent en cours ne sont jamais interrompus, et un aller-retour rapide (notification) ne change rien.
    9. Suppression : « Supprimer ce profil » dans l'espace parent, avec confirmation explicite. S'il reste un seul profil → retour direct à son accueil ; plusieurs → « Qui joue ? » ; aucun → onboarding.
    10. diff --git a/src/App.tsx b/src/App.tsx index 72cbab86..1ea2b058 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,6 +88,14 @@ function initialScreen(profile: UserProfile | null, profileCount: number): Scree return profileHome(profile); } +// Retour au premier plan après une longue absence : sur une tablette +// familiale, l'enfant qui reprend l'app n'est souvent pas celui qui l'a +// laissée — et la PWA reste en mémoire des heures, donc le « Qui joue ? » du +// boot ne couvre pas ce cas. Au-delà de ce délai passé en arrière-plan, on +// repropose le choix du joueur. Sous le seuil (notification, aller-retour +// rapide), on ne touche à rien. +const RESHOW_PICKER_AFTER_HIDDEN_MS = 15 * 60 * 1000; + export default function App() { const [profile, setProfile] = useState(() => loadProfile()); const [screen, setScreen] = useState(() => initialScreen(profile, listProfiles().length)); @@ -219,6 +227,27 @@ export default function App() { }; }, []); + // Repropose « Qui joue ? » au retour au premier plan après une longue + // absence (cf. RESHOW_PICKER_AFTER_HIDDEN_MS), s'il y a plusieurs profils. + // Uniquement depuis l'accueil : on n'interrompt jamais une séance, un récap + // ou une navigation parent — leur état mémoire serait perdu. + useEffect(() => { + let hiddenAt = 0; + const onVisibility = () => { + if (document.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + const longAbsence = + hiddenAt > 0 && Date.now() - hiddenAt >= RESHOW_PICKER_AFTER_HIDDEN_MS; + hiddenAt = 0; + if (!longAbsence || listProfiles().length < 2) return; + setScreen((prev) => (prev === 'home' ? 'profiles' : prev)); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, []); + // Welcome: create new profile with optional placement test results. // addProfile persiste tout de suite sous un NOUVEL id (qui devient actif) : // sans ça, l'effet de sauvegarde écraserait le profil de l'enfant précédent diff --git a/src/__tests__/multiProfile.test.tsx b/src/__tests__/multiProfile.test.tsx index 03152cd7..0956977b 100644 --- a/src/__tests__/multiProfile.test.tsx +++ b/src/__tests__/multiProfile.test.tsx @@ -48,6 +48,25 @@ function readGreeting(): string { return document.querySelector('.home-greeting')?.textContent ?? ''; } +// Simule un passage arrière-plan / premier plan de la PWA. jsdom n'expose pas +// de setter pour visibilityState : on shadow le getter sur l'instance (retiré +// dans afterEach pour ne pas fuiter sur les autres tests). +function setVisibility(state: 'hidden' | 'visible'): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); +} + +// Avance l'horloge mockée sans déclencher les timers en attente (contrairement +// à advanceTimersByTime) : on simule du temps passé app cachée, pas des timers. +function jumpClock(ms: number): void { + vi.setSystemTime(new Date(Date.now() + ms)); +} + // Crée un profil via le vrai parcours Welcome (prénom + « Passer le test ») // puis ferme l'intro des règles pour atterrir sur Home. function completeWelcome(name: string): void { @@ -97,6 +116,9 @@ describe('Mode multi-profils (DOM)', () => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); + // Retire l'éventuel shadow posé par setVisibility (le getter du prototype + // reprend la main). + delete (document as { visibilityState?: unknown }).visibilityState; }); it("migre l'ancien profil mono-clé vers le schéma multi-profils sans rien perdre", () => { @@ -169,6 +191,65 @@ describe('Mode multi-profils (DOM)', () => { expect(readGreeting()).toContain('Zoe'); }); + it("repropose « Qui joue ? » au retour au premier plan après une longue absence", () => { + const zoe = createNewProfile('Zoe'); + zoe.hasSeenRulesIntro = true; + addProfile(zoe); + const max = createNewProfile('Max'); + max.hasSeenRulesIntro = true; + addProfile(max); + + render(); + fireEvent.click(findButton(/Max/)!); + expect(readGreeting()).toContain('Max'); + + // Aller-retour court (< 15 min) : on ne touche à rien. + setVisibility('hidden'); + jumpClock(5 * 60 * 1000); + setVisibility('visible'); + expect(document.querySelector('.profile-select-screen')).toBeNull(); + expect(readGreeting()).toContain('Max'); + + // Longue absence : retour sur le choix du joueur. + setVisibility('hidden'); + jumpClock(16 * 60 * 1000); + setVisibility('visible'); + expect(document.querySelector('.profile-select-screen')).not.toBeNull(); + }); + + it("ne repropose pas le choix du joueur en pleine séance ni en mono-profil", () => { + // Mono-profil : une longue absence ne déclenche rien. + const zoe = createNewProfile('Zoe'); + zoe.hasSeenRulesIntro = true; + addProfile(zoe); + render(); + expect(readGreeting()).toContain('Zoe'); + setVisibility('hidden'); + jumpClock(60 * 60 * 1000); + setVisibility('visible'); + expect(document.querySelector('.profile-select-screen')).toBeNull(); + expect(readGreeting()).toContain('Zoe'); + + // Multi-profils mais séance en cours : jamais d'interruption. + const max = createNewProfile('Max'); + max.hasSeenRulesIntro = true; + addProfile(max); + cleanup(); + render(); + fireEvent.click(findButton(/Max/)!); + fireEvent.click(findButton(/C'est parti/)!); + const inSession = () => + document.querySelector('.session-intro') !== null || + document.querySelector('.session-question-text') !== null; + expect(inSession()).toBe(true); + + setVisibility('hidden'); + jumpClock(60 * 60 * 1000); + setVisibility('visible'); + expect(document.querySelector('.profile-select-screen')).toBeNull(); + expect(inSession()).toBe(true); + }); + it("supprimer le profil actif bascule sur l'autre enfant", async () => { // Deux profils seedés directement (Max actif, dernier ajouté). const zoe = createNewProfile('Zoe'); From f44c31b5c09fd9d8f472dced6abc59c5cc627618 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 19:04:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?refactor(profils):=20partage=20la=20notion?= =?UTF-8?q?=20=C2=AB=20=C3=A9cran=20sans=20=C3=A9tat=20pr=C3=A9cieux=20?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le retour-picker au premier plan et le reload SW exprimaient deux fois la même notion (home/welcome/profiles = rien à perdre). Extraction d'un prédicat isDisposableScreen utilisé par les deux : le retour après une longue absence couvre désormais aussi un ajout d'enfant laissé en plan, comme le fait déjà le reload SW. Tests : helper seedProfiles pour le seeding deux-profils copié-collé dans 3 tests. https://claude.ai/code/session_01C2TMaVQwKBai1xMrEE4KmL --- public/specs/index.html | 2 +- src/App.tsx | 27 ++++++++++++++--------- src/__tests__/multiProfile.test.tsx | 33 ++++++++++++----------------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/public/specs/index.html b/public/specs/index.html index 285465ce..f4a2149e 100644 --- a/public/specs/index.html +++ b/public/specs/index.html @@ -993,7 +993,7 @@

      12.1Parcours

    11. Ajout d'un enfant : bouton « Ajouter un enfant » dans l'espace parent (section Profils) ou depuis l'écran « Qui joue ? ». Rejoue l'onboarding complet : prénom, test de placement, intro des règles ×1/×10.
    12. Dès 2 profils : l'app s'ouvre sur l'écran « Qui joue ? » (liste des prénoms, pastille-initiale colorée stable par prénom). On ne devine jamais quel enfant tient l'appareil — choisir coûte un tap, se tromper de profil polluerait les boîtes Leitner des deux enfants.
    13. Changer de joueur : bouton dédié en haut de l'écran d'accueil (visible uniquement en multi-profils), qui ramène à « Qui joue ? ».
    14. -
    15. Retour au premier plan : la PWA restant en mémoire des heures, le choix du boot ne suffit pas — quand l'app revient au premier plan après plus de 15 minutes en arrière-plan et qu'il y a plusieurs profils, « Qui joue ? » est reproposé. Uniquement depuis l'accueil : une séance, un récap ou une navigation parent en cours ne sont jamais interrompus, et un aller-retour rapide (notification) ne change rien.
    16. +
    17. Retour au premier plan : la PWA restant en mémoire des heures, le choix du boot ne suffit pas — quand l'app revient au premier plan après plus de 15 minutes en arrière-plan et qu'il y a plusieurs profils, « Qui joue ? » est reproposé. Uniquement depuis un écran sans état précieux (accueil, onboarding laissé en plan — même notion que pour les mises à jour du service worker) : une séance, un récap ou une navigation parent en cours ne sont jamais interrompus, et un aller-retour rapide (notification) ne change rien.
    18. Suppression : « Supprimer ce profil » dans l'espace parent, avec confirmation explicite. S'il reste un seul profil → retour direct à son accueil ; plusieurs → « Qui joue ? » ; aucun → onboarding.
    19. diff --git a/src/App.tsx b/src/App.tsx index 1ea2b058..9cd40cbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,6 +88,13 @@ function initialScreen(profile: UserProfile | null, profileCount: number): Scree return profileHome(profile); } +// Écrans sans état mémoire précieux : un reload SW ou un retour forcé au +// choix du joueur n'y fait rien perdre. Partout ailleurs (séance, récap, +// navigation parent…), interrompre casserait le travail en cours. +function isDisposableScreen(screen: Screen): boolean { + return screen === 'home' || screen === 'welcome' || screen === 'profiles'; +} + // Retour au premier plan après une longue absence : sur une tablette // familiale, l'enfant qui reprend l'app n'est souvent pas celui qui l'a // laissée — et la PWA reste en mémoire des heures, donc le « Qui joue ? » du @@ -200,13 +207,11 @@ export default function App() { }, [screen]); // Signale au pwa-register si on est dans un écran "safe" pour appliquer - // une mise à jour SW (= reload). `home`, `welcome` ET `profiles` le sont : - // ailleurs, un reload casserait l'état mémoire en cours (séance, recap - // animations, navigation parent, etc.). `welcome` est inclus car une install - // neuve (sans profil) y reste bloquée — sans ça, ces utilisateurs ne - // recevraient JAMAIS de mise à jour (ex. l'écran d'import lui-même). Rien de - // précieux à perdre en rechargeant l'accueil/l'onboarding/le choix du joueur. - const safeForReload = screen === 'home' || screen === 'welcome' || screen === 'profiles'; + // une mise à jour SW (= reload) — cf. isDisposableScreen. `welcome` est + // inclus car une install neuve (sans profil) y reste bloquée — sans ça, ces + // utilisateurs ne recevraient JAMAIS de mise à jour (ex. l'écran d'import + // lui-même). + const safeForReload = isDisposableScreen(screen); useEffect(() => { setSwBusy(!safeForReload); }, [safeForReload]); @@ -229,8 +234,10 @@ export default function App() { // Repropose « Qui joue ? » au retour au premier plan après une longue // absence (cf. RESHOW_PICKER_AFTER_HIDDEN_MS), s'il y a plusieurs profils. - // Uniquement depuis l'accueil : on n'interrompt jamais une séance, un récap - // ou une navigation parent — leur état mémoire serait perdu. + // Uniquement depuis un écran sans état précieux (même notion que le reload + // SW) : on n'interrompt jamais une séance, un récap ou une navigation + // parent. Un ajout d'enfant laissé en plan > 15 min, lui, est périmé — + // retour au choix du joueur, comme le ferait une mise à jour SW. useEffect(() => { let hiddenAt = 0; const onVisibility = () => { @@ -242,7 +249,7 @@ export default function App() { hiddenAt > 0 && Date.now() - hiddenAt >= RESHOW_PICKER_AFTER_HIDDEN_MS; hiddenAt = 0; if (!longAbsence || listProfiles().length < 2) return; - setScreen((prev) => (prev === 'home' ? 'profiles' : prev)); + setScreen((prev) => (isDisposableScreen(prev) ? 'profiles' : prev)); }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); diff --git a/src/__tests__/multiProfile.test.tsx b/src/__tests__/multiProfile.test.tsx index 0956977b..3de90a04 100644 --- a/src/__tests__/multiProfile.test.tsx +++ b/src/__tests__/multiProfile.test.tsx @@ -48,6 +48,16 @@ function readGreeting(): string { return document.querySelector('.home-greeting')?.textContent ?? ''; } +// Seed direct (sans passer par l'UI) : un profil prêt à jouer par prénom, +// onboarding déjà vu. Le dernier ajouté est le profil actif. +function seedProfiles(...names: string[]): void { + for (const name of names) { + const profile = createNewProfile(name); + profile.hasSeenRulesIntro = true; + addProfile(profile); + } +} + // Simule un passage arrière-plan / premier plan de la PWA. jsdom n'expose pas // de setter pour visibilityState : on shadow le getter sur l'instance (retiré // dans afterEach pour ne pas fuiter sur les autres tests). @@ -192,12 +202,7 @@ describe('Mode multi-profils (DOM)', () => { }); it("repropose « Qui joue ? » au retour au premier plan après une longue absence", () => { - const zoe = createNewProfile('Zoe'); - zoe.hasSeenRulesIntro = true; - addProfile(zoe); - const max = createNewProfile('Max'); - max.hasSeenRulesIntro = true; - addProfile(max); + seedProfiles('Zoe', 'Max'); render(); fireEvent.click(findButton(/Max/)!); @@ -219,9 +224,7 @@ describe('Mode multi-profils (DOM)', () => { it("ne repropose pas le choix du joueur en pleine séance ni en mono-profil", () => { // Mono-profil : une longue absence ne déclenche rien. - const zoe = createNewProfile('Zoe'); - zoe.hasSeenRulesIntro = true; - addProfile(zoe); + seedProfiles('Zoe'); render(); expect(readGreeting()).toContain('Zoe'); setVisibility('hidden'); @@ -231,9 +234,7 @@ describe('Mode multi-profils (DOM)', () => { expect(readGreeting()).toContain('Zoe'); // Multi-profils mais séance en cours : jamais d'interruption. - const max = createNewProfile('Max'); - max.hasSeenRulesIntro = true; - addProfile(max); + seedProfiles('Max'); cleanup(); render(); fireEvent.click(findButton(/Max/)!); @@ -251,13 +252,7 @@ describe('Mode multi-profils (DOM)', () => { }); it("supprimer le profil actif bascule sur l'autre enfant", async () => { - // Deux profils seedés directement (Max actif, dernier ajouté). - const zoe = createNewProfile('Zoe'); - zoe.hasSeenRulesIntro = true; - addProfile(zoe); - const max = createNewProfile('Max'); - max.hasSeenRulesIntro = true; - addProfile(max); + seedProfiles('Zoe', 'Max'); render(); fireEvent.click(findButton(/Max/)!);

    ÉcranContenu
    AccueilMascotte (animations d'idle), prénom, streak, bouton « C'est parti ! »
    Qui joue ?Choix du joueur au lancement — visible uniquement dès 2 profils sur l'appareil (§12)
    AccueilMascotte (animations d'idle), prénom, streak, bouton « C'est parti ! » (+ bouton « changer de joueur » en multi-profils)
    Séance — IntroGrille de points animée pour un nouveau fait
    Séance — QuestionQuestion en gros, pavé numérique, barre de progression
    Séance — Feedback correctAnimation joyeuse, mascotte enthousiaste