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
74
components/PushReminderSync.tsx
Normal file
74
components/PushReminderSync.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue