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.
74 lines
2.4 KiB
TypeScript
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;
|
|
}
|