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:
parent
6036238bf0
commit
77819b5961
18 changed files with 498 additions and 49 deletions
36
scripts/reminder-payload.mjs
Normal file
36
scripts/reminder-payload.mjs
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue