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..f4a2149e 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,34 @@

    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

    + + +

    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)

    -

    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 +1041,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..b81081d8 100644 --- a/scripts/generate-user-guide.mjs +++ b/scripts/generate-user-guide.mjs @@ -256,9 +256,19 @@ 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) { localStorage.removeItem('multiplix-profile'); } 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 @@ -267,10 +277,39 @@ 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) => { - 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); @@ -711,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 = [ @@ -925,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 }) { @@ -1318,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 diff --git a/src/App.tsx b/src/App.tsx index 001b74e4..9cd40cbb 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,42 @@ 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); } +// É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 +// 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)); + 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 +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` 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) — 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]); @@ -194,14 +232,60 @@ export default function App() { }; }, []); - // Welcome: create new profile with optional placement test results + // 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 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 = () => { + 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) => (isDisposableScreen(prev) ? '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 + // 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 : 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(initialScreen(profile, listProfiles().length)); + }, [profile]); + const handleRulesIntroComplete = useCallback(() => { setProfile((prev) => (prev ? { ...prev, hasSeenRulesIntro: true } : prev)); setScreen('home'); @@ -487,25 +571,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(); + // 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(initialScreen(next, listProfiles().length)); + }, [profile]); return (
      @@ -515,7 +608,22 @@ export default function App() { qui flashe. */} {screen === 'welcome' && ( - + 0 ? handleWelcomeCancel : undefined} + /> + )} + + {screen === 'profiles' && ( + )} {screen === 'rulesIntro' && profile && ( @@ -538,6 +646,7 @@ export default function App() { onShowBadges={() => setScreen('badges')} onShowRules={handleShowRules} onShowParent={() => setScreen('parent')} + onSwitchProfile={profileCount > 1 ? () => setScreen('profiles') : undefined} /> )} @@ -594,7 +703,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..3de90a04 --- /dev/null +++ b/src/__tests__/multiProfile.test.tsx @@ -0,0 +1,271 @@ +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 ?? ''; +} + +// 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). +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 { + 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(); + // 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", () => { + // 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("repropose « Qui joue ? » au retour au premier plan après une longue absence", () => { + seedProfiles('Zoe', '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. + seedProfiles('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. + seedProfiles('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 () => { + seedProfiles('Zoe', '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 && ( + + )} )}

    É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