Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,20 @@
try {
var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
|| window.navigator.standalone === true;
var profile = false, skipped = false, hasImport = false;
var profile = false, skipped = false, hasBootFragment = false;
// 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) {}
// Profil dans le fragment d'URL → on boote l'app directement (pas la
// landing) : #import= (migration cross-origin depuis l'ancien domaine)
// ou #transfer= (transfert depuis un autre appareil, QR de l'espace
// parent). L'import réel a lieu dans main.tsx.
try { hasImport = location.hash.indexOf('import=') !== -1 || location.hash.indexOf('transfer=') !== -1; } catch (e) {}
if (standalone || profile || skipped || hasImport) {
// Fragment d'URL porteur d'un profil ou d'un appairage → on boote
// l'app directement (pas la landing) : #import= (migration
// cross-origin depuis l'ancien domaine), #transfer= (transfert depuis
// un autre appareil, QR de l'espace parent), ou #watch= (suivi à
// distance : un parent scanne le QR de l'appareil de son enfant, et
// n'a rien à faire de la landing). Le traitement réel a lieu dans
// main.tsx.
try { hasBootFragment = /(import|transfer|watch)=/.test(location.hash); } catch (e) {}
if (standalone || profile || skipped || hasBootFragment) {
document.documentElement.classList.add('skip-static-landing');
}
// Langue de l'interface (globale) : préférence explicite stockée,
Expand All @@ -79,7 +82,7 @@
if (lang === 'en') {
document.title = 'Tablito — Practise the times tables (free, no ads)';
var desc = document.querySelector('meta[name="description"]');
if (desc) desc.setAttribute('content', 'Free, ad-free app to learn the multiplication tables gently, from age 7. Leitner method, no grades or rankings, works offline. Data stays on the device.');
if (desc) desc.setAttribute('content', 'Free, ad-free app to learn the multiplication tables gently, from age 7. Leitner method, no grades or rankings, works offline. Data stays on the device by default.');
// Manifest localisé : le standard n'a pas de description multilingue,
// donc un fichier par langue, basculé ici (le <link rel="manifest">
// existe déjà plus haut). Seule la `description` diffère ; le nom et
Expand Down Expand Up @@ -123,7 +126,7 @@
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" />

<title>Tablito — Réviser les tables de multiplication (gratuit, sans pub)</title>
<meta name="description" content="Appli gratuite et sans publicité pour apprendre les tables de multiplication en douceur, dès le CE1. Méthode Leitner, sans notes ni classement, fonctionne hors-ligne. Les données restent sur l'appareil." />
<meta name="description" content="Appli gratuite et sans publicité pour apprendre les tables de multiplication en douceur, dès le CE1. Méthode Leitner, sans notes ni classement, fonctionne hors-ligne. Les données restent sur l'appareil par défaut." />
<link rel="canonical" href="https://tablito.app/" />

<!-- Open Graph / Twitter : aperçu riche quand l'URL est partagée (groupes
Expand Down Expand Up @@ -251,8 +254,8 @@ <h1 class="landing-title">Tablito</h1>
<span class="landing-principle-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M8 12l3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
</span>
<span data-lang="fr"><strong>Hors-ligne, sans pub, sans compte.</strong> Une fois installé, plus besoin de connexion. Les données restent sur l'appareil.</span>
<span data-lang="en"><strong>Offline, no ads, no account.</strong> Once installed, no connection needed. Data stays on the device.</span>
<span data-lang="fr"><strong>Hors-ligne, sans pub, sans compte.</strong> Une fois installé, plus besoin de connexion. Les données restent sur l'appareil par défaut.</span>
<span data-lang="en"><strong>Offline, no ads, no account.</strong> Once installed, no connection needed. Data stays on the device by default.</span>
</li>
</ul>

Expand Down
28 changes: 26 additions & 2 deletions public/specs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ <h1>Tablito<em>.</em></h1>
<li><a href="#division">Niveau 2 — Réviser par la division</a></li>
<li><a href="#reste">Niveau 3 — La division avec reste</a></li>
<li><a href="#profils">Multi-profils — plusieurs enfants par appareil</a></li>
<li><a href="#suivi">Suivi à distance — le parent sur son propre appareil</a></li>
<li><a href="#evolutions">Évolutions possibles (V2)</a></li>
<li><a href="#references">Références</a></li>
</ol>
Expand Down Expand Up @@ -783,6 +784,7 @@ <h3 id="prog-parent"><span class="num">5.2</span>Vue parent : tableau de bord</h
<li><strong>Envoyer un avis</strong> : formulaire texte libre + email optionnel. Les envois atterrissent dans une table Supabase (policy RLS insert-only via la publishable key). Un contexte anonyme (user-agent, viewport, stats agrégées du profil — nombre de séances, faits maîtrisés, streak) est joint pour faciliter le triage. Lecture réservée au propriétaire de l'app via la secret key (stockée en local, hors du repo)</li>
<li><strong>Partager l'app</strong> : ouvre la feuille de partage native du téléphone (iMessage, WhatsApp, mail…) pour envoyer le lien de Tablito ; sur ordinateur, le lien est copié dans le presse-papiers</li>
<li><strong>Rappel quotidien</strong> : toggle opt-in qui active une notification push quotidienne (détails en §8.1)</li>
<li><strong>Suivi à distance</strong> : partager la progression de cet appareil vers celui d'un parent, ou suivre un enfant qui pratique ailleurs (détails en §14)</li>
<li><strong>Confidentialité</strong> : page dédiée expliquant le traitement des données (localStorage, Supabase pour avis et push, droits associés)</li>
<li><strong>Nouveautés</strong> : journal des évolutions visibles côté enfant ou parent (alimenté par <code>src/lib/changelog.ts</code>)</li>
<li><strong>Réinitialiser le profil</strong> : efface les données locales et relance le test de positionnement — utile pour repartir de zéro ou changer d'enfant</li>
Expand Down Expand Up @@ -1068,15 +1070,37 @@ <h3 id="profils-stockage"><span class="num">13.2</span>Stockage et migration</h3
<p>Le <strong>transfert par QR code</strong> (§7) est lui aussi par-profil : le profil reçu devient un nouveau profil actif sur l'appareil de destination — sauf si le même enfant (prénom + date de début identiques) y existe déjà, auquel cas son profil est mis à jour au lieu d'être dupliqué (cas du re-transfert). Pendant la récupération, l'app affiche « Récupération de la progression… » (pas d'écran vide entre le scan et l'arrivée) ; l'import réussi est confirmé par une bannière « bien arrivée ✓ », un transfert échoué (lien expiré ou déjà servi) par une bannière d'erreur.</p>
</section>

<section class="chapter" id="suivi">
<h2><span class="num">14</span>Suivi à distance — le parent sur son propre appareil</h2>
<p>Le multi-profils (§13) couvre la tablette familiale partagée. Il ne couvre pas le cas, très courant, où l'enfant pratique sur un <strong>appareil qui n'est pas celui du parent</strong> — un ancien téléphone de la famille, en WiFi seul, gardé pour lui. Le parent n'a alors aucune visibilité sur la progression : le tableau de bord parent (§8) existe, mais il vit sur l'appareil de l'enfant.</p>
<p>Le suivi à distance répond à ce besoin sans introduire ni compte, ni mot de passe, ni adresse e-mail. Deux rôles, non exclusifs sur un même appareil : <strong>publieur</strong> (l'appareil de l'enfant) et <strong>suiveur</strong> (celui du parent).</p>

<h3 id="suivi-parcours"><span class="num">14.1</span>Parcours</h3>
<ul>
<li><strong>Ouvrir le partage</strong> (appareil de l'enfant) : « Partager la progression de &lt;prénom&gt; » dans l'espace parent, section « Suivi à distance ». Affiche un QR code — et un lien copiable en repli.</li>
<li><strong>Appairer</strong> (appareil du parent) : deux chemins. Scanner le QR avec l'appareil photo du téléphone, ce qui ouvre Tablito sur le lien ; ou, si Tablito est déjà installé, « Suivre un enfant à distance » depuis l'espace parent, qui filme le QR avec la caméra in-app (indispensable sur iPhone, où un lien n'ouvre jamais la web app installée).</li>
<li><strong>Parent qui n'a pas encore Tablito</strong> : le QR l'amène sur le site, et l'app boote <em>directement</em> sur l'espace parent avec la progression de l'enfant. Aucun onboarding enfant (prénom, test de placement) ne lui est proposé — ce serait un contresens : il n'est pas venu s'entraîner. La landing statique est également sautée.</li>
<li><strong>Consulter</strong> : le tableau de bord distant est le même écran que le tableau de bord local, aux mêmes statistiques. Une barre de fraîcheur indique la date du dernier dépôt (« Synchronisé il y a 2 h ») avec un bouton « Actualiser » — sans elle, un appareil enfant éteint depuis une semaine afficherait des chiffres périmés sans le dire.</li>
<li><strong>Deux sources côte à côte</strong> : un parent peut avoir son propre profil local (pour s'entraîner lui-même) <em>et</em> suivre un enfant à distance. Un sélecteur en tête de l'espace parent bascule d'une source à l'autre. Il n'apparaît que s'il y a vraiment un choix à faire.</li>
<li><strong>Arrêter</strong> : « Ne plus partager » côté enfant supprime le dépôt (les appareils suiveurs l'annoncent explicitement, distinct d'une panne réseau) ; « Ne plus suivre » côté parent oublie le suivi localement.</li>
</ul>

<h3 id="suivi-modele"><span class="num">14.2</span>Modèle et garanties</h3>
<p>Après chaque séance, l'appareil de l'enfant dépose un instantané de son profil, <strong>gzippé puis chiffré côté client</strong> (AES-GCM, clé aléatoire), sous un code de 96 bits. C'est la primitive du transfert (§7) : la clé de déchiffrement voyage dans le fragment d'URL du QR, jamais transmise au serveur, qui ne voit donc passer qu'un blob opaque.</p>
<p>Deux différences avec un transfert, qui justifient une table distincte : le dépôt est <strong>durable</strong> (rafraîchi à chaque séance au lieu d'être consommé) et la lecture est <strong>non consommante</strong>. Le code est donc une capacité permanente jusqu'à révocation — garde-fous : entropie inénumérable, clé jamais exposée au serveur (un dump de la base ne donne rien de lisible), révocation explicite, et purge automatique d'un suivi que plus aucune séance n'a rafraîchi depuis 6 mois.</p>
<p>Le profil suivi n'est <strong>jamais installé</strong> sur l'appareil du parent : il ne vit qu'en mémoire, le temps de l'afficher. Installé, il se mêlerait aux profils locaux (« Qui joue ? »), et une séance faite par erreur dessus divergerait de l'appareil de l'enfant, que le dépôt suivant écraserait.</p>
<p>Conséquence assumée sur les sections propres à l'appareil : sauvegarde, transfert, rappel quotidien et suppression de profil disparaissent quand la source affichée est un enfant distant — elles parleraient d'une progression qu'on n'a pas en main.</p>
</section>

<section class="chapter" id="evolutions">
<h2><span class="num">14</span>Évolutions possibles (V2)</h2>
<h2><span class="num">15</span>Évolutions possibles (V2)</h2>
<ul>
<li>Mode défi : séance bonus optionnelle le week-end</li>
</ul>
</section>

<section class="chapter" id="references">
<h2><span class="num">15</span>Références</h2>
<h2><span class="num">16</span>Références</h2>
<ol class="references">
<li>Axtell, P. K., McCallum, R. S., Bell, S. M., &amp; Poncy, B. (2009). Developing math automaticity using a classwide fluency building procedure for middle school students: A preliminary study. <em>Psychology in the Schools, 46</em>(6), 526–538.</li>
<li>Brendefur, J., Strother, S., Thiede, K., &amp; Appleton, S. (2015). Developing multiplication fact fluency. <em>Advances in Social Sciences Research Journal, 2</em>(8). <a href="https://doi.org/10.14738/assrj.28.1396">doi:10.14738/assrj.28.1396</a></li>
Expand Down
58 changes: 52 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
deleteActiveProfile,
setActiveProfile,
listProfiles,
getActiveProfileId,
createNewProfile,
exportProfile,
importProfile,
Expand All @@ -41,6 +42,8 @@ import { applyStreakUpdate } from './lib/streak';
import { isStandalone, clearInstallSkipped } from './lib/install';
import { preflightMicPermission } from './lib/micPreflight';
import { syncLastSession } from './lib/push';
import { listWatched } from './lib/watchStore';
import type { WatchPairing } from './lib/watch';
import { isVoiceMode } from './hooks/useInputMode';
import { useAppStrings } from './i18n/app';
// Eager : parcours principal (onboarding + boucle quotidienne). Ces
Expand Down Expand Up @@ -99,7 +102,13 @@ function initialScreen(profile: UserProfile | null, profileCount: number): Scree
// 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) {
// Aucun profil local mais au moins un enfant suivi à distance : l'appareil
// est celui d'un parent (il a peut-être découvert Tablito en scannant le QR
// de son enfant). Lui proposer l'onboarding enfant — prénom, test de
// placement — serait un contresens : on ouvre son espace parent.
return listWatched().length > 0 ? 'parent' : 'welcome';
}
return profileHome(profile);
}

Expand All @@ -125,13 +134,22 @@ interface AppProps {
// expiré, déjà consommé, hors-ligne…). Annoncé une fois, quel que soit
// l'écran d'arrivée.
transferResult?: 'imported' | 'error' | null;
// Issu d'un #watch= présent au boot : le parent vient de scanner le QR de
// l'appareil de son enfant. Déjà déchiffré par main.tsx, transmis tel quel à
// l'espace parent pour un affichage immédiat.
watchPairing?: WatchPairing | 'error' | null;
}

export default function App({ transferResult = null }: AppProps) {
export default function App({ transferResult = null, watchPairing = null }: AppProps) {
const appStrings = useAppStrings();
const [transferNotice, setTransferNotice] = useState(transferResult);
const [profile, setProfile] = useState<UserProfile | null>(() => loadProfile());
const [screen, setScreen] = useState<Screen>(() => initialScreen(profile, listProfiles().length));
// Un #watch= au boot signifie que le parent vient de scanner le QR de son
// enfant : quoi qu'il y ait par ailleurs sur l'appareil, ce qu'il veut voir
// est l'espace parent (même en cas d'échec — il peut y réessayer l'appairage).
const [screen, setScreen] = useState<Screen>(() =>
watchPairing ? 'parent' : 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.
Expand Down Expand Up @@ -615,6 +633,18 @@ export default function App({ transferResult = null }: AppProps) {
// pour que le cron saute l'envoi du soir. Best-effort (no-op si non
// abonné / push non configuré), jamais bloquant pour le recap.
void syncLastSession();

// Suivi à distance : rafraîchit l'instantané chiffré que consulte le
// parent sur son propre appareil. Même esprit que ci-dessus — no-op si le
// profil n'est pas partagé, et un échec (hors-ligne) sera rattrapé par la
// séance suivante, donc jamais bloquant pour le recap.
// Import dynamique : lib/watch tire le chiffrement (et transfer.ts) —
// hors de question de les faire entrer dans le graphe eager du boot pour
// un chemin qui ne sert qu'aux profils partagés.
const activeId = getActiveProfileId();
if (activeId) {
void import('./lib/watch').then((m) => m.publishWatchSnapshot(activeId, updatedProfile));
}
},
[profile, divisionUnlocked, remainderUnlocked, sessionMode],
);
Expand Down Expand Up @@ -688,6 +718,13 @@ export default function App({ transferResult = null }: AppProps) {
if (!profile) return;
const ok = window.confirm(appStrings.confirmDeleteProfile(profile.name));
if (!ok) return;
// Le profil part d'ici, donc son suivi à distance n'a plus d'objet : on
// révoque le dépôt AVANT de perdre l'id qui porte ses identifiants, sinon un
// instantané relisible survivrait à l'enfant (purgé au mieux après 6 mois).
const deletedId = getActiveProfileId();
if (deletedId) {
void import('./lib/watch').then((m) => m.stopWatch(deletedId));
}
deleteActiveProfile();
// Même décision qu'au boot : plusieurs enfants → « Qui joue ? » ; un seul
// → son accueil directement ; aucun → onboarding complet.
Expand Down Expand Up @@ -728,7 +765,10 @@ export default function App({ transferResult = null }: AppProps) {
// Annulable uniquement en mode « ajout d'un enfant » (il existe
// déjà au moins un profil) : au tout premier onboarding il n'y a
// nulle part où revenir.
onCancel={profileCount > 0 ? handleWelcomeCancel : undefined}
// Annulable dès qu'il y a un écran d'arrivée ailleurs : un profil
// local, OU un enfant suivi à distance (sinon le parent qui tape
// « Créer un profil sur cet appareil » reste piégé dans l'onboarding).
onCancel={profileCount > 0 || listWatched().length > 0 ? handleWelcomeCancel : undefined}
/>
)}

Expand Down Expand Up @@ -806,10 +846,16 @@ export default function App({ transferResult = null }: AppProps) {
<RulesScreen onBack={() => setScreen('home')} showRule11={rule11Unlocked} />
)}

{screen === 'parent' && profile && (
{/* `watchPairing` compte, y compris quand il vaut 'error' : un parent sans
Tablito qui scanne un QR périmé n'a ni profil local ni suivi mémorisé,
et sans ce terme il tomberait sur un écran blanc au lieu de l'espace
parent, d'où il peut réessayer l'appairage. */}
{screen === 'parent' && (profile || watchPairing || listWatched().length > 0) && (
<ParentDashboard
profile={profile}
onBack={() => setScreen('home')}
initialWatch={watchPairing && watchPairing !== 'error' ? watchPairing : null}
// Sans profil local, l'espace parent EST l'app : nulle part où revenir.
onBack={profile ? () => setScreen('home') : undefined}
onExport={handleExport}
onImport={handleImport}
onAddProfile={handleAddProfile}
Expand Down
Loading
Loading