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
60
lib/reminder.ts
Normal file
60
lib/reminder.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue