freezmo/components/PushReminderSync.tsx
Martin Dvorak 77819b5961 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.
2026-10-18 14:34:15 +00:00

74 lines
2.4 KiB
TypeScript

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