Push reminders: personal to the notes, generic without them

The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:

- Personal: "3 cards are due in Sky deck", in the app's language, tapping
  through to /review. Without notes (or with nothing due) it is the plain
  nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
  a path on this app, so a stolen subscription cannot make the notification
  link elsewhere. The store keeps endpoint, keys, the message and createdAt
  (a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
  opens and whenever the tab becomes visible (throttled to twice a day), so a
  count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
  PUSH_REMINDER_TITLE/_BODY for a subscription that has none
  (scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
  timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
  server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
  text in en and cs, the generic fallback in both, the URL guard, and the
  sender's preference order (81 checks).

Version 0.8.2, SW v104.
This commit is contained in:
Martin Dvořák 2026-10-18 14:34:15 +00:00
commit 77819b5961
18 changed files with 498 additions and 49 deletions

View file

@ -47,7 +47,7 @@ const PRIVACY_EN: LegalDocument = {
paragraphs: ["There is no other server-side data. Each of these is off until you turn it on:"],
list: [
"Sync — an encrypted blob the server cannot read (the key stays in your browser). Revocable from Settings → Delete synced data.",
"Reminders — the push endpoint your browser gave us, its keys and a timestamp. No personal data, no history.",
"Reminders — the push endpoint your browser gave us, its keys, a timestamp, and the reminder text your browser wrote for the notification (it may name one of your decks). The server sends that text back to you and stores nothing else.",
"Reported questions — the question text, the deck and activity ids and a timestamp. These are how bad AI cards get fixed; they are the one thing an operator can read.",
],
},
@ -60,7 +60,7 @@ const PRIVACY_EN: LegalDocument = {
{
heading: "Third parties",
paragraphs: [
"Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter or your own endpoint) with the key from your browser. The provider's own privacy terms apply to those calls. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.",
"Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter or your own endpoint) with the key from your browser. The provider's own privacy terms apply to those calls. The reminder text — which may name one of your decks — is stored with the subscription so it can arrive while the app is closed; reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.",
],
},
{
@ -97,7 +97,7 @@ const PRIVACY_CS: LegalDocument = {
paragraphs: ["Jiná data na serveru nejsou. Každá z těchhle věcí je vypnutá, dokud ji nezapneš:"],
list: [
"Synchronizace — zašifrovaný blob, který server nedokáže přečíst (klíč zůstává v prohlížeči). Zrušitelný v Nastavení → Smazat synchronizovaná data.",
"Připomínky — push endpoint, který ti dal prohlížeč, jeho klíče a časové razítko. Žádná osobní data, žádná historie.",
"Připomínky — push endpoint, který ti dal prohlížeč, jeho klíče, časové razítko a text připomínky, který pro oznámení napsal tvůj prohlížeč (může obsahovat název tvé sady). Server ten text posílá zpět tobě a nic dalšího neukládá.",
"Nahlášené otázky — text otázky, ID sady a aktivity a časové razítko. Díky nim se opravují špatné AI kartičky; je to jediná věc, kterou provozovatel může číst.",
],
},
@ -110,7 +110,7 @@ const PRIVACY_CS: LegalDocument = {
{
heading: "Třetí strany",
paragraphs: [
"Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter nebo vlastní endpoint) s klíčem z tvého prohlížeče; na tato volání platí podmínky daného poskytovatele. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.",
"Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, OpenRouter nebo vlastní endpoint) s klíčem z tvého prohlížeče; na tato volání platí podmínky daného poskytovatele. Text připomínky — může obsahovat název tvé sady — se ukládá k přihlášce, aby mohl dorazit i se zavřenou aplikací; připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.",
],
},
{