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

@ -886,8 +886,7 @@ const en: Record<string, string> = {
"server.pushNotSubscribed": "This browser is not subscribed to reminders.",
"server.pushGone": "That subscription is no longer valid — reminders were turned off for it.",
"server.pushSendFailed": "The reminder could not be sent.",
"server.pushReminderTitle": "Time to review",
"server.pushReminderBody": "Your decks are waiting — a few due cards keep the streak alive.",
"server.pushTestPrefix": "Test:",
/* folders / files -------------------------------------------------- */
"folder.nameRequired": "Give the folder a name.",
@ -974,7 +973,12 @@ const en: Record<string, string> = {
"settings.dailyGoalNote": "Answers per day that count as done.",
"settings.reminders": "Daily reminder",
"settings.remindersNote":
"A gentle nudge when your review is due. Needs the installed app — on iPhone that is required.",
"A gentle nudge when your review is due, naming the deck that is waiting. Needs the installed app — on iPhone that is required.",
"push.title": "Time to review",
"push.due.one": "{n} card is due in {deck}",
"push.due.few": "{n} cards are due in {deck}",
"push.due.many": "{n} cards are due in {deck}",
"push.generic": "Your decks are waiting — a few due cards keep the streak alive.",
"settings.reminderEnable": "Turn on reminders",
"settings.reminderDisable": "Turn off",
"settings.reminderTest": "Send a test",
@ -2043,8 +2047,7 @@ const cs: Record<string, string> = {
"server.pushNotSubscribed": "Tento prohlížeč není k připomínkám přihlášený.",
"server.pushGone": "Ta přihláška už není platná — připomínky pro ni byly vypnuté.",
"server.pushSendFailed": "Připomínku se nepodařilo poslat.",
"server.pushReminderTitle": "Čas na opakování",
"server.pushReminderBody": "Sady na tebe čekají — pár kartiček udrží sérii.",
"server.pushTestPrefix": "Test:",
/* folders / files -------------------------------------------------- */
"folder.nameRequired": "Dej složce název.",
@ -2130,8 +2133,13 @@ const cs: Record<string, string> = {
"settings.dailyGoal": "Denní cíl",
"settings.dailyGoalNote": "Kolik odpovědí za den se počítá jako splněno.",
"settings.reminders": "Denní připomínka",
"push.title": "Čas na opakování",
"push.due.one": "{n} kartička čeká v sadě {deck}",
"push.due.few": "{n} kartičky čekají v sadě {deck}",
"push.due.many": "{n} kartiček čeká v sadě {deck}",
"push.generic": "Sady na tebe čekají — pár kartiček udrží sérii.",
"settings.remindersNote":
"Jemné připomenutí, když je co opakovat. Potřebuje nainstalovanou aplikaci — na iPhonu je to podmínka.",
"Jemné připomenutí, když je čas na opakování — i s názvem sady, která čeká. Potřebuje nainstalovanou aplikaci (na iPhonu povinně).",
"settings.reminderEnable": "Zapnout připomínky",
"settings.reminderDisable": "Vypnout",
"settings.reminderTest": "Poslat zkušební",