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í",

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.",
],
},
{

View file

@ -2,14 +2,22 @@ import { promises as fs } from "node:fs";
import path from "node:path";
/**
* Server-side push subscriptions: one JSON file with endpoint + keys and a
* creation timestamp. No personal data, no history, no user ids — and the
* whole feature stays hidden unless VAPID keys are configured.
* Server-side push subscriptions: one JSON file with endpoint + keys, the
* reminder text this browser asked for, and a creation timestamp. No user ids,
* no history, no note copy — the reminder text is what the user's own browser
* composed for the notification (it may name one of their decks), and it is
* sent back to them unchanged. The whole feature stays hidden unless VAPID
* keys are configured.
*/
/** What the notification will say; composed in the browser (lib/reminder.ts). */
export type PushReminderMessage = { title: string; body: string; url?: string };
export type PushSubscriptionRecord = {
endpoint: string;
keys: { p256dh: string; auth: string };
/** Absent for a subscription stored before reminders carried a message. */
message?: PushReminderMessage;
createdAt: number;
};
@ -33,7 +41,6 @@ export async function readSubscriptions(dir: string): Promise<PushSubscriptionRe
return [];
}
}
async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> {
await fs.mkdir(dir, { recursive: true });
const file = fileFor(dir);
@ -45,17 +52,25 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]):
/** Adds or refreshes a subscription (deduped by endpoint). */
export async function addSubscription(
dir: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
subscription: { endpoint: string; keys: { p256dh: string; auth: string }; message?: PushReminderMessage },
/** Cap on stored subscriptions — the configured, sane one. */
max = 50,
): Promise<{ count: number; accepted: boolean }> {
const current = await readSubscriptions(dir);
const exists = current.some((entry) => entry.endpoint === subscription.endpoint);
const previous = current.find((entry) => entry.endpoint === subscription.endpoint);
const next = current.filter((entry) => entry.endpoint !== subscription.endpoint);
// Refreshing a device that is already subscribed always works; a brand new
// device over the cap is refused.
if (!exists && next.length >= max) return { count: current.length, accepted: false };
next.push({ ...subscription, createdAt: Date.now() });
if (previous === undefined && next.length >= max) return { count: current.length, accepted: false };
/* A refresh may carry a newer message; without one the device keeps the
reminder it already had, and it keeps its original timestamp. */
const message = subscription.message ?? previous?.message;
next.push({
endpoint: subscription.endpoint,
keys: subscription.keys,
...(message ? { message } : {}),
createdAt: previous?.createdAt ?? Date.now(),
});
const capped = next.slice(-max);
await writeSubscriptions(dir, capped);
return { count: capped.length, accepted: true };

60
lib/reminder.ts Normal file
View file

@ -0,0 +1,60 @@
"use client";
/**
* The daily reminder's text, composed **in the browser**.
*
* The server never sees the notes — it only knows which push endpoints exist —
* so the personal part of the nudge has to be written here and stored with the
* subscription (see lib/push-store.ts). Personal means: how many of the user's
* cards are due and which deck they belong to; the notification then opens the
* review screen. When there is nothing to name — no decks yet, or nothing due —
* the message is the plain nudge in the user's own language (English or Czech,
* like every other string in the app).
*
* Always resolves; a failure to read the local stores only costs the personal
* words, never the reminder.
*/
import type { SrsRecord } from "./history";
import { ensureLearningHydrated, learningStore } from "./history";
import { plural, t } from "./i18n";
import { dueItems } from "./learning";
import { deckStore } from "./storage";
export type ReminderMessage = { title: string; body: string; url: string };
/** The generic nudge, used whenever the notes cannot carry the message. */
export function genericReminder(): ReminderMessage {
return { title: t("push.title"), body: t("push.generic"), url: "/" };
}
/** Which deck has the most due cards (ties keep the first, i.e. the oldest). */
function busiestDeck(schedules: SrsRecord[]): { title: string; count: number } | null {
const byDeck = new Map<string, { title: string; count: number }>();
for (const item of dueItems(schedules)) {
const entry = byDeck.get(item.deck.id) ?? { title: item.deck.title, count: 0 };
entry.count += 1;
byDeck.set(item.deck.id, entry);
}
let top: { title: string; count: number } | null = null;
for (const entry of byDeck.values()) {
if (!top || entry.count > top.count) top = entry;
}
return top;
}
export async function buildReminderMessage(): Promise<ReminderMessage> {
try {
await ensureLearningHydrated();
const top = busiestDeck(learningStore.getSrsSnapshot());
if (!top || deckStore.getSnapshot().length === 0) return genericReminder();
return {
title: t("push.title"),
body: plural(top.count, "push.due", { deck: top.title }),
/* The review screen holds the due queue the nudge talks about. */
url: "/review",
};
} catch {
return genericReminder();
}
}