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

@ -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 <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. |
| “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
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`).