From 77819b5961a51d0ad341793264163b7eab21e093 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sun, 18 Oct 2026 14:34:15 +0000 Subject: [PATCH] Push reminders: personal to the notes, generic without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 8 +- README.md | 69 +++++++++++---- app/api/push/route.ts | 13 +++ app/api/push/test/route.ts | 10 ++- app/layout.tsx | 2 + components/LearningSection.tsx | 6 +- components/PushReminderSync.tsx | 74 ++++++++++++++++ deploy/freezmo-reminders.service | 29 +++++++ deploy/freezmo-reminders.timer | 17 ++++ lib/i18n-dict.ts | 20 +++-- lib/legal.ts | 8 +- lib/push-store.ts | 31 +++++-- lib/reminder.ts | 60 +++++++++++++ package.json | 2 +- public/sw.js | 2 +- scripts/reminder-payload.mjs | 36 ++++++++ scripts/send-reminders.mjs | 21 +++-- tests/phase2-test.mjs | 139 ++++++++++++++++++++++++++++++- 18 files changed, 498 insertions(+), 49 deletions(-) create mode 100644 components/PushReminderSync.tsx create mode 100644 deploy/freezmo-reminders.service create mode 100644 deploy/freezmo-reminders.timer create mode 100644 lib/reminder.ts create mode 100644 scripts/reminder-payload.mjs diff --git a/.env.example b/.env.example index eed2f74..fcdce26 100644 --- a/.env.example +++ b/.env.example @@ -65,10 +65,14 @@ TEXT_MODEL=gpt-oss:120b-cloud # VAPID_PUBLIC_KEY= # VAPID_PRIVATE_KEY= # VAPID_SUBJECT=mailto:you@example.com -# Where subscriptions are stored (endpoint + keys only, no personal data). +# Where subscriptions are stored (endpoint, keys, and the reminder text the +# browser composed for that device — it may name one of its decks). # PUSH_DIR=.freezmo/push -# The daily sender is a script you schedule yourself (cron/systemd timer): +# The daily sender is a script you schedule yourself — cron, or the shipped +# systemd timer (deploy/freezmo-reminders.timer): # node scripts/send-reminders.mjs +# A device's own reminder text wins; these generic lines are the fallback for +# subscriptions that never synced one (same words the app uses without notes). # PUSH_REMINDER_TITLE=Time to review # PUSH_REMINDER_BODY=Your decks are waiting — a few due cards keep the streak alive. diff --git a/README.md b/README.md index 6165de6..d8fbe36 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,11 @@ no database, no data leaving your machine except the requests you send to Ollama tab has the day streak, a five-week calendar, accuracy per subject and per deck and the weakest topics. **Exam mode** (its own tab) takes one or more decks, a question count and an optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily - reminders** appear in Settings only when the server has VAPID keys configured; subscriptions are - stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does - the sending. + reminders** appear in Settings only when the server has VAPID keys configured. Each device + writes its own notification — how many of *its* cards are due, in which deck, in the app's + language, or the plain nudge when there are no notes at all — and stores it with its + subscription; `node scripts/send-reminders.mjs` is the daily sender (the shipped + `deploy/freezmo-reminders.timer`, or your own cron). - **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders, homework, progress, safe settings — never the API key) to one JSON file and imports a backup with a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14 @@ -546,6 +548,24 @@ loginctl enable-linger "$USER" # start at boot without logging in | `journalctl --user -u freezmo -f` | live production logs | | `systemctl --user restart freezmo-dev` | after changing the dev unit | +The **daily reminder** is its own pair — `deploy/freezmo-reminders.service` +(one shot: send whatever each device asked for) and +`deploy/freezmo-reminders.timer` (18:00 local, `Persistent=true` so a laptop +that was asleep still catches up): + +```bash +cp deploy/freezmo-reminders.service deploy/freezmo-reminders.timer ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now freezmo-reminders.timer +systemctl --user list-timers freezmo-reminders.timer # next run +journalctl --user -u freezmo-reminders -n 20 # what it sent +``` + +It needs `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` in the shared `env.local` +(the settings UI is hidden without them) and reads `PUSH_DIR` relative to the +release's `server/` directory, which is where the app writes subscriptions — +run it from there, or set `PUSH_DIR` yourself. + Production is **never** rebuilt or restarted by hand — `scripts/deploy.sh` does that, and only with a pushed, verified commit. The dev instance reads `~/freezmo/.env.local`, builds into `.next-dev` and keeps its data in @@ -922,9 +942,9 @@ scripts/ | `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 | | `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) | | `GET /api/push` | – | `{ enabled, publicKey? }` — reminders are hidden when `enabled` is false | -| `POST /api/push` | `{ endpoint, keys }` | `{ ok, count }` (stored server-side, endpoint + keys only) | +| `POST /api/push` | `{ endpoint, keys, message? }` | `{ ok, count }` (stored server-side: endpoint, keys, and the reminder text the browser composed — decision 110) | | `DELETE /api/push?endpoint=` | – | `{ ok, count }` | -| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses | +| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses; sends the reminder this device stored, prefixed “Test:” | The model routes accept an optional **`x-ollama-key`** header (the browser's own key; required in `user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers @@ -987,9 +1007,9 @@ grading, regeneration and “add more” (and the assignment language for homewo - **Answer history lives in IndexedDB** (`freezmo-learning`, versioned stores `answers` + `srs`), because an event log would eventually fill `localStorage`; the log is capped at 20 000 answers (oldest pruned). Backups and sync include it, so progress survives a device move. Push - subscriptions are the only reminder state on the server (`PUSH_DIR`, endpoint + keys only) and - are removed when you turn reminders off — or automatically when the push service reports them - gone. + subscriptions are the only reminder state on the server (`PUSH_DIR`: endpoint, keys and the + notification text the browser composed — nothing else) and are removed when you turn reminders + off, or automatically when the push service reports them gone. --- @@ -1003,6 +1023,7 @@ grading, regeneration and “add more” (and the assignment language for homewo | Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. | | “You've run out of your free monthly Ollama usage” | The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at `ollama.com/settings`, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry. | | Reminders are missing from Settings | They only appear when `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are set in `.env.local` (generate with `npx web-push generate-vapid-keys`). On iPhone, add Freezmo to the Home Screen first — Safari tabs can't receive push. | +| No reminder arrived at the usual time | The sender is a timer you install (or your own cron): `systemctl --user list-timers freezmo-reminders.timer` shows the next run, `journalctl --user -u freezmo-reminders -n 20` shows what it sent. It reads `PUSH_DIR` relative to the release's `server/` directory, so run it from there (the shipped unit does). | | “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. | | “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. | | “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at . | @@ -1259,8 +1280,8 @@ Things I chose while building, with the reasoning: (native), i18n, the freeze loading screen, Smart Review, and phases 1–3 (backup/sync/usage limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or VAPID keys start their own `next start` instance on a spare port, verify the port is free and - kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Twelve suites, - 513 checks. + kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). The deploy + gate runs every suite against the release before switching `current`. 46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the @@ -1492,6 +1513,24 @@ Things I chose while building, with the reasoning: into view as you type. No strings, so no i18n; the additive change sits on top of the merged scene-heat rework unchanged. +110. **The reminder is written by the browser, because the notes never leave it.** Push reminders + used to say one generic line to everyone, and the server had no way to do better: it only + knows push endpoints, never a deck. So the notification text is now composed on the device + (`lib/reminder.ts`): how many cards are due, in the deck that has the most of them, in the + app's own language — or the plain nudge when there are no notes at all, translated like every + other string. It travels with the subscription (`POST /api/push` takes an optional + `message: { title, body, url }`, `url` forced to a path on this app so a stolen subscription + cannot turn the notification into a link elsewhere), and the daily sender + (`scripts/send-reminders.mjs`, `scripts/reminder-payload.mjs`) sends each device exactly what + it asked for, falling back to the operator's `PUSH_REMINDER_TITLE/_BODY` for a subscription + that has none. Tapping the notification opens `/review` — the queue the count talks about. The + text cannot go stale either: `components/PushReminderSync.tsx` refreshes it when the app opens + and whenever the tab becomes visible, throttled to twice a day, so "3 cards are due" is only + ever sent while three really are. The settings copy says what the server now holds, the + privacy page and `.env.example` say it too, and the phase-2 suite pins the whole path with a + faked browser subscription: personal text in both languages, the generic fallback in both, + and the sender's preference order (81 checks). + 109. **Teach it back: the AI stops being the tutor and becomes the student.** The mode where the user explains a topic and an AI classmate probes the gaps — never a grader, never lecturing. `/teach` (a sidebar row next to Exam mode, plus *Teach it back* in every deck's actions) @@ -2247,10 +2286,12 @@ Things I chose while building, with the reasoning: start** (a bug the phase-2 suite caught: answering a mistake removed it from the live list and collapsed the session mid-run). Push reminders are all-or-nothing: no VAPID keys → no UI, no endpoints, nothing to misconfigure; `web-push` is the one added server dependency and the daily - send is an explicit cron/timer script. Verified by the phase-2 suite (43 checks: history and - SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the - VAPID-less hidden state and the configured subscribe/validate/test/delete path; the suite - runs its own server instance on a separate port and kills the process group on exit). + send is an explicit cron/timer script — the shipped `deploy/freezmo-reminders.{service,timer}` + pair. Verified by the phase-2 suite (81 checks: history and SRS shapes, the review/mistakes + sessions, stats math, exam with timer and breakdowns, the VAPID-less hidden state, the + configured subscribe/validate/test/delete path, and the reminder each device composes from its + own notes in both languages; the suite runs its own server instance on a separate port and + kills the process group on exit). 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). diff --git a/app/api/push/route.ts b/app/api/push/route.ts index d8c8451..8e18f45 100644 --- a/app/api/push/route.ts +++ b/app/api/push/route.ts @@ -39,6 +39,19 @@ const subscriptionSchema = z.object({ p256dh: z.string().min(16).max(400), auth: z.string().min(8).max(200), }), + /** + * The reminder text this browser wants to receive (composed client-side, see + * lib/reminder.ts). Only ever shown back to the user in their notification. + * `url` must be a path on this app, so a stolen subscription cannot turn the + * notification into a link somewhere else. + */ + message: z + .object({ + title: z.string().min(1).max(150), + body: z.string().min(1).max(300), + url: z.string().max(300).regex(/^\//).optional(), + }) + .optional(), }); export async function GET(request: Request) { diff --git a/app/api/push/test/route.ts b/app/api/push/test/route.ts index b98ab04..79be3a6 100644 --- a/app/api/push/test/route.ts +++ b/app/api/push/test/route.ts @@ -61,13 +61,17 @@ export async function POST(request: Request) { } webpush.setVapidDetails(config.push.subject, config.push.publicKey, config.push.privateKey); + /* The test shows the reminder this device would actually get — the browser's + own text when it has one, the generic nudge otherwise — with a marker so a + test is never mistaken for the real thing. */ + const message = target.message; try { await webpush.sendNotification( { endpoint: target.endpoint, keys: target.keys }, JSON.stringify({ - title: translate(locale, "server.pushReminderTitle"), - body: translate(locale, "server.pushReminderBody"), - url: "/", + title: `${translate(locale, "server.pushTestPrefix")} ${message?.title ?? translate(locale, "push.title")}`, + body: message?.body ?? translate(locale, "push.generic"), + url: message?.url ?? "/", }), ); } catch (error) { diff --git a/app/layout.tsx b/app/layout.tsx index 2ecd23f..067f4fd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { MotionPreference } from "@/components/MotionPreference"; import { CookieConsent } from "@/components/CookieConsent"; import { InstallSheet } from "@/components/InstallSheet"; import { OfflineBanner } from "@/components/OfflineBanner"; +import { PushReminderSync } from "@/components/PushReminderSync"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { Toaster } from "@/components/Toaster"; import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; @@ -116,6 +117,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + diff --git a/components/LearningSection.tsx b/components/LearningSection.tsx index 0043eaf..6a4e3c3 100644 --- a/components/LearningSection.tsx +++ b/components/LearningSection.tsx @@ -5,6 +5,7 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { Button } from "@/components/ui"; import { haptic } from "@/lib/haptics"; import { localeHeader, useI18n } from "@/lib/i18n"; +import { buildReminderMessage } from "@/lib/reminder"; import { authHeaders, saveSettings, settingsStore } from "@/lib/settings"; const GOALS = [10, 20, 30, 50]; @@ -93,10 +94,13 @@ export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) { userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(status.publicKey) as unknown as BufferSource, }); + /* The reminder text travels with the subscription: the server has no + access to the notes, so this is where it becomes personal. */ + const message = await buildReminderMessage(); const response = await fetch("/api/push", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, - body: JSON.stringify(subscription.toJSON()), + body: JSON.stringify({ ...subscription.toJSON(), message }), }); if (!response.ok) throw new Error("subscribe failed"); setSubscribed(true); diff --git a/components/PushReminderSync.tsx b/components/PushReminderSync.tsx new file mode 100644 index 0000000..d4ea83c --- /dev/null +++ b/components/PushReminderSync.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect } from "react"; + +import { localeHeader } from "@/lib/i18n"; +import { buildReminderMessage } from "@/lib/reminder"; +import { authHeaders } from "@/lib/settings"; + +/** + * Keeps the reminder text the server holds in step with the notes. + * + * The reminder is written in the browser (lib/reminder.ts) because the notes + * never leave it, and the due cards move all day: a nudge that still says + * "3 cards are due" on a morning with nothing due is worse than no nudge. This + * refreshes it when the app opens and whenever the tab is visible again, + * throttled to twice a day. It does nothing at all when this browser is not + * subscribed, has no push support, or the server has reminders switched off. + */ + +const REFRESH_KEY = "freezmo.push.refresh.v1"; +const REFRESH_MS = 6 * 60 * 60 * 1000; + +function fresh(): boolean { + try { + const at = Number(window.localStorage.getItem(REFRESH_KEY) ?? 0); + return Number.isFinite(at) && Date.now() - at < REFRESH_MS; + } catch { + return false; + } +} + +function remember(): void { + try { + window.localStorage.setItem(REFRESH_KEY, String(Date.now())); + } catch { + /* private mode */ + } +} + +export function PushReminderSync() { + useEffect(() => { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator) || !("PushManager" in window)) return; + if (fresh()) return; + let done = false; + const sync = async () => { + if (done) return; + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) return; // reminders are off for this browser + const message = await buildReminderMessage(); + const response = await fetch("/api/push", { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, + body: JSON.stringify({ ...subscription.toJSON(), message }), + }); + if (response.ok) { + done = true; + remember(); + } + } catch { + /* the stored reminder stays as it is */ + } + }; + void sync(); + const onVisible = () => { + if (document.visibilityState === "visible") void sync(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, []); + + return null; +} diff --git a/deploy/freezmo-reminders.service b/deploy/freezmo-reminders.service new file mode 100644 index 0000000..6b0fb55 --- /dev/null +++ b/deploy/freezmo-reminders.service @@ -0,0 +1,29 @@ +[Unit] +# Freezmo — the daily review reminder. +# +# Reminders are personal: each browser composes the notification text from its +# own notes (how many cards are due, in which deck, in the user's language) and +# stores it with its push subscription. This oneshot sends exactly that text to +# every device, falling back to the generic PUSH_REMINDER_TITLE/_BODY for a +# subscription that has none. It reads the same shared env and data directory +# as the production service, so it sees the subscriptions the app wrote. +# +# Install (per user, together with the timer): +# cp ~/freezmo/deploy/freezmo-reminders.{service,timer} ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now freezmo-reminders.timer +# Logs: journalctl --user -u freezmo-reminders -n 20 +Description=Freezmo daily review reminder +Documentation=file://%h/freezmo/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# The release's server directory, so `.freezmo/push` is the exact directory the +# running app writes subscriptions to (the standalone server has no .env file +# handling of its own — the env comes from EnvironmentFile, like the service). +WorkingDirectory=%h/freezmo-prod/current/server +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=-%h/freezmo-prod/shared/env.local +ExecStart=/bin/node %h/freezmo-prod/current/src/scripts/send-reminders.mjs diff --git a/deploy/freezmo-reminders.timer b/deploy/freezmo-reminders.timer new file mode 100644 index 0000000..eb15d6b --- /dev/null +++ b/deploy/freezmo-reminders.timer @@ -0,0 +1,17 @@ +[Unit] +# Freezmo — when the daily review reminder goes out. +# +# Evening by default: late enough that a day's study is already in the +# schedule, early enough to still do something about it. `Persistent=true` +# sends a missed run when the machine comes back (a laptop that was asleep), +# and the random delay keeps every instance from firing on the same second. +Description=Freezmo daily review reminder (timer) + +[Timer] +OnCalendar=*-*-* 18:00:00 +Persistent=true +RandomizedDelaySec=900 +Unit=freezmo-reminders.service + +[Install] +WantedBy=timers.target diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 47dab6f..6635485 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -886,8 +886,7 @@ const en: Record = { "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 = { "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 = { "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 = { "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í", diff --git a/lib/legal.ts b/lib/legal.ts index effeae5..766e337 100644 --- a/lib/legal.ts +++ b/lib/legal.ts @@ -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.", ], }, { diff --git a/lib/push-store.ts b/lib/push-store.ts index 7259c44..f857789 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -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 { 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 }; diff --git a/lib/reminder.ts b/lib/reminder.ts new file mode 100644 index 0000000..9ed6041 --- /dev/null +++ b/lib/reminder.ts @@ -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(); + 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 { + 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(); + } +} diff --git a/package.json b/package.json index f13bbb3..3ffc1b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.8.1", + "version": "0.8.2", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 599ad31..3ef35d5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v103"; +const CACHE_VERSION = "freezmo-v104"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/scripts/reminder-payload.mjs b/scripts/reminder-payload.mjs new file mode 100644 index 0000000..987d71d --- /dev/null +++ b/scripts/reminder-payload.mjs @@ -0,0 +1,36 @@ +/** + * What one stored subscription's reminder should say. + * + * Since reminders became personal (lib/reminder.ts), each subscription carries + * the notification text its own browser composed — already in the user's + * language, naming their deck when their notes could carry it. Older records + * (or a browser that never refreshed) have none, so the operator's generic + * default from the environment is the fallback. Kept in its own module so both + * the sender and the test suite can exercise it without sending anything. + */ + +/** The operator's fallback, from PUSH_REMINDER_* (see .env.example). */ +export const DEFAULT_REMINDER = { + title: "Time to review", + body: "Your decks are waiting — a few due cards keep the streak alive.", + url: "/", +}; + +function text(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function reminderPayload(entry, defaults = DEFAULT_REMINDER) { + const fallback = { + title: text(defaults?.title) || DEFAULT_REMINDER.title, + body: text(defaults?.body) || DEFAULT_REMINDER.body, + url: text(defaults?.url) || DEFAULT_REMINDER.url, + }; + const message = entry?.message; + if (!message || typeof message !== "object") return fallback; + const title = text(message.title); + const body = text(message.body); + if (!title || !body) return fallback; + const url = text(message.url); + return { title, body, url: url.startsWith("/") ? url : fallback.url }; +} diff --git a/scripts/send-reminders.mjs b/scripts/send-reminders.mjs index c4a4f20..a16de81 100644 --- a/scripts/send-reminders.mjs +++ b/scripts/send-reminders.mjs @@ -1,9 +1,14 @@ /** * Sends the daily review reminder to every stored push subscription. * - * Meant for cron or a systemd timer, e.g.: + * Meant for cron or a systemd timer (deploy/freezmo-reminders.timer): * 0 18 * * * cd /path/to/freezmo && node scripts/send-reminders.mjs * + * Each subscription carries the notification text its own browser composed — + * personal (how many cards are due, in which deck) and in the user's language. + * Subscriptions without one (stored before that, or a browser that has not + * synced) get the generic fallback from PUSH_REMINDER_TITLE / _BODY. + * * Silently does nothing when VAPID keys are not configured (the whole * reminder feature stays hidden in the UI in that case). */ @@ -11,14 +16,17 @@ import { readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import webpush from "web-push"; +import { reminderPayload } from "./reminder-payload.mjs"; + const dir = process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"); const publicKey = process.env.VAPID_PUBLIC_KEY?.trim(); const privateKey = process.env.VAPID_PRIVATE_KEY?.trim(); const subject = process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost"; -const title = process.env.PUSH_REMINDER_TITLE?.trim() || "Time to review"; -const body = - process.env.PUSH_REMINDER_BODY?.trim() || - "Your decks are waiting — a few due cards keep the streak alive."; +const defaults = { + title: process.env.PUSH_REMINDER_TITLE, + body: process.env.PUSH_REMINDER_BODY, + url: process.env.PUSH_REMINDER_URL, +}; if (!publicKey || !privateKey) { console.log("[freezmo] VAPID keys are not configured — nothing to send."); @@ -45,9 +53,10 @@ let removed = 0; const remaining = []; for (const entry of subscriptions) { try { + const payload = reminderPayload(entry, defaults); await webpush.sendNotification( { endpoint: entry.endpoint, keys: entry.keys }, - JSON.stringify({ title, body, url: "/" }), + JSON.stringify(payload), ); sent += 1; remaining.push(entry); diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index 344ac4a..5b2d398 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -4,7 +4,7 @@ * timer, and the push-reminder plumbing (hidden without VAPID, validated with * it). */ -import { mkdirSync, readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import path from "node:path"; @@ -12,6 +12,7 @@ import { BASE, DECK, REPO, + dueSrs, launchBrowser, makeChecker, makePage, @@ -22,6 +23,7 @@ import { spawnApp, waitForHttp, } from "./lib.mjs"; +import { reminderPayload } from "../scripts/reminder-payload.mjs"; const { check, finish } = makeChecker(); @@ -498,7 +500,11 @@ const browser = await launchBrowser(); { check("push: port 3209 is free", !(await portInUse(3209))); - mkdirSync(path.join(REPO, "tests", ".tmp", "push"), { recursive: true }); + const pushDir = path.join(REPO, "tests", ".tmp", "push"); + mkdirSync(pushDir, { recursive: true }); + /* Start from an empty store: a previous run's subscriptions (and their + reminder texts) must never be mistaken for this run's. */ + rmSync(path.join(pushDir, "subscriptions.json"), { force: true }); const vapid = await new Promise((resolve) => { const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO }); let output = ""; @@ -550,7 +556,11 @@ const browser = await launchBrowser(); }, subscription); check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body)); const file = JSON.parse(readFileSync(path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"), "utf8")); - check("push: stored without personal data", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys"); + check( + "push: a subscription with no reminder text stores nothing about the notes", + file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys", + JSON.stringify(Object.keys(file[0] ?? {})), + ); const badBody = await page.evaluate(async () => { const response = await fetch("/api/push", { @@ -562,6 +572,16 @@ const browser = await launchBrowser(); }); check("push: invalid subscription → 400", badBody === 400, String(badBody)); + const badMessage = await page.evaluate(async (sub) => { + const response = await fetch("/api/push", { + method: "POST", + headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" }, + body: JSON.stringify({ ...sub, message: { title: "Hi", body: "There", url: "https://elsewhere.example/" } }), + }); + return response.status; + }, subscription); + check("push: a reminder link that leaves the app → 400", badMessage === 400, String(badMessage)); + const testSend = await page.evaluate(async (sub) => { const response = await fetch("/api/push/test", { method: "POST", @@ -581,6 +601,119 @@ const browser = await launchBrowser(); }, subscription); check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body)); await context.close(); + + /* 5b. the reminder text is composed in the browser, from the notes --- + The server never sees the notes, so the message each device wants + travels with its subscription. The app's own background refresh is + driven here with a faked browser subscription (the headless browser + has no push service of its own) — the composer, the store and the + read-back are all the real code. */ + const subscriptionsFile = path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"); + const scenarios = [ + { + name: "en, two cards due in Sky deck", + locale: "en", + decks: [DECK], + due: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2")], + endpoint: "https://push.example.invalid/personal-en", + title: "Time to review", + body: "2 cards are due in Sky deck", + url: "/review", + }, + { + name: "cs, one card due", + locale: "cs", + decks: [DECK], + due: [dueSrs("seed_deck", "a2")], + endpoint: "https://push.example.invalid/personal-cs", + title: "Čas na opakování", + body: "1 kartička čeká v sadě Sky deck", + url: "/review", + }, + { + name: "en, no notes at all", + locale: "en", + decks: [], + due: [], + endpoint: "https://push.example.invalid/generic-en", + title: "Time to review", + body: "Your decks are waiting — a few due cards keep the streak alive.", + url: "/", + }, + { + name: "cs, no notes at all", + locale: "cs", + decks: [], + due: [], + endpoint: "https://push.example.invalid/generic-cs", + title: "Čas na opakování", + body: "Sady na tebe čekají — pár kartiček udrží sérii.", + url: "/", + }, + ]; + + for (const scenario of scenarios) { + const personal = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const personalPage = await personal.newPage(); + await seed(personalPage, { decks: scenario.decks, locale: scenario.locale }); + await personalPage.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" }); + await personalPage.waitForTimeout(700); + if (scenario.due.length > 0) { + /* The home screen reads the learning store on mount, so the mirror + is already hydrated: the records written here are picked up by a + reload, exactly like a browser that opened the app after studying. */ + await seedLearning(personalPage, { srs: scenario.due }); + await personalPage.reload({ waitUntil: "networkidle" }); + await personalPage.waitForTimeout(700); + } + + await personalPage.evaluate(async (endpoint) => { + const registration = await navigator.serviceWorker.ready; + Object.defineProperty(registration, "pushManager", { + configurable: true, + value: { + getSubscription: async () => ({ + toJSON: () => ({ endpoint, keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) } }), + }), + }, + }); + /* The component refreshes on visibility: the first pass (no real + subscription here) leaves it primed for exactly this event. */ + document.dispatchEvent(new Event("visibilitychange")); + }, scenario.endpoint); + + let stored = null; + for (let attempt = 0; attempt < 40 && !stored; attempt += 1) { + await personalPage.waitForTimeout(150); + const entries = JSON.parse(readFileSync(subscriptionsFile, "utf8")); + stored = entries.find((entry) => entry.endpoint === scenario.endpoint) ?? null; + } + check( + `push: ${scenario.name} — the browser-composed reminder`, + stored?.message?.title === scenario.title && + stored?.message?.body === scenario.body && + stored?.message?.url === scenario.url, + JSON.stringify(stored?.message ?? null), + ); + await personal.close(); + } + + /* The sender script's fallback: a device with no text of its own gets + the operator's generic lines, and a link that leaves the app is never + sent. */ + check("push: the sender prefers the device's own reminder text", (() => { + const message = { title: "Own title", body: "Own body", url: "/review" }; + const payload = reminderPayload({ message }, {}); + return payload.title === "Own title" && payload.body === "Own body" && payload.url === "/review"; + })()); + check("push: the sender falls back to the generic lines", (() => { + const payload = reminderPayload({}, { title: "Fallback", body: "Fallback body" }); + return payload.title === "Fallback" && payload.body === "Fallback body" && payload.url === "/"; + })()); + check("push: the sender never sends a reminder link off the app", (() => { + const payload = reminderPayload({ message: { title: "T", body: "B", url: "https://elsewhere.example/" } }, {}); + return payload.url === "/"; + })()); } } finally { app.stop();