From 8dad1817431fe12d9a4f850489121bf681da7f1b Mon Sep 17 00:00:00 2001 From: Ivan Schneider Date: Mon, 27 Jul 2026 00:13:26 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20recap=20hebdomadaire=20du=20suivi?= =?UTF-8?q?=20=C3=A0=20distance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un miroir consultable ne sert que si le parent pense à le regarder. Un toggle facultatif, dans la section « Suivi à distance », envoie une notification le dimanche soir sur l'appareil du parent — au plus une par semaine, avec rattrapage le lundi si le cron a sauté la soirée. Le message ne contient AUCUN chiffre : l'instantané suivi étant chiffré de bout en bout, le serveur qui envoie la notification ne connaît ni le prénom de l'enfant ni sa progression. Le clic ouvre l'espace parent, qui déchiffre localement. C'est ce qui permet un signal poussé sans rien concéder sur la confidentialité, là où un e-mail aurait imposé de composer le contenu côté serveur, donc de l'y exposer en clair. Le rappel quotidien et le recap ne s'adressent pas à la même personne (l'enfant sur son appareil, le parent sur le sien) : ils deviennent deux préférences indépendantes portées par la même souscription push, avec mise à jour partielle côté SQL pour que les deux toggles ne s'écrasent pas. - `upsert_push_prefs` porte un NOM DISTINCT de `upsert_push_subscription` (conservée intacte) : élargir la signature en aurait fait une surcharge Postgres, rendant ambigus les appels des clients encore en cache - le RPC renvoie l'état résultant, pour que « plus rien d'actif → on désabonne » découle d'une valeur lue et jamais d'une lecture qui a échoué - miroir localStorage des préférences : l'affichage des toggles ne doit pas dépendre du réseau (une PWA s'ouvre souvent hors-ligne) - sw.js : un tag par type de notification, et navigation avant focus pour le seul recap — avec catch, navigate() rejetant sur un client non contrôlé - cesser de suivre le dernier enfant éteint le recap, dont le toggle disparaît --- index.html | 7 +- public/specs/index.html | 7 +- scripts/send-reminders.mjs | 126 +++++++++++++++---- scripts/send-reminders.test.mjs | 99 ++++++++++++++- scripts/sw.js | 27 +++- src/App.tsx | 14 ++- src/__tests__/remoteFollow.test.tsx | 29 ++++- src/components/NotificationSettings.tsx | 89 ++++---------- src/components/PushToggle.tsx | 42 +++++++ src/components/WeeklyRecapSettings.tsx | 44 +++++++ src/hooks/usePushPref.ts | 49 ++++++++ src/i18n/changelog.ts | 12 ++ src/i18n/parent.ts | 41 +++++++ src/i18n/privacy.tsx | 57 ++++++--- src/lib/push.ts | 157 +++++++++++++++++++----- src/lib/watchStore.ts | 8 ++ src/main.tsx | 13 +- src/screens/ParentDashboard.tsx | 38 ++++-- supabase/push_subscriptions.sql | 111 ++++++++++++++++- 19 files changed, 791 insertions(+), 179 deletions(-) create mode 100644 src/components/PushToggle.tsx create mode 100644 src/components/WeeklyRecapSettings.tsx create mode 100644 src/hooks/usePushPref.ts diff --git a/index.html b/index.html index 786cdc5b..d81692d9 100644 --- a/index.html +++ b/index.html @@ -61,9 +61,10 @@ // 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) {} + // n'a rien à faire de la landing), ou #recap (clic sur la notification + // hebdomadaire, qui ouvre l'espace parent). Le traitement réel a lieu + // dans main.tsx / App.tsx. + try { hasBootFragment = /(import|transfer|watch)=|^#recap$/.test(location.hash); } catch (e) {} if (standalone || profile || skipped || hasBootFragment) { document.documentElement.classList.add('skip-static-landing'); } diff --git a/public/specs/index.html b/public/specs/index.html index dd4e17cd..e5f32b74 100644 --- a/public/specs/index.html +++ b/public/specs/index.html @@ -1085,7 +1085,12 @@

14.1Parcours

  • Arrêter : « 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.
  • -

    14.2Modèle et garanties

    +

    14.2Recap hebdomadaire

    +

    Un miroir consultable ne sert que si le parent pense à le regarder. Un toggle facultatif, dans la même section, envoie une notification le dimanche soir sur l'appareil du parent — au plus une par semaine. Il n'apparaît que si l'appareil suit au moins un enfant : sans suivi, il n'y a aucun recap à annoncer.

    +

    Cette notification est indépendante du rappel quotidien (§8.1), et c'est le point important : les deux ne s'adressent pas à la même personne. Le rappel pousse l'enfant à faire sa séance, sur son appareil ; le recap prévient le parent, sur le sien. Vouloir l'un sans l'autre est le cas normal, d'où deux préférences distinctes portées par la même souscription push. Quand les deux tombent le même soir, le recap prime — un appareil ne reçoit jamais deux notifications dans la même soirée.

    +

    Le message est générique et sans aucun chiffre : l'instantané étant chiffré de bout en bout, le serveur qui envoie la notification ne connaît ni le prénom de l'enfant ni sa progression. Le clic ouvre l'espace parent, qui déchiffre localement et affiche le contenu réel. C'est ce qui permet d'avoir un signal poussé sans rien concéder sur la confidentialité — là où un e-mail aurait imposé de composer le contenu côté serveur, donc de l'y exposer en clair.

    + +

    14.3Modèle et garanties

    Après chaque séance, l'appareil de l'enfant dépose un instantané de son profil, gzippé puis chiffré côté client (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.

    Deux différences avec un transfert, qui justifient une table distincte : le dépôt est durable (rafraîchi à chaque séance au lieu d'être consommé) et la lecture est non consommante. 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.

    Le profil suivi n'est jamais installé 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.

    diff --git a/scripts/send-reminders.mjs b/scripts/send-reminders.mjs index bb950275..932a0958 100644 --- a/scripts/send-reminders.mjs +++ b/scripts/send-reminders.mjs @@ -1,19 +1,44 @@ -// Cron d'envoi du rappel quotidien (Web Push). +// Cron d'envoi des notifications Web Push. // // Lancé toutes les heures par .github/workflows/send-reminders.yml. Lit la -// table Supabase `push_subscriptions` (service key), et pour chaque abonné dont -// c'est l'heure (18h–23h locale) envoie une notif, sauf si l'enfant a déjà -// pratiqué aujourd'hui (anti-nag) ou a déjà été notifié aujourd'hui -// (dédoublonnage, robuste au décalage des crons GitHub). +// table Supabase `push_subscriptions` (service key) et, pour chaque abonné dont +// c'est l'heure (18h–23h locale), décide quoi envoyer parmi DEUX notifications : // -// La logique de décision est isolée dans `shouldSend` (fonction pure, testée -// dans scripts/send-reminders.test.mjs). web-push est importé paresseusement -// dans main() pour que les tests puissent importer ce module sans charger la lib. +// - RAPPEL QUOTIDIEN (`daily_reminder`) : sur l'appareil de l'enfant, sauf s'il +// a déjà pratiqué aujourd'hui (anti-nag) ou déjà été notifié aujourd'hui +// (dédoublonnage, robuste au décalage des crons GitHub). +// - RECAP HEBDOMADAIRE (`weekly_recap`) : sur l'appareil du parent qui suit un +// enfant à distance, le dimanche soir, au plus une fois par semaine. +// +// Le recap PRIME sur le rappel quand les deux tombent le même soir : un appareil +// ne reçoit jamais deux notifications dans la même soirée, et le recap est le +// plus informatif des deux. En pratique le cas est rare (les deux drapeaux +// visent des appareils différents), mais rien ne l'interdit. +// +// Le corps du recap est GÉNÉRIQUE : l'instantané suivi est chiffré de bout en +// bout, donc le serveur ne connaît ni le prénom de l'enfant ni ses chiffres. La +// notification annonce qu'un recap est prêt ; c'est l'app qui déchiffre. +// +// La logique de décision est isolée dans `plan` (fonction pure, testée dans +// scripts/send-reminders.test.mjs). web-push est importé paresseusement dans +// main() pour que les tests puissent importer ce module sans charger la lib. import { pathToFileURL } from 'node:url'; export const REMINDER_HOUR = 18; export const WINDOW_HOURS = 5; // fenêtre d'envoi : 18h ≤ heure locale < 23h +// Dimanche soir : la semaine vient de se terminer, et c'est le moment où un +// parent peut encore réagir avant la semaine d'école qui commence. +export const WEEKLY_DAY = 0; // 0 = dimanche +// Rattrapage : les workflows planifiés GitHub sont régulièrement décalés et +// parfois sautés sous charge. Sans filet, une soirée de dimanche manquée perd le +// recap de la semaine (le rappel quotidien, lui, revient le lendemain). On +// autorise donc un envoi le lundi soir s'il n'y en a pas eu depuis une semaine. +export const WEEKLY_CATCHUP_DAY = 1; // lundi +// Garde-fou de dédoublonnage : jamais deux recaps à moins de 6 jours. Comparer +// des dates plutôt que calculer un numéro de semaine ISO évite les cas tordus de +// bascule d'année. +export const WEEKLY_MIN_DAYS = 6; function localHour(tz, now) { return Number( @@ -25,23 +50,58 @@ function localDate(tz, now) { return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(now); // YYYY-MM-DD } +// Jour de la semaine (0 = dimanche) d'une date locale 'YYYY-MM-DD'. Dérivé de la +// date déjà calculée dans le bon fuseau, plutôt que d'un second Intl : une table +// de noms anglais et un indexOf renverraient -1 en silence si la sortie changeait. +function weekdayOf(dateISO) { + return new Date(`${dateISO}T00:00:00Z`).getUTCDay(); +} + +// Écart en jours pleins entre deux dates 'YYYY-MM-DD' (UTC des deux côtés, donc +// pas de dérive de fuseau : on ne compare que des calendriers). +function daysBetween(fromISO, toISO) { + const ms = Date.parse(`${toISO}T00:00:00Z`) - Date.parse(`${fromISO}T00:00:00Z`); + return Number.isNaN(ms) ? Infinity : Math.round(ms / 86400000); +} + /** - * Faut-il notifier cette subscription maintenant ? - * @param {{timezone:string,last_session_date?:string|null,last_notified_date?:string|null}} sub + * Que faut-il envoyer à cette subscription maintenant ? + * @param {{timezone:string,daily_reminder?:boolean,weekly_recap?:boolean, + * last_session_date?:string|null,last_notified_date?:string|null, + * last_weekly_date?:string|null}} sub * @param {Date} now + * @returns {'weekly'|'daily'|null} */ -export function shouldSend(sub, now) { +export function plan(sub, now) { let hour, date; try { hour = localHour(sub.timezone, now); date = localDate(sub.timezone, now); } catch { - return false; // fuseau invalide → on ignore plutôt que de crasher + return null; // fuseau invalide → on ignore plutôt que de crasher } - if (hour < REMINDER_HOUR || hour >= REMINDER_HOUR + WINDOW_HOURS) return false; - if (sub.last_notified_date === date) return false; // déjà notifié aujourd'hui - if (sub.last_session_date === date) return false; // déjà pratiqué aujourd'hui - return true; + if (hour < REMINDER_HOUR || hour >= REMINDER_HOUR + WINDOW_HOURS) return null; + if (sub.last_notified_date === date) return null; // déjà notifié aujourd'hui + + // Le recap d'abord : plus informatif, et il ne concurrence le rappel qu'un + // soir sur sept. + const weekday = weekdayOf(date); + const sinceLastWeekly = sub.last_weekly_date + ? daysBetween(sub.last_weekly_date, date) + : Infinity; + const weeklyDue = + weekday === WEEKLY_DAY + ? sinceLastWeekly >= WEEKLY_MIN_DAYS + // Lundi : uniquement en rattrapage d'un dimanche manqué (cron sauté). + : weekday === WEEKLY_CATCHUP_DAY && sinceLastWeekly >= 7; + if (sub.weekly_recap && weeklyDue) return 'weekly'; + + // Rétrocompat : une ligne écrite avant la colonne `daily_reminder` n'a pas le + // champ dans un test qui l'omet — l'absence vaut « abonné au rappel », ce que + // le défaut SQL (true) garantit aussi côté base. + if (sub.daily_reminder === false) return null; + if (sub.last_session_date === date) return null; // déjà pratiqué aujourd'hui + return 'daily'; } async function main() { @@ -76,25 +136,43 @@ async function main() { } const subs = await res.json(); const now = new Date(); - const due = subs.filter((s) => shouldSend(s, now)); - console.log(`${subs.length} subscription(s), ${due.length} à notifier.`); + const due = subs.map((s) => ({ sub: s, kind: plan(s, now) })).filter((d) => d.kind); + const weekly = due.filter((d) => d.kind === 'weekly').length; + console.log(`${subs.length} subscription(s), ${due.length} à notifier (dont ${weekly} recap).`); let sent = 0, removed = 0, failed = 0; - // Neutre vis-à-vis de l'opération : la séance du jour peut être de la - // multiplication ou de la division (niveau 2) selon la progression. - const body = "C'est l'heure de ta séance Tablito ! 🎯"; - for (const sub of due) { - const payload = JSON.stringify({ title: 'Tablito', body, url: reminderUrl }); + // Rappel quotidien — neutre vis-à-vis de l'opération : la séance du jour peut + // être de la multiplication, de la division ou de la division avec reste. + const dailyBody = "C'est l'heure de ta séance Tablito ! 🎯"; + // Recap hebdomadaire — aucune donnée : le serveur ne peut pas lire la + // progression suivie (chiffrée de bout en bout), et n'a donc ni prénom ni + // chiffre à mettre ici. Le contenu, c'est l'app qui l'affiche après + // déchiffrement local. + const weeklyBody = 'Le recap de la semaine est prêt 📊'; + for (const { sub, kind } of due) { + const isWeekly = kind === 'weekly'; + const payload = JSON.stringify({ + title: 'Tablito', + body: isWeekly ? weeklyBody : dailyBody, + // Le recap ouvre directement l'espace parent, sur le suivi à distance. + url: isWeekly ? `${reminderUrl}#recap` : reminderUrl, + tag: isWeekly ? 'weekly-recap' : 'daily-reminder', + }); try { await webpush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload, ); sent++; + const today = localDate(sub.timezone, now); await fetch(`${table}?endpoint=eq.${encodeURIComponent(sub.endpoint)}`, { method: 'PATCH', headers: { ...headers, Prefer: 'return=minimal' }, - body: JSON.stringify({ last_notified_date: localDate(sub.timezone, now), updated_at: now.toISOString() }), + body: JSON.stringify({ + last_notified_date: today, + ...(isWeekly ? { last_weekly_date: today } : {}), + updated_at: now.toISOString(), + }), }); } catch (err) { const code = err?.statusCode; diff --git a/scripts/send-reminders.test.mjs b/scripts/send-reminders.test.mjs index 9d0e4295..63ffd508 100644 --- a/scripts/send-reminders.test.mjs +++ b/scripts/send-reminders.test.mjs @@ -1,18 +1,40 @@ import { describe, it, expect } from 'vitest'; -import { shouldSend, REMINDER_HOUR, WINDOW_HOURS } from './send-reminders.mjs'; +import { + plan, + REMINDER_HOUR, + WINDOW_HOURS, + WEEKLY_DAY, + WEEKLY_CATCHUP_DAY, + WEEKLY_MIN_DAYS, +} from './send-reminders.mjs'; // Rappels : // - REMINDER_HOUR=18, WINDOW_HOURS=5 → fenêtre [18h, 23h[ heure LOCALE. // - Paris en mai = UTC+2 → 18h Paris = 16:00Z. -// - shouldSend compare last_notified_date / last_session_date à la date LOCALE. +// - plan() compare last_notified_date / last_session_date à la date LOCALE. +// - 2026-05-29 est un vendredi ; 2026-05-31 un dimanche. const paris = (sub) => ({ timezone: 'Europe/Paris', ...sub }); +// Raccourci : un abonné au seul rappel quotidien (le cas historique). +const shouldSend = (sub, now) => plan(sub, now) === 'daily'; -describe('shouldSend', () => { +describe('rappel quotidien', () => { it('expose la fenêtre attendue', () => { expect(REMINDER_HOUR).toBe(18); expect(WINDOW_HOURS).toBe(5); }); + it('reste envoyé quand daily_reminder est absent (lignes d’avant la colonne)', () => { + // Le défaut SQL vaut true : une ligne écrite avant l'ajout de la colonne + // doit continuer à recevoir son rappel, sans quoi la feature aurait + // silencieusement désabonné tout le monde. + expect(plan(paris({}), new Date('2026-05-29T16:00:00Z'))).toBe('daily'); + }); + + it('n’envoie rien si le rappel quotidien est désactivé', () => { + const now = new Date('2026-05-29T16:00:00Z'); + expect(plan(paris({ daily_reminder: false }), now)).toBe(null); + }); + it('notifie à 18h locale si ni notifié ni pratiqué', () => { const now = new Date('2026-05-29T16:00:00Z'); // 18h Paris expect(shouldSend(paris({}), now)).toBe(true); @@ -51,3 +73,74 @@ describe('shouldSend', () => { expect(shouldSend({ timezone: 'Not/AZone' }, new Date('2026-05-29T16:00:00Z'))).toBe(false); }); }); + +describe('recap hebdomadaire', () => { + const dimanche18h = new Date('2026-05-31T16:00:00Z'); // dimanche 18h Paris + const vendredi18h = new Date('2026-05-29T16:00:00Z'); + const abonne = (sub) => paris({ weekly_recap: true, daily_reminder: false, ...sub }); + + it('envoie le dimanche soir', () => { + expect(WEEKLY_DAY).toBe(0); + expect(plan(abonne({}), dimanche18h)).toBe('weekly'); + }); + + it('n’envoie pas les autres jours', () => { + expect(plan(abonne({}), vendredi18h)).toBe(null); + }); + + it('n’envoie pas hors de la fenêtre horaire', () => { + expect(plan(abonne({}), new Date('2026-05-31T13:00:00Z'))).toBe(null); // 15h Paris + }); + + it('n’envoie pas deux fois la même semaine', () => { + expect(plan(abonne({ last_weekly_date: '2026-05-31' }), dimanche18h)).toBe(null); + // Le dimanche suivant (7 jours) repasse. + expect(plan(abonne({ last_weekly_date: '2026-05-24' }), dimanche18h)).toBe('weekly'); + expect(WEEKLY_MIN_DAYS).toBe(6); + }); + + it('rattrape le lundi soir un dimanche manqué', () => { + // Les crons GitHub sont parfois décalés ou sautés : sans rattrapage, une + // soirée manquée perd le recap de la semaine entière. + expect(WEEKLY_CATCHUP_DAY).toBe(1); + const lundi18h = new Date('2026-06-01T16:00:00Z'); + expect(plan(abonne({ last_weekly_date: '2026-05-24' }), lundi18h)).toBe('weekly'); + // Mais pas si le dimanche a bien été envoyé la veille. + expect(plan(abonne({ last_weekly_date: '2026-05-31' }), lundi18h)).toBe(null); + }); + + it('ne rattrape pas les autres jours de la semaine', () => { + const mardi18h = new Date('2026-06-02T16:00:00Z'); + expect(plan(abonne({ last_weekly_date: '2026-05-17' }), mardi18h)).toBe(null); + }); + + it('n’envoie rien si le recap est désactivé', () => { + expect(plan(paris({ weekly_recap: false, daily_reminder: false }), dimanche18h)).toBe(null); + }); + + it('prime sur le rappel quotidien quand les deux tombent le même soir', () => { + // Un appareil abonné aux deux ne doit pas recevoir deux notifications. + const deux = paris({ weekly_recap: true, daily_reminder: true }); + expect(plan(deux, dimanche18h)).toBe('weekly'); + // Les autres soirs, le rappel reprend la main. + expect(plan(deux, vendredi18h)).toBe('daily'); + }); + + it('n’est PAS bloqué par une séance faite le jour même', () => { + // L'anti-nag protège l'enfant d'un rappel inutile ; il n'a aucun sens pour + // le parent, à qui on annonce justement que la progression a bougé. + const sub = abonne({ last_session_date: '2026-05-31' }); + expect(plan(sub, dimanche18h)).toBe('weekly'); + }); + + it('respecte le dédoublonnage global d’une notification par soir', () => { + expect(plan(abonne({ last_notified_date: '2026-05-31' }), dimanche18h)).toBe(null); + }); + + it('suit le fuseau de l’abonné pour le jour de la semaine', () => { + // 2026-06-01T01:00Z : lundi 03h à Paris, mais encore dimanche 21h à New York. + const instant = new Date('2026-06-01T01:00:00Z'); + expect(plan({ timezone: 'America/New_York', weekly_recap: true }, instant)).toBe('weekly'); + expect(plan({ timezone: 'Europe/Paris', weekly_recap: true }, instant)).toBe(null); + }); +}); diff --git a/scripts/sw.js b/scripts/sw.js index 10b92b13..08c6d63a 100644 --- a/scripts/sw.js +++ b/scripts/sw.js @@ -88,20 +88,25 @@ self.addEventListener('fetch', (e) => { ) }) -// Push : rappel quotidien (cf. scripts/send-reminders.mjs). Le payload est un -// JSON {title, body, url}. Fallback défensif si le payload manque/est illisible. +// Push : rappel quotidien de séance ou recap hebdomadaire du suivi à distance +// (cf. scripts/send-reminders.mjs). Le payload est un JSON {title, body, url, +// tag}. Fallback défensif si le payload manque/est illisible. self.addEventListener('push', (e) => { let data = {} try { data = e.data ? e.data.json() : {} } catch { data = {} } const title = data.title || 'Tablito' const body = data.body || "C'est l'heure de ta séance Tablito ! 🎯" const url = data.url || BASE + // Un tag par type : au sein d'un type, une notif non lue est remplacée plutôt + // qu'empilée — mais un recap ne doit pas effacer un rappel de séance, ni + // l'inverse (ils ne s'adressent même pas à la même personne). + const tag = data.tag || 'daily-reminder' e.waitUntil( self.registration.showNotification(title, { body, icon: BASE + 'icons/icon-192.png', badge: BASE + 'icons/icon-192.png', - tag: 'daily-reminder', // remplace une notif précédente non lue plutôt que d'empiler + tag, data: { url }, }) ) @@ -114,7 +119,21 @@ self.addEventListener('notificationclick', (e) => { e.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { for (const c of clients) { - if ('focus' in c) return c.focus() + if (!('focus' in c)) continue + // Une app déjà ouverte reprend là où elle en était : sans navigation, le + // fragment de la notification serait ignoré et le parent retomberait sur + // l'écran précédent au lieu du recap. On ne navigue QUE dans ce cas — le + // rappel quotidien pointe sur la racine, et rediriger un enfant en pleine + // séance lui ferait perdre sa séance. + const needsNavigate = 'navigate' in c && target !== BASE && c.url !== target + if (!needsNavigate) return c.focus() + // navigate() REJETTE sur un client non contrôlé par ce SW — et matchAll + // ci-dessus inclut volontairement les non contrôlés. Sans ce catch, le + // rejet remonte au waitUntil et le clic ne fait plus rien du tout. + return c + .navigate(target) + .then((n) => (n || c).focus()) + .catch(() => c.focus()) } if (self.clients.openWindow) return self.clients.openWindow(target) }) diff --git a/src/App.tsx b/src/App.tsx index c30574e7..d27e4274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,9 +138,16 @@ interface AppProps { // 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; + // Vrai si le boot venait d'un clic sur la notification de recap hebdomadaire + // (fragment RECAP_HASH, consommé par main.tsx comme les autres fragments). + recapRequested?: boolean; } -export default function App({ transferResult = null, watchPairing = null }: AppProps) { +export default function App({ + transferResult = null, + watchPairing = null, + recapRequested = false, +}: AppProps) { const appStrings = useAppStrings(); const [transferNotice, setTransferNotice] = useState(transferResult); const [profile, setProfile] = useState(() => loadProfile()); @@ -148,7 +155,10 @@ export default function App({ transferResult = null, watchPairing = null }: AppP // 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(() => - watchPairing ? 'parent' : initialScreen(profile, listProfiles().length), + // Un #watch= au boot signifie un appairage ; #recap vient du clic sur la + // notification hebdomadaire. Dans les deux cas c'est l'espace parent qu'on + // veut, pas l'accueil de l'enfant. + watchPairing || recapRequested ? '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 diff --git a/src/__tests__/remoteFollow.test.tsx b/src/__tests__/remoteFollow.test.tsx index 7a37fa6e..a4128786 100644 --- a/src/__tests__/remoteFollow.test.tsx +++ b/src/__tests__/remoteFollow.test.tsx @@ -31,7 +31,9 @@ async function shareChildProgress(name: string, totalSessions = 9) { // Monte l'app et laisse le React.lazy() de ParentDashboard se résoudre : il se // règle en microtâche, donc le tout premier rendu d'un fichier de test a besoin // d'un tick de plus que les suivants (où le module est déjà résolu). -async function renderApp(props: { watchPairing?: WatchPairing | 'error' | null } = {}) { +async function renderApp( + props: { watchPairing?: WatchPairing | 'error' | null; recapRequested?: boolean } = {}, +) { await act(async () => { render(); }); @@ -196,6 +198,31 @@ function completeWelcome(name: string): void { fireEvent.click(findButton(/J'ai compris/)!); } +describe('lien profond #recap (clic sur la notification hebdomadaire)', () => { + it('ouvre l’espace parent même quand un profil local existe', async () => { + mockWatchServer({ otherCalls: 'ignore' }); + const { link } = await shareChildProgress('Zoé', 12); + localStorage.clear(); + localStorage.setItem('multiplix-lang', 'fr'); + await addWatched(link); + const mine = createNewProfile('Papa'); + mine.hasSeenRulesIntro = true; + addProfile(mine); + + // Boot ordinaire : l'accueil de l'enfant local. + await renderApp(); + expect(document.querySelector('.parent-dashboard')).toBeNull(); + cleanup(); + + // Clic sur la notification : on atterrit dans l'espace parent, sinon elle + // déposerait le parent sur un écran qui n'a rien à voir avec le recap. + // (La consommation du fragment #recap elle-même vit dans main.tsx boot(), + // avec celle des autres fragments.) + await renderApp({ recapRequested: true }); + expect(document.querySelector('.parent-dashboard')).not.toBeNull(); + }); +}); + describe('appareil mixte : un profil local ET un enfant suivi', () => { // Le cas d'usage d'origine : le parent pratique lui-même sur son téléphone et // suit son enfant, qui pratique sur un autre appareil. diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx index 2f5c8857..a733c820 100644 --- a/src/components/NotificationSettings.tsx +++ b/src/components/NotificationSettings.tsx @@ -1,62 +1,26 @@ -import { useState, useEffect, useCallback } from 'react'; -import { - pushConfigured, - pushSupported, - isSubscribed, - subscribeToReminders, - unsubscribeFromReminders, -} from '../lib/push'; +import { pushConfigured, pushSupported } from '../lib/push'; import { isIOS, isStandalone } from '../lib/install'; import { useNotificationSettingsStrings } from '../i18n/parent'; +import { usePushPref } from '../hooks/usePushPref'; +import PushToggle from './PushToggle'; // Section « Rappel quotidien » de l'espace parent : un simple toggle on/off. // L'heure (18h locale) est fixe côté serveur (cf. scripts/send-reminders.mjs) ; -// pas de sélecteur d'heure. La source de vérité de l'état activé/désactivé est -// la subscription du navigateur (isSubscribed), pas le profil — on la -// réconcilie au montage pour gérer une permission révoquée hors de l'app. +// pas de sélecteur d'heure. La source de vérité est la préférence enregistrée +// pour cet appareil (cf. usePushPref), réconciliée au montage pour gérer une +// permission révoquée hors de l'app. +// +// Ce rappel s'adresse à l'ENFANT, sur l'appareil où il pratique. Le recap +// hebdomadaire destiné au parent est un autre toggle, dans la section « Suivi à +// distance » (WeeklyRecapSettings) — les deux sont indépendants. export default function NotificationSettings() { - const [enabled, setEnabled] = useState(false); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(null); const t = useNotificationSettingsStrings(); + const { enabled, busy, message, toggle } = usePushPref('daily', t); const supported = pushSupported(); // Push web sur iOS : seulement en PWA installée (iOS 16.4+). const iosNeedsInstall = isIOS() && !isStandalone(); - useEffect(() => { - let cancelled = false; - isSubscribed().then((v) => { - if (!cancelled) setEnabled(v); - }); - return () => { - cancelled = true; - }; - }, []); - - const handleToggle = useCallback(async () => { - if (busy) return; - setBusy(true); - setMessage(null); - try { - if (enabled) { - await unsubscribeFromReminders(); - setEnabled(false); - } else { - const res = await subscribeToReminders(); - if (res === 'subscribed') { - setEnabled(true); - } else if (res === 'denied') { - setMessage(t.blocked); - } else { - setMessage(t.unavailable); - } - } - } finally { - setBusy(false); - } - }, [busy, enabled, t]); - if (!pushConfigured) return null; // Non supporté : le seul cas qui mérite une explication est iOS pas encore @@ -66,9 +30,7 @@ export default function NotificationSettings() { return (

    {t.dailyReminder}

    -

    - {t.iosInstallSubtitle} -

    +

    {t.iosInstallSubtitle}

    ); } @@ -76,24 +38,15 @@ export default function NotificationSettings() { return (

    {t.dailyReminder}

    -

    - {t.reminderSubtitle} -

    - - {message &&

    {message}

    } +

    {t.reminderSubtitle}

    +
    ); } diff --git a/src/components/PushToggle.tsx b/src/components/PushToggle.tsx new file mode 100644 index 00000000..69615d4f --- /dev/null +++ b/src/components/PushToggle.tsx @@ -0,0 +1,42 @@ +// Interrupteur d'une notification push, avec son libellé et son message +// d'erreur. Partagé par le rappel quotidien et le recap hebdomadaire : mêmes +// classes, même sémantique ARIA (role="switch"), seuls les textes changent. + +interface PushToggleProps { + enabled: boolean; + busy: boolean; + message: string | null; + onToggle: () => void; + // Libellé affiché selon l'état — « Activé ✓ » vs l'invitation à activer. + onLabel: string; + offLabel: string; +} + +export default function PushToggle({ + enabled, + busy, + message, + onToggle, + onLabel, + offLabel, +}: PushToggleProps) { + return ( + <> + + {message &&

    {message}

    } + + ); +} diff --git a/src/components/WeeklyRecapSettings.tsx b/src/components/WeeklyRecapSettings.tsx new file mode 100644 index 00000000..fca063b8 --- /dev/null +++ b/src/components/WeeklyRecapSettings.tsx @@ -0,0 +1,44 @@ +// Toggle « Recap hebdomadaire » — la contrepartie parent du rappel quotidien. +// +// Vit dans la section « Suivi à distance » et non à côté du rappel quotidien, +// pour deux raisons : il n'a de sens que si cet appareil suit au moins un enfant +// (sinon il n'y a aucun recap à annoncer), et sur un appareil purement suiveur +// NotificationSettings n'est jamais rendu — il est conditionné au profil local. +// +// La notification elle-même reste GÉNÉRIQUE : le serveur ne peut pas lire le +// prénom de l'enfant, l'instantané étant chiffré de bout en bout. Elle dit +// qu'un recap est prêt et ouvre l'espace parent, qui déchiffre localement. + +import { pushConfigured, pushSupported } from '../lib/push'; +import { isIOS, isStandalone } from '../lib/install'; +import { useWeeklyRecapStrings } from '../i18n/parent'; +import { usePushPref } from '../hooks/usePushPref'; +import PushToggle from './PushToggle'; + +export default function WeeklyRecapSettings() { + const t = useWeeklyRecapStrings(); + const { enabled, busy, message, toggle } = usePushPref('weekly', t); + + if (!pushConfigured) return null; + + if (!pushSupported()) { + // Même règle que le rappel quotidien : on n'explique que le cas iOS non + // installé, seul cas réparable par l'utilisateur. + if (!(isIOS() && !isStandalone())) return null; + return

    {t.iosInstallSubtitle}

    ; + } + + return ( +
    +

    {t.subtitle}

    + +
    + ); +} diff --git a/src/hooks/usePushPref.ts b/src/hooks/usePushPref.ts new file mode 100644 index 00000000..ebfd92e6 --- /dev/null +++ b/src/hooks/usePushPref.ts @@ -0,0 +1,49 @@ +// Mécanique commune aux deux toggles de notification de l'espace parent : le +// rappel quotidien de séance (appareil de l'enfant) et le recap hebdomadaire du +// suivi à distance (appareil du parent). Même cycle — réconcilier l'état +// enregistré au montage, demander la permission à l'activation, distinguer +// « refusé » de « indisponible ». +// +// Chaque toggle ne pilote QUE son drapeau : la mise à jour est partielle côté +// SQL, donc deux hooks montés en même temps ne s'écrasent pas. + +import { useCallback, useEffect, useState } from 'react'; +import { getPushPrefs, setPushPref, type PushPrefs } from '../lib/push'; + +interface PushPrefStrings { + blocked: string; + unavailable: string; +} + +export function usePushPref(key: keyof PushPrefs, t: PushPrefStrings) { + const [enabled, setEnabled] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + getPushPrefs().then((prefs) => { + if (!cancelled) setEnabled(prefs[key]); + }); + return () => { + cancelled = true; + }; + }, [key]); + + const toggle = useCallback(async () => { + if (busy) return; + setBusy(true); + setMessage(null); + try { + const next = !enabled; + const res = await setPushPref(key, next); + if (res === 'ok') setEnabled(next); + else if (res === 'denied') setMessage(t.blocked); + else setMessage(t.unavailable); + } finally { + setBusy(false); + } + }, [busy, enabled, key, t]); + + return { enabled, busy, message, toggle }; +} diff --git a/src/i18n/changelog.ts b/src/i18n/changelog.ts index c6dea2f3..0fd17741 100644 --- a/src/i18n/changelog.ts +++ b/src/i18n/changelog.ts @@ -8,6 +8,12 @@ import type { ChangelogEntry } from '../lib/changelog'; // parent (anglais adulte clair). Consommé par lib/changelog.ts via getLang(). const fr: ChangelogEntry[] = [ + { + date: '2026-07-27', + items: [ + "Recap hebdomadaire du suivi à distance : si vous suivez la progression d'un enfant depuis votre appareil, vous pouvez recevoir une notification le dimanche soir pour penser à y jeter un œil. C'est indépendant du rappel quotidien de séance — celui-ci s'adresse à l'enfant sur son appareil, le recap à vous sur le vôtre, et vous pouvez activer l'un sans l'autre. La notification ne contient aucun chiffre (la progression reste chiffrée) : elle ouvre directement l'espace parent, où tout s'affiche.", + ], + }, { date: '2026-07-25', items: [ @@ -177,6 +183,12 @@ const fr: ChangelogEntry[] = [ ]; const en: ChangelogEntry[] = [ + { + date: '2026-07-27', + items: [ + "Weekly recap for remote follow: if you follow a child's progress from your device, you can now get a notification on Sunday evening to remember to take a look. It is independent from the daily session reminder — that one is for the child on their device, the recap is for you on yours, and you can turn on either without the other. The notification carries no figures (the progress stays encrypted): it opens the parent area directly, where everything is shown.", + ], + }, { date: '2026-07-25', items: [ diff --git a/src/i18n/parent.ts b/src/i18n/parent.ts index cf7a5b4d..5122b402 100644 --- a/src/i18n/parent.ts +++ b/src/i18n/parent.ts @@ -456,6 +456,47 @@ export function useNotificationSettingsStrings(): NotificationSettingsStrings { return useStrings(notificationSettingsStrings); } +// === WeeklyRecapSettings (recap hebdomadaire du suivi à distance) === + +interface WeeklyRecapStrings { + subtitle: string; + enabled: string; + enable: string; + iosInstallSubtitle: string; + blocked: string; + unavailable: string; +} + +const weeklyRecapFr: WeeklyRecapStrings = { + subtitle: + 'Une notification le dimanche soir pour penser à regarder la progression de la semaine. Elle ne contient aucune donnée\u00a0: elle ouvre simplement cette page, où la progression est déchiffrée sur votre appareil.', + enabled: 'Activé', + enable: 'Recevoir un recap chaque semaine', + iosInstallSubtitle: + "Pour recevoir le recap hebdomadaire, installez d'abord Tablito sur l'écran d'accueil (menu Partager de Safari → «\u00a0Sur l'écran d'accueil\u00a0»).", + blocked: + 'Notifications bloquées. Autorisez-les dans les réglages de votre navigateur, puis réessayez.', + unavailable: "Impossible d'activer le recap pour le moment. Réessayez plus tard.", +}; + +const weeklyRecapEn: WeeklyRecapStrings = { + subtitle: + 'A notification on Sunday evening, to remember to check the week\u2019s progress. It carries no data: it simply opens this page, where the progress is decrypted on your device.', + enabled: 'On', + enable: 'Get a weekly recap', + iosInstallSubtitle: + 'To get the weekly recap, first add Tablito to your home screen (Safari Share menu → "Add to Home Screen").', + blocked: + 'Notifications are blocked. Allow them in your browser settings, then try again.', + unavailable: "Can't turn on the recap right now. Please try again later.", +}; + +export const weeklyRecapStrings = { fr: weeklyRecapFr, en: weeklyRecapEn }; + +export function useWeeklyRecapStrings(): WeeklyRecapStrings { + return useStrings(weeklyRecapStrings); +} + // === FeedbackModal === interface FeedbackModalStrings { diff --git a/src/i18n/privacy.tsx b/src/i18n/privacy.tsx index 96b64e42..acd81a66 100644 --- a/src/i18n/privacy.tsx +++ b/src/i18n/privacy.tsx @@ -82,12 +82,14 @@ const fr: PrivacyStrings = { le responsable du projet. ), - reminderTitle: 'Ce qui est envoyé si vous activez le rappel quotidien', + reminderTitle: 'Ce qui est envoyé si vous activez les notifications', reminderIntro: ( <> - Le rappel quotidien (espace parent) est facultatif. - Si vous l'activez, l'appareil enregistre un abonnement push{' '} - dans la même base Supabase : + Les notifications (espace parent) sont facultatives, et + indépendantes l'une de l'autre : le rappel quotidien de + séance, et le recap hebdomadaire du suivi à distance. Si + vous en activez au moins une, l'appareil enregistre un{' '} + abonnement push dans la même base Supabase : ), reminderItems: [ @@ -98,18 +100,25 @@ const fr: PrivacyStrings = { <>le fuseau horaire de l'appareil (pour envoyer à 18 h heure locale),, <> la date de la dernière séance, uniquement pour ne pas notifier les - jours où l'enfant a déjà pratiqué (anti-nag). + jours où l'enfant a déjà pratiqué (anti-nag), + , + <> + lesquelles des deux notifications sont activées, et la date du dernier + recap envoyé (pour n'en envoyer qu'un par semaine). , ], reminderOutro: ( <> - Le prénom n'est pas envoyé et le message de rappel - est générique. Ces informations sont rattachées à l'abonnement (son + Le prénom n'est pas envoyé et les deux messages sont + génériques — le recap hebdomadaire ne contient d'ailleurs aucun chiffre, + puisque la progression suivie est chiffrée et illisible par le serveur : + il annonce qu'un recap est prêt, et c'est l'app qui l'affiche après + déchiffrement sur votre appareil. Ces informations sont rattachées à l'abonnement (son endpoint), pas à une identité. L'envoi des notifications passe par le service de notification de votre navigateur (Google, Mozilla ou Apple selon le navigateur), comme pour toute notification web. Vous pouvez - désactiver le rappel à tout moment depuis le même bouton, ce qui - supprime l'abonnement de la base. + désactiver chaque notification à tout moment depuis son bouton ; + désactiver la dernière supprime l'abonnement de la base. ), transferTitle: 'Ce qui est envoyé lors d’un transfert vers un autre appareil', @@ -218,12 +227,14 @@ const en: PrivacyStrings = { database hosted in Europe, readable only by the project owner. ), - reminderTitle: 'What is sent if you turn on the daily reminder', + reminderTitle: 'What is sent if you turn on notifications', reminderIntro: ( <> - The daily reminder (parent area) is optional. If you - turn it on, the device records a push subscription{' '} - in the same Supabase database: + Notifications (parent area) are optional, and independent + of each other: the daily session reminder, and the{' '} + weekly recap of remote follow. If you turn on at least one, + the device records a push subscription in the same Supabase + database: ), reminderItems: [ @@ -234,18 +245,24 @@ const en: PrivacyStrings = { <>the device's time zone (to send at 6 pm local time),, <> the date of the last session, only so as not to notify on days when - the child has already practiced (anti-nag). + the child has already practiced (anti-nag), + , + <> + which of the two notifications are on, and the date of the last recap sent + (so that only one goes out per week). , ], reminderOutro: ( <> - The first name is not sent and the reminder message - is generic. This information is tied to the subscription (its - endpoint), not to an identity. Notifications are delivered through your + The first name is not sent and both messages are generic — + the weekly recap carries no figures at all, since the followed progress is + encrypted and unreadable by the server: it announces that a recap is ready, + and the app displays it after decrypting on your device. This information is + tied to the subscription (its endpoint), not to an identity. Notifications are delivered through your browser's notification service (Google, Mozilla or Apple depending on - the browser), as with any web notification. You can turn off the - reminder at any time from the same button, which removes the - subscription from the database. + the browser), as with any web notification. You can turn off each + notification at any time from its own button; turning off the last one + removes the subscription from the database. ), transferTitle: 'What is sent when transferring to another device', diff --git a/src/lib/push.ts b/src/lib/push.ts index 6f94ed1d..6515abfc 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -1,11 +1,19 @@ -// Web Push — rappel quotidien (cf. plan « rappel quotidien à heure fixe »). +// Web Push — deux notifications distinctes portées par la MÊME subscription : +// - le RAPPEL QUOTIDIEN de séance, sur l'appareil de l'enfant ; +// - le RECAP HEBDOMADAIRE du suivi à distance, sur l'appareil du parent, qui +// l'avertit que la progression de l'enfant qu'il suit a bougé. +// Vouloir l'un sans l'autre est le cas normal (le parent n'a pas de séance à +// faire), d'où deux préférences indépendantes plutôt qu'un unique « abonné ». // -// Côté client, on ne fait que (dé)s'abonner et tenir à jour deux dates dans la -// table Supabase `push_subscriptions`. L'envoi réel est fait par un cron -// GitHub Actions (scripts/send-reminders.mjs), seul détenteur de la clé VAPID -// privée. La table n'est jamais lue côté client (pas de policy SELECT anon) : -// l'endpoint d'une subscription est une URL opaque non devinable, qui sert de -// clé pour update/delete sa propre ligne. +// Côté client, on tient à jour SA ligne de la table Supabase +// `push_subscriptions` : les deux préférences et les dates de dédoublonnage. +// L'envoi réel est fait par un cron GitHub Actions +// (scripts/send-reminders.mjs), seul détenteur de la clé VAPID privée. +// +// La table n'a aucune policy (ni SELECT ni écriture) : l'endpoint d'une +// subscription est une URL opaque non devinable, qui sert de clé ET +// d'autorisation, via des fonctions SECURITY DEFINER. Un client peut donc lire +// et écrire SA ligne sans que la table soit énumérable. // // Même conventions réseau que src/lib/feedback.ts (PostgREST + publishable key). @@ -56,35 +64,108 @@ async function activeSubscription(): Promise { return (await reg?.pushManager.getSubscription()) ?? null; } +export type PushPrefResult = 'ok' | 'denied' | 'unsupported' | 'error'; + +/** Les deux notifications, indépendantes. Tout à false = aucun abonnement. */ +export interface PushPrefs { + daily: boolean; + weekly: boolean; +} + +const NO_PUSH: PushPrefs = { daily: false, weekly: false }; + +// Miroir local du dernier état connu. L'affichage des toggles ne doit pas +// dépendre du réseau : une PWA s'ouvre souvent hors-ligne, et afficher OFF +// pendant que les notifications arrivent bel et bien est un mensonge — pire, il +// rend le toggle inopérant pour DÉSACTIVER (il ne proposerait qu'« activer »). +const MIRROR_KEY = 'multiplix-push-prefs'; + +function readMirror(): PushPrefs | null { + try { + const raw = localStorage.getItem(MIRROR_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as PushPrefs; + return { daily: !!parsed?.daily, weekly: !!parsed?.weekly }; + } catch { + return null; + } +} + +function writeMirror(prefs: PushPrefs | null): void { + try { + if (prefs) localStorage.setItem(MIRROR_KEY, JSON.stringify(prefs)); + else localStorage.removeItem(MIRROR_KEY); + } catch { + // ignore (navigation privée stricte) + } +} + /** - * État courant de l'abonnement, pour réconcilier le toggle à l'ouverture de - * l'espace parent (gère la permission révoquée hors de l'app). + * Préférences de cet appareil. La permission révoquée hors de l'app fait foi + * (aucune notification n'arrivera), donc elle l'emporte. Sinon on interroge le + * serveur, et on retombe sur le dernier état connu localement si la lecture + * échoue — surtout ne pas répondre « rien d'activé » pour cause de hors-ligne. */ -export async function isSubscribed(): Promise { - if (!pushConfigured || !pushSupported() || Notification.permission !== 'granted') return false; - return (await activeSubscription()) !== null; +export async function getPushPrefs(): Promise { + if (!pushConfigured || !pushSupported() || Notification.permission !== 'granted') return NO_PUSH; + try { + const sub = await activeSubscription(); + if (!sub) return NO_PUSH; // pas de canal : rien ne peut arriver, c'est certain + // La table n'a aucune policy SELECT (anti-énumération) : on passe donc par un + // RPC SECURITY DEFINER, à qui l'endpoint opaque sert d'autorisation. + const res = await fetch(`${url}/rest/v1/rpc/read_push_prefs`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ p_endpoint: serialize(sub).endpoint }), + }); + if (!res.ok) return readMirror() ?? NO_PUSH; + const row = (await res.json()) as PushPrefs | null; + const prefs = row ? { daily: !!row.daily, weekly: !!row.weekly } : NO_PUSH; + writeMirror(prefs); + return prefs; + } catch { + return readMirror() ?? NO_PUSH; + } } -export type SubscribeResult = 'subscribed' | 'denied' | 'unsupported' | 'error'; - /** - * Active le rappel quotidien : demande la permission, crée la subscription - * push, et upsert la ligne Supabase (clé = endpoint). + * Active ou désactive UNE des deux notifications, sans toucher à l'autre : la + * mise à jour est partielle côté SQL (paramètre NULL = drapeau inchangé), parce + * que les deux toggles vivent dans deux endroits distincts de l'espace parent et + * qu'un read-modify-write côté client les ferait s'écraser mutuellement. + * + * Désactiver la dernière notification active supprime tout l'abonnement : garder + * une subscription que le cron ignore n'aurait aucun sens. C'est le RPC qui + * renvoie l'état résultant, donc cette décision découle d'une valeur lue et + * jamais d'une supposition. */ -export async function subscribeToReminders(): Promise { +export async function setPushPref( + key: keyof PushPrefs, + value: boolean, +): Promise { if (!pushConfigured || !pushSupported()) return 'unsupported'; - const permission = await Notification.requestPermission(); - if (permission !== 'granted') return 'denied'; + let sub = await activeSubscription(); + const created = sub === null; + if (!value && !sub) return 'ok'; // rien à désactiver + + // La permission se demande dès qu'elle n'est pas accordée, et pas seulement + // quand la subscription manque : un utilisateur peut l'avoir révoquée dans les + // réglages du site sans que le navigateur ne détruise la PushSubscription — on + // afficherait alors « Activé » pour des notifications qui n'arriveront jamais. + if (Notification.permission !== 'granted') { + if ((await Notification.requestPermission()) !== 'granted') return 'denied'; + } try { - const reg = await navigator.serviceWorker.ready; - let sub = await reg.pushManager.getSubscription(); if (!sub) { - sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }); + const reg = await navigator.serviceWorker.ready; + sub = + (await reg.pushManager.getSubscription()) ?? + (await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + })); } const { endpoint, p256dh, auth } = serialize(sub); @@ -94,10 +175,9 @@ export async function subscribeToReminders(): Promise { // sous RLS, un ON CONFLICT DO UPDATE (comme un PATCH filtré par endpoint) // doit lire la ligne ciblée, donc les policies SELECT s'appliquent — or on en // refuse une exprès (l'endpoint opaque sert de secret, personne ne doit pouvoir - // énumérer les abonnés). La fonction bypasse RLS et ne retourne rien : on peut - // upsert SA ligne sans rendre la table lisible. Le ON CONFLICT préserve + // énumérer les abonnés). La fonction bypasse RLS. Le ON CONFLICT préserve // last_session_date / last_notified_date (la fonction ne les touche pas). - const res = await fetch(`${url}/rest/v1/rpc/upsert_push_subscription`, { + const res = await fetch(`${url}/rest/v1/rpc/upsert_push_prefs`, { method: 'POST', headers: baseHeaders, body: JSON.stringify({ @@ -105,15 +185,27 @@ export async function subscribeToReminders(): Promise { p_p256dh: p256dh, p_auth: auth, p_timezone: timezone, + p_daily: key === 'daily' ? value : null, + p_weekly: key === 'weekly' ? value : null, }), }); if (!res.ok) { - // L'enregistrement serveur a échoué : retirer la subscription locale pour - // ne pas laisser un abonnement que le cron ignore. - await sub.unsubscribe().catch(() => {}); + // Ne retirer la subscription que si on vient de la CRÉER : sinon on + // détruirait un abonnement préexistant (et l'autre notification avec) à + // cause d'un échec sur celle-ci. + if (created) await sub.unsubscribe().catch(() => {}); return 'error'; } - return 'subscribed'; + + const prefs = (await res.json()) as PushPrefs; + // Plus rien d'actif : on retire l'abonnement au lieu de garder une ligne + // muette (et la permission cesse d'être « utilisée » côté navigateur). + if (!prefs.daily && !prefs.weekly) { + await unsubscribeFromReminders(); + return 'ok'; + } + writeMirror(prefs); + return 'ok'; } catch { return 'error'; } @@ -133,6 +225,7 @@ export async function unsubscribeFromReminders(): Promise { // best-effort : le cron purgera de toute façon les endpoints morts (410). } await sub.unsubscribe().catch(() => {}); + writeMirror(null); } /** diff --git a/src/lib/watchStore.ts b/src/lib/watchStore.ts index eac99f43..cf018091 100644 --- a/src/lib/watchStore.ts +++ b/src/lib/watchStore.ts @@ -54,6 +54,14 @@ export function watchLink({ code, key }: WatchCredentials): string { return `${window.location.origin}${import.meta.env.BASE_URL}#watch=${code}.${key}`; } +/** + * Fragment déposé par la notification de recap hebdomadaire (cf. + * scripts/send-reminders.mjs), qui demande l'ouverture de l'espace parent. + * ⚠ Aussi testé en dur par l'inline script d'index.html (pré-paint) pour sauter + * la landing — garder les deux en phase. + */ +export const RECAP_HASH = '#recap'; + // ⚠ Le nom du fragment est aussi testé en dur par l'inline script d'index.html // (pré-paint, avant tout module) pour sauter la landing statique — si `watch=` // change ici, mettre à jour la regex des fragments de boot là-bas. diff --git a/src/main.tsx b/src/main.tsx index 98fc88fc..82a99369 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,8 @@ import { registerSW } from 'virtual:pwa-register' import App from './App.tsx' import ErrorBoundary from './components/ErrorBoundary' import { LangProvider } from './i18n/LangProvider' -import { importProfileFromUrl } from './lib/storage' +import { clearUrlHash, importProfileFromUrl } from './lib/storage' +import { RECAP_HASH } from './lib/watchStore' import { transferFetchingStrings, watchFetchingStrings } from './i18n/onboarding' import type { TransferImportResult } from './lib/transfer' import type { WatchPairing } from './lib/watch' @@ -40,6 +41,10 @@ async function boot() { let transferResult: TransferImportResult = null let watchPairing: WatchPairing | 'error' | null = null const hash = window.location.hash + // Clic sur la notification de recap : App ouvrira l'espace parent. Consommé + // ici comme les autres fragments, pour qu'un rechargement ne le rejoue pas. + const recapRequested = hash === RECAP_HASH + if (recapRequested) clearUrlHash() if (hash.includes('transfer=')) { showWaiting(root, transferFetchingStrings) transferResult = await (await import('./lib/transfer')).importTransferFromUrl() @@ -57,7 +62,11 @@ async function boot() { - + , diff --git a/src/screens/ParentDashboard.tsx b/src/screens/ParentDashboard.tsx index 3b403e8d..d3111806 100644 --- a/src/screens/ParentDashboard.tsx +++ b/src/screens/ParentDashboard.tsx @@ -3,12 +3,14 @@ import type { UserProfile } from '../types'; import BackChevron from '../components/BackChevron'; import FeedbackModal from '../components/FeedbackModal'; import NotificationSettings from '../components/NotificationSettings'; +import WeeklyRecapSettings from '../components/WeeklyRecapSettings'; import LanguageToggle from '../components/LanguageToggle'; import ParentStats from '../components/ParentStats'; import QrCanvas from '../components/QrCanvas'; import { useGuideBase } from '../i18n/lang'; import { useParentDashboardStrings } from '../i18n/parent'; import { getActiveProfileId } from '../lib/storage'; +import { setPushPref } from '../lib/push'; import { createTransfer, transferConfigured, TRANSFER_TTL_MINUTES } from '../lib/transfer'; import { addWatched, @@ -281,6 +283,11 @@ export default function ParentDashboard({ const handleStopWatching = (code: string) => { const list = removeWatched(code); setWatched(list); + // Plus aucun enfant suivi : le recap hebdomadaire n'a plus rien à annoncer, + // et son toggle disparaît avec la liste — le laisser actif condamnerait le + // parent à une notification hebdomadaire qu'il ne pourrait plus éteindre + // depuis l'app. + if (list.length === 0) void setPushPref('weekly', false); // Le suivi affiché disparaît : on retombe sur le profil local, ou à défaut // sur un autre enfant suivi. if (selectedCode === code) setSelectedCode(profile ? null : list[0]?.code ?? null); @@ -522,20 +529,25 @@ export default function ParentDashboard({ )} + {/* Recap hebdomadaire et liste des suivis : n'ont de sens que si cet + appareil suit quelqu'un. */} {watched.length > 0 && ( -
    - {watched.map((w) => ( -
    - {w.name} - -
    - ))} -
    + <> + +
    + {watched.map((w) => ( +
    + {w.name} + +
    + ))} +
    + )} )} diff --git a/supabase/push_subscriptions.sql b/supabase/push_subscriptions.sql index aec5c61d..4dbc1b88 100644 --- a/supabase/push_subscriptions.sql +++ b/supabase/push_subscriptions.sql @@ -1,9 +1,18 @@ --- Schéma de référence de la table `push_subscriptions` (rappel quotidien Web Push). +-- Schéma de référence de la table `push_subscriptions` (Web Push : rappel +-- quotidien de séance ET recap hebdomadaire du suivi à distance). -- -- ⚠ Ce projet n'a PAS de système de migrations : la DDL est appliquée à la main -- sur l'instance Supabase (comme la table `feedback`). Ce fichier documente -- l'état canonique pour la reproductibilité — il n'est exécuté par aucun build. -- +-- ⚠ ORDRE DE DÉPLOIEMENT : appliquer ce fichier AVANT de déployer le client qui +-- l'utilise. Le sens inverse casse l'existant — un nouveau client face à une base +-- sans `upsert_push_prefs` / `read_push_prefs` reçoit des 404, affiche donc ses +-- deux toggles à OFF alors que le rappel quotidien tourne, et la moindre action +-- de l'utilisateur pour « le remettre » échoue. Dans l'autre sens (DDL d'abord), +-- les clients encore en cache continuent d'appeler upsert_push_subscription, +-- conservée intacte plus bas, et rien ne change pour eux. +-- -- Application manuelle : -- psql "$SUPABASE_DB_URL" -f supabase/push_subscriptions.sql -- (idempotent : create table if not exists + drop policy if exists) @@ -20,9 +29,9 @@ -- DELETE / ON CONFLICT) doit lire la ligne ciblée, donc une policy SELECT serait -- requise — qu'on refuse ici (anti-énumération). Sans ligne « visible », -- l'écriture matche 0 ligne *en renvoyant 204* : échec silencieux. Le client --- passe donc par deux fonctions SECURITY DEFINER (upsert_push_subscription pour --- (dé)s'abonner, mark_reminder_session pour l'anti-nag) qui bypassent RLS et ne --- retournent rien — cf. src/lib/push.ts. Seul le DELETE de désabonnement reste +-- passe donc par des fonctions SECURITY DEFINER (upsert_push_prefs pour les +-- préférences, read_push_prefs pour les relire, mark_reminder_session pour +-- l'anti-nag) qui bypassent RLS — cf. src/lib/push.ts. Seul le DELETE de désabonnement reste -- best-effort en direct : il échoue en silence mais le cron purge l'endpoint au -- premier 410, et la subscription locale est retirée côté navigateur. @@ -34,10 +43,27 @@ create table if not exists public.push_subscriptions ( timezone text not null, -- IANA, ex 'Europe/Paris' (fenêtre 18h locale) last_session_date text, -- 'YYYY-MM-DD' local — anti-nag last_notified_date text, -- 'YYYY-MM-DD' local — dédoublonnage (1 envoi/jour) + -- Deux abonnements distincts portés par la MÊME subscription push, parce + -- qu'ils ne s'adressent pas à la même personne : le rappel quotidien pousse + -- l'enfant à faire sa séance (sur SON appareil), le recap hebdomadaire prévient + -- le parent que la progression de l'enfant qu'il suit à distance a bougé (sur + -- l'appareil DU PARENT). Vouloir l'un sans l'autre est le cas normal. + -- `daily_reminder` vaut true par défaut : les abonnés d'avant cette colonne + -- s'étaient inscrits précisément pour ça, leur comportement ne change pas. + daily_reminder boolean not null default true, + weekly_recap boolean not null default false, + last_weekly_date text, -- 'YYYY-MM-DD' local — dernier recap envoyé created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); +-- Ajout des colonnes sur une instance qui a déjà la table (ce projet n'a pas de +-- migrations : ce fichier doit rester rejouable tel quel). +alter table public.push_subscriptions + add column if not exists daily_reminder boolean not null default true, + add column if not exists weekly_recap boolean not null default false, + add column if not exists last_weekly_date text; + alter table public.push_subscriptions enable row level security; -- Rôles : la clé publishable de ce projet agit comme `authenticated` ; on cible @@ -59,9 +85,13 @@ create policy "client delete" on public.push_subscriptions -- la note d'en-tête : un INSERT/PATCH/upsert direct filtré par endpoint matcherait -- 0 ligne sous RLS sans policy SELECT, qu'on refuse volontairement. +-- LEGACY — conservée uniquement pour les clients déjà déployés (cf. la note sur +-- upsert_push_prefs plus bas). Ne rien y ajouter : le code actuel appelle +-- upsert_push_prefs. Supprimable quand plus aucun client ne l'appellera. -- (Dé)s'abonner : upsert de la ligne par endpoint. Le ON CONFLICT préserve --- last_session_date / last_notified_date (non touchés ici). Appelée par --- src/lib/push.ts subscribeToReminders via POST /rest/v1/rpc/upsert_push_subscription. +-- last_session_date / last_notified_date (non touchés ici). Ne mentionnant pas +-- daily_reminder, l'insertion prend le défaut de colonne (true) : c'est +-- exactement l'ancienne sémantique, « être abonné = vouloir le rappel ». create or replace function public.upsert_push_subscription( p_endpoint text, p_p256dh text, p_auth text, p_timezone text ) returns void @@ -81,6 +111,75 @@ $$; revoke all on function public.upsert_push_subscription(text, text, text, text) from public; grant execute on function public.upsert_push_subscription(text, text, text, text) to anon, authenticated; +-- ⚠ NOM DISTINCT, et non une signature élargie de upsert_push_subscription. +-- Ajouter des paramètres à celle-ci en aurait fait une SURCHARGE : les appels à +-- 4 arguments des clients encore en cache (le SW peut mettre un moment à se +-- mettre à jour) seraient devenus ambigus — « function is not unique ». Et la +-- supprimer aurait été pire : un client périmé aurait alors créé sa ligne via la +-- nouvelle fonction avec p_daily = NULL, donc un rappel quotidien à false, sans +-- que personne ne s'en aperçoive. L'ancienne fonction reste donc en place, avec +-- sa sémantique intacte, jusqu'à ce que plus aucun client ne l'appelle. +-- +-- Mise à jour PARTIELLE des préférences : un paramètre NULL laisse le drapeau +-- inchangé. Indispensable parce que les deux toggles vivent dans deux endroits +-- distincts de l'espace parent — avec un upsert « total », activer le recap +-- hebdomadaire écraserait le rappel quotidien avec une valeur lue plus tôt. +-- À l'insertion, un NULL vaut false : un parent qui n'active que le recap ne doit +-- pas hériter du rappel de séance (il n'a pas de séance à faire). +-- RENVOIE l'état résultant, et pas void : le client doit savoir si l'autre +-- préférence est encore active pour décider de supprimer ou non l'abonnement. +-- Le lui faire relire par un second appel serait bancal — une lecture qui échoue +-- (hors-ligne) ne se distingue pas d'un « plus rien d'actif », et on +-- supprimerait alors un abonnement bien vivant. Ici la décision découle d'une +-- valeur lue dans la même transaction que l'écriture. +-- (drop préalable : create or replace ne peut pas changer un type de retour. La +-- fonction est neuve, rien de déployé n'en dépend.) +drop function if exists public.upsert_push_prefs(text, text, text, text, boolean, boolean); +create function public.upsert_push_prefs( + p_endpoint text, p_p256dh text, p_auth text, p_timezone text, + p_daily boolean default null, p_weekly boolean default null +) returns jsonb +language sql +security definer +set search_path = public +as $$ + insert into public.push_subscriptions + (endpoint, p256dh, auth, timezone, daily_reminder, weekly_recap, updated_at) + values (p_endpoint, p_p256dh, p_auth, p_timezone, + coalesce(p_daily, false), coalesce(p_weekly, false), now()) + on conflict (endpoint) do update + set p256dh = excluded.p256dh, + auth = excluded.auth, + timezone = excluded.timezone, + daily_reminder = coalesce(p_daily, public.push_subscriptions.daily_reminder), + weekly_recap = coalesce(p_weekly, public.push_subscriptions.weekly_recap), + updated_at = now() + returning jsonb_build_object('daily', daily_reminder, 'weekly', weekly_recap); +$$; + +revoke all on function public.upsert_push_prefs(text, text, text, text, boolean, boolean) from public; +grant execute on function public.upsert_push_prefs(text, text, text, text, boolean, boolean) to anon, authenticated; + +-- Lecture des préférences de SA ligne. Nécessaire parce que la table n'a aucune +-- policy SELECT (anti-énumération) : sans ce RPC, l'espace parent ne pourrait pas +-- réconcilier ses deux toggles à l'ouverture et afficherait un état inventé. +-- L'endpoint push est une URL opaque non devinable : le connaître EST +-- l'autorisation, exactement comme pour les écritures ci-dessus. Ne renvoie que +-- les deux booléens — ni clés, ni dates, ni existence d'autres lignes. +create or replace function public.read_push_prefs(p_endpoint text) +returns jsonb +language sql +security definer +set search_path = public +as $$ + select jsonb_build_object('daily', daily_reminder, 'weekly', weekly_recap) + from public.push_subscriptions + where endpoint = p_endpoint; +$$; + +revoke all on function public.read_push_prefs(text) from public; +grant execute on function public.read_push_prefs(text) to anon, authenticated; + -- Anti-nag : marquage de la séance du jour, pour que le cron saute l'envoi un -- jour de séance. Appelée par src/lib/push.ts syncLastSession via -- POST /rest/v1/rpc/mark_reminder_session. From 3cf7eb0fec19b126c5d61ecad9040e2d8891fd36 Mon Sep 17 00:00:00 2001 From: Ivan Schneider Date: Mon, 3 Aug 2026 15:11:40 +0200 Subject: [PATCH 2/2] fix(recap): ouvrir sur l'enfant suivi, pas sur le profil local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La notification annonce la progression de l'enfant, mais l'espace parent s'ouvrait sur la source par défaut — le profil local dès qu'il en existe un. Sur le téléphone du parent, taper « le recap de Zoé est prêt » affichait donc SES propres stats, et il fallait encore taper l'onglet de Zoé. L'arrivée par la notification force la première source suivie. --- src/App.tsx | 1 + src/__tests__/remoteFollow.test.tsx | 7 +++++++ src/screens/ParentDashboard.tsx | 13 +++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d27e4274..bb4af678 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -864,6 +864,7 @@ export default function App({ setScreen('home') : undefined} onExport={handleExport} diff --git a/src/__tests__/remoteFollow.test.tsx b/src/__tests__/remoteFollow.test.tsx index a4128786..19d7f9cf 100644 --- a/src/__tests__/remoteFollow.test.tsx +++ b/src/__tests__/remoteFollow.test.tsx @@ -220,6 +220,13 @@ describe('lien profond #recap (clic sur la notification hebdomadaire)', () => { // avec celle des autres fragments.) await renderApp({ recapRequested: true }); expect(document.querySelector('.parent-dashboard')).not.toBeNull(); + + // …et sur l'enfant SUIVI, pas sur le profil local : la notification parle de + // la progression de l'enfant, l'ouvrir sur « Papa » obligerait à taper + // l'onglet à chaque fois. + expect(document.querySelector('.parent-title')?.textContent).toContain('Zoé'); + const active = document.querySelector('.parent-op-tabs .progress-tab.active'); + expect((active?.textContent ?? '')).toContain('Zoé'); }); }); diff --git a/src/screens/ParentDashboard.tsx b/src/screens/ParentDashboard.tsx index d3111806..e4f163e7 100644 --- a/src/screens/ParentDashboard.tsx +++ b/src/screens/ParentDashboard.tsx @@ -51,6 +51,9 @@ interface ParentDashboardProps { // Suivi appairé au boot depuis un `#watch=` : déjà déchiffré par main.tsx, on // l'affiche sans second aller-retour réseau. initialWatch?: WatchPairing | null; + // Vrai quand l'espace parent est ouvert par la notification de recap + // hebdomadaire : change la source affichée par défaut. + openOnWatched?: boolean; } // État de la relecture du suivi sélectionné ('loading' + les trois issues de @@ -74,6 +77,7 @@ export default function ParentDashboard({ onShowPrivacy, onShowChangelog, initialWatch = null, + openOnWatched = false, }: ParentDashboardProps) { const t = useParentDashboardStrings(); const guideBase = useGuideBase(); @@ -101,8 +105,13 @@ export default function ParentDashboard({ // Appairage au boot : on ouvre directement sur l'enfant qu'on vient de // scanner, c'est la raison même de l'ouverture de l'app. if (initialWatch) return initialWatch.entry.code; - if (profile) return null; - return listWatched()[0]?.code ?? null; + const firstWatched = listWatched()[0]?.code ?? null; + // Arrivée par la notification de recap : c'est la progression SUIVIE que le + // parent vient consulter, pas la sienne — même s'il a un profil local ici, + // auquel cas la source par défaut serait ce profil et il faudrait encore + // taper l'onglet de l'enfant. + if (openOnWatched && firstWatched) return firstWatched; + return profile ? null : firstWatched; }); // L'instantané distant, ÉTIQUETÉ du code auquel il appartient : c'est ce qui