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
7 changes: 4 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
7 changes: 6 additions & 1 deletion public/specs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,12 @@ <h3 id="suivi-parcours"><span class="num">14.1</span>Parcours</h3>
<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>
<h3 id="suivi-recap"><span class="num">14.2</span>Recap hebdomadaire</h3>
<p>Un miroir consultable ne sert que si le parent pense à le regarder. Un toggle facultatif, dans la même section, envoie une notification <strong>le dimanche soir</strong> sur l'appareil du parent — au plus une par semaine. Il n'apparaît que si l'appareil suit au moins un enfant&nbsp;: sans suivi, il n'y a aucun recap à annoncer.</p>
<p>Cette notification est <strong>indépendante du rappel quotidien</strong> (§8.1), et c'est le point important&nbsp;: les deux ne s'adressent pas à la même personne. Le rappel pousse l'enfant à faire sa séance, sur son appareil&nbsp;; 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.</p>
<p>Le message est <strong>générique et sans aucun chiffre</strong>&nbsp;: 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.</p>

<h3 id="suivi-modele"><span class="num">14.3</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>
Expand Down
126 changes: 102 additions & 24 deletions scripts/send-reminders.mjs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 96 additions & 3 deletions scripts/send-reminders.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
});
});
Loading
Loading