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.
60 lines
2.3 KiB
TypeScript
60 lines
2.3 KiB
TypeScript
"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();
|
|
}
|
|
}
|