Mascotte (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)
+
Accueil
Mascotte (animations d'idle), prénom, streak, bouton « C'est parti ! » (+ bouton « changer de joueur » en multi-profils)
Séance — Intro
Grille de points animée pour un nouveau fait
Séance — Question
Question en gros, pavé numérique, barre de progression
Séance — Feedback correct
Animation joyeuse, mascotte enthousiaste
@@ -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
+
+
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 ? ».
+
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.
+
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
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.
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
@@ -1021,7 +1041,7 @@
13Références
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/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 && (
+
+ )}
)}