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

@ -65,10 +65,14 @@ TEXT_MODEL=gpt-oss:120b-cloud
# VAPID_PUBLIC_KEY= # VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY= # VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:you@example.com # 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 # 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 # 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_TITLE=Time to review
# PUSH_REMINDER_BODY=Your decks are waiting — a few due cards keep the streak alive. # PUSH_REMINDER_BODY=Your decks are waiting — a few due cards keep the streak alive.

View file

@ -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 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 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 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 reminders** appear in Settings only when the server has VAPID keys configured. Each device
stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does writes its own notification — how many of *its* cards are due, in which deck, in the app's
the sending. 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, - **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 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 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 | | `journalctl --user -u freezmo -f` | live production logs |
| `systemctl --user restart freezmo-dev` | after changing the dev unit | | `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` Production is **never** rebuilt or restarted by hand — `scripts/deploy.sh`
does that, and only with a pushed, verified commit. The dev instance reads 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 `~/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 | | `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 |
| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) | | `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) |
| `GET /api/push` | – | `{ enabled, publicKey? }` — reminders are hidden when `enabled` is false | | `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 }` | | `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 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 `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`), - **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 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 (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 subscriptions are the only reminder state on the server (`PUSH_DIR`: endpoint, keys and the
are removed when you turn reminders off — or automatically when the push service reports them notification text the browser composed — nothing else) and are removed when you turn reminders
gone. 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`. | | 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. | | “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. | | 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 <https://ollama.com/settings/keys> on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. | | “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from <https://ollama.com/settings/keys> 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. | | “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 <https://ollama.com/settings>. | | “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 <https://ollama.com/settings>. |
@ -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 (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 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 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, kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). The deploy
513 checks. 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 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 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 into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged. 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 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. 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) `/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 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 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 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 send is an explicit cron/timer script — the shipped `deploy/freezmo-reminders.{service,timer}`
SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the pair. Verified by the phase-2 suite (81 checks: history and SRS shapes, the review/mistakes
VAPID-less hidden state and the configured subscribe/validate/test/delete path; the suite sessions, stats math, exam with timer and breakdowns, the VAPID-less hidden state, the
runs its own server instance on a separate port and kills the process group on exit). 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 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`). support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).

View file

@ -39,6 +39,19 @@ const subscriptionSchema = z.object({
p256dh: z.string().min(16).max(400), p256dh: z.string().min(16).max(400),
auth: z.string().min(8).max(200), 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) { export async function GET(request: Request) {

View file

@ -61,13 +61,17 @@ export async function POST(request: Request) {
} }
webpush.setVapidDetails(config.push.subject, config.push.publicKey, config.push.privateKey); 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 { try {
await webpush.sendNotification( await webpush.sendNotification(
{ endpoint: target.endpoint, keys: target.keys }, { endpoint: target.endpoint, keys: target.keys },
JSON.stringify({ JSON.stringify({
title: translate(locale, "server.pushReminderTitle"), title: `${translate(locale, "server.pushTestPrefix")} ${message?.title ?? translate(locale, "push.title")}`,
body: translate(locale, "server.pushReminderBody"), body: message?.body ?? translate(locale, "push.generic"),
url: "/", url: message?.url ?? "/",
}), }),
); );
} catch (error) { } catch (error) {

View file

@ -6,6 +6,7 @@ import { MotionPreference } from "@/components/MotionPreference";
import { CookieConsent } from "@/components/CookieConsent"; import { CookieConsent } from "@/components/CookieConsent";
import { InstallSheet } from "@/components/InstallSheet"; import { InstallSheet } from "@/components/InstallSheet";
import { OfflineBanner } from "@/components/OfflineBanner"; import { OfflineBanner } from "@/components/OfflineBanner";
import { PushReminderSync } from "@/components/PushReminderSync";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import { Toaster } from "@/components/Toaster"; import { Toaster } from "@/components/Toaster";
import { IS_DEV_INSTANCE } from "@/lib/dev-mode"; import { IS_DEV_INSTANCE } from "@/lib/dev-mode";
@ -116,6 +117,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<CookieConsent /> <CookieConsent />
<Toaster /> <Toaster />
<ServiceWorkerRegistrar /> <ServiceWorkerRegistrar />
<PushReminderSync />
</MotionPreference> </MotionPreference>
</body> </body>
</html> </html>

View file

@ -5,6 +5,7 @@ import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
import { localeHeader, useI18n } from "@/lib/i18n"; import { localeHeader, useI18n } from "@/lib/i18n";
import { buildReminderMessage } from "@/lib/reminder";
import { authHeaders, saveSettings, settingsStore } from "@/lib/settings"; import { authHeaders, saveSettings, settingsStore } from "@/lib/settings";
const GOALS = [10, 20, 30, 50]; const GOALS = [10, 20, 30, 50];
@ -93,10 +94,13 @@ export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(status.publicKey) as unknown as BufferSource, 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", { const response = await fetch("/api/push", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, 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"); if (!response.ok) throw new Error("subscribe failed");
setSubscribed(true); setSubscribed(true);

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -886,8 +886,7 @@ const en: Record<string, string> = {
"server.pushNotSubscribed": "This browser is not subscribed to reminders.", "server.pushNotSubscribed": "This browser is not subscribed to reminders.",
"server.pushGone": "That subscription is no longer valid — reminders were turned off for it.", "server.pushGone": "That subscription is no longer valid — reminders were turned off for it.",
"server.pushSendFailed": "The reminder could not be sent.", "server.pushSendFailed": "The reminder could not be sent.",
"server.pushReminderTitle": "Time to review", "server.pushTestPrefix": "Test:",
"server.pushReminderBody": "Your decks are waiting — a few due cards keep the streak alive.",
/* folders / files -------------------------------------------------- */ /* folders / files -------------------------------------------------- */
"folder.nameRequired": "Give the folder a name.", "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.dailyGoalNote": "Answers per day that count as done.",
"settings.reminders": "Daily reminder", "settings.reminders": "Daily reminder",
"settings.remindersNote": "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.reminderEnable": "Turn on reminders",
"settings.reminderDisable": "Turn off", "settings.reminderDisable": "Turn off",
"settings.reminderTest": "Send a test", "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.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.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.pushSendFailed": "Připomínku se nepodařilo poslat.",
"server.pushReminderTitle": "Čas na opakování", "server.pushTestPrefix": "Test:",
"server.pushReminderBody": "Sady na tebe čekají — pár kartiček udrží sérii.",
/* folders / files -------------------------------------------------- */ /* folders / files -------------------------------------------------- */
"folder.nameRequired": "Dej složce název.", "folder.nameRequired": "Dej složce název.",
@ -2130,8 +2133,13 @@ const cs: Record<string, string> = {
"settings.dailyGoal": "Denní cíl", "settings.dailyGoal": "Denní cíl",
"settings.dailyGoalNote": "Kolik odpovědí za den se počítá jako splněno.", "settings.dailyGoalNote": "Kolik odpovědí za den se počítá jako splněno.",
"settings.reminders": "Denní připomínka", "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": "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.reminderEnable": "Zapnout připomínky",
"settings.reminderDisable": "Vypnout", "settings.reminderDisable": "Vypnout",
"settings.reminderTest": "Poslat zkušební", "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:"], paragraphs: ["There is no other server-side data. Each of these is off until you turn it on:"],
list: [ list: [
"Sync — an encrypted blob the server cannot read (the key stays in your browser). Revocable from Settings → Delete synced data.", "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.", "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", heading: "Third parties",
paragraphs: [ 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š:"], paragraphs: ["Jiná data na serveru nejsou. Každá z těchhle věcí je vypnutá, dokud ji nezapneš:"],
list: [ 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.", "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.", "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", heading: "Třetí strany",
paragraphs: [ 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"; import path from "node:path";
/** /**
* Server-side push subscriptions: one JSON file with endpoint + keys and a * Server-side push subscriptions: one JSON file with endpoint + keys, the
* creation timestamp. No personal data, no history, no user ids — and the * reminder text this browser asked for, and a creation timestamp. No user ids,
* whole feature stays hidden unless VAPID keys are configured. * 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 = { export type PushSubscriptionRecord = {
endpoint: string; endpoint: string;
keys: { p256dh: string; auth: string }; keys: { p256dh: string; auth: string };
/** Absent for a subscription stored before reminders carried a message. */
message?: PushReminderMessage;
createdAt: number; createdAt: number;
}; };
@ -33,7 +41,6 @@ export async function readSubscriptions(dir: string): Promise<PushSubscriptionRe
return []; return [];
} }
} }
async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> { async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> {
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const file = fileFor(dir); const file = fileFor(dir);
@ -45,17 +52,25 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]):
/** Adds or refreshes a subscription (deduped by endpoint). */ /** Adds or refreshes a subscription (deduped by endpoint). */
export async function addSubscription( export async function addSubscription(
dir: string, 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. */ /** Cap on stored subscriptions — the configured, sane one. */
max = 50, max = 50,
): Promise<{ count: number; accepted: boolean }> { ): Promise<{ count: number; accepted: boolean }> {
const current = await readSubscriptions(dir); 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); const next = current.filter((entry) => entry.endpoint !== subscription.endpoint);
// Refreshing a device that is already subscribed always works; a brand new // Refreshing a device that is already subscribed always works; a brand new
// device over the cap is refused. // device over the cap is refused.
if (!exists && next.length >= max) return { count: current.length, accepted: false }; if (previous === undefined && next.length >= max) return { count: current.length, accepted: false };
next.push({ ...subscription, createdAt: Date.now() }); /* 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); const capped = next.slice(-max);
await writeSubscriptions(dir, capped); await writeSubscriptions(dir, capped);
return { count: capped.length, accepted: true }; 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();
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.8.1", "version": "0.8.2",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -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 };
}

View file

@ -1,9 +1,14 @@
/** /**
* Sends the daily review reminder to every stored push subscription. * 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 * 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 * Silently does nothing when VAPID keys are not configured (the whole
* reminder feature stays hidden in the UI in that case). * 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 path from "node:path";
import webpush from "web-push"; 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 dir = process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push");
const publicKey = process.env.VAPID_PUBLIC_KEY?.trim(); const publicKey = process.env.VAPID_PUBLIC_KEY?.trim();
const privateKey = process.env.VAPID_PRIVATE_KEY?.trim(); const privateKey = process.env.VAPID_PRIVATE_KEY?.trim();
const subject = process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost"; const subject = process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost";
const title = process.env.PUSH_REMINDER_TITLE?.trim() || "Time to review"; const defaults = {
const body = title: process.env.PUSH_REMINDER_TITLE,
process.env.PUSH_REMINDER_BODY?.trim() || body: process.env.PUSH_REMINDER_BODY,
"Your decks are waiting — a few due cards keep the streak alive."; url: process.env.PUSH_REMINDER_URL,
};
if (!publicKey || !privateKey) { if (!publicKey || !privateKey) {
console.log("[freezmo] VAPID keys are not configured — nothing to send."); console.log("[freezmo] VAPID keys are not configured — nothing to send.");
@ -45,9 +53,10 @@ let removed = 0;
const remaining = []; const remaining = [];
for (const entry of subscriptions) { for (const entry of subscriptions) {
try { try {
const payload = reminderPayload(entry, defaults);
await webpush.sendNotification( await webpush.sendNotification(
{ endpoint: entry.endpoint, keys: entry.keys }, { endpoint: entry.endpoint, keys: entry.keys },
JSON.stringify({ title, body, url: "/" }), JSON.stringify(payload),
); );
sent += 1; sent += 1;
remaining.push(entry); remaining.push(entry);

View file

@ -4,7 +4,7 @@
* timer, and the push-reminder plumbing (hidden without VAPID, validated with * timer, and the push-reminder plumbing (hidden without VAPID, validated with
* it). * it).
*/ */
import { mkdirSync, readFileSync } from "node:fs"; import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import path from "node:path"; import path from "node:path";
@ -12,6 +12,7 @@ import {
BASE, BASE,
DECK, DECK,
REPO, REPO,
dueSrs,
launchBrowser, launchBrowser,
makeChecker, makeChecker,
makePage, makePage,
@ -22,6 +23,7 @@ import {
spawnApp, spawnApp,
waitForHttp, waitForHttp,
} from "./lib.mjs"; } from "./lib.mjs";
import { reminderPayload } from "../scripts/reminder-payload.mjs";
const { check, finish } = makeChecker(); const { check, finish } = makeChecker();
@ -498,7 +500,11 @@ const browser = await launchBrowser();
{ {
check("push: port 3209 is free", !(await portInUse(3209))); 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 vapid = await new Promise((resolve) => {
const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO }); const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO });
let output = ""; let output = "";
@ -550,7 +556,11 @@ const browser = await launchBrowser();
}, subscription); }, subscription);
check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body)); 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")); 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 badBody = await page.evaluate(async () => {
const response = await fetch("/api/push", { const response = await fetch("/api/push", {
@ -562,6 +572,16 @@ const browser = await launchBrowser();
}); });
check("push: invalid subscription → 400", badBody === 400, String(badBody)); 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 testSend = await page.evaluate(async (sub) => {
const response = await fetch("/api/push/test", { const response = await fetch("/api/push/test", {
method: "POST", method: "POST",
@ -581,6 +601,119 @@ const browser = await launchBrowser();
}, subscription); }, subscription);
check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body)); check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body));
await context.close(); 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 { } finally {
app.stop(); app.stop();