feat(notifications): personal-records push opt-in toggle (default OFF)

Adds the PERSONAL_RECORD event type to the dispatcher vocabulary
and flips its default policy to opt-in. Without an explicit
preference row the dispatcher now suppresses PR pushes — so a
multi-year Apple Health backfill writes the records but does not
saturate the lock-screen on first sync. Other events keep the
opt-out default they always had.

The /notifications matrix renders the new event automatically
because it iterates EVENT_TYPES; the per-event default helper
keeps the toggle in the right initial state across all six
locales (EN, DE, FR, ES, IT, PL).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:51:05 +02:00
1 parent 7b7a896c44
commit 15a72bb329
10 files changed
+313 -14

No files matched your search

+31 -1
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
"glp1LogSideEffect": "Nebenwirkung loggen",
"inventory": {
"title": "Bestand",
"addPen": "Neuen Pen erfassen",
"addPenTitle": "Neuen Pen erfassen",
"loading": "Bestand wird geladen…",
"empty": "Noch keine Pens erfasst. Erfasse den nächsten, um die 30-Tage-Frist zu starten.",
"dosesLabel": "Dosen",
"printedExpiry": "Verfallsdatum auf der Schachtel: {date}",
"purchasedAt": "Gekauft am {date}",
"pastTitle": "Frühere Pens ({count})",
"markAsInUse": "Als angebrochen markieren",
"markAsUsedUp": "Als verbraucht markieren",
"delete": "Löschen",
"cancel": "Abbrechen",
"save": "Speichern",
"fieldDosesTotal": "Dosen pro Pen",
"fieldDosesTotalHelp": "Anzahl der Dosen, die dieser Pen enthält. Mounjaro-KwikPen = 4, Trulicity Single-Dose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Aufgedrucktes Verfallsdatum",
"fieldPurchasedAt": "Kaufdatum",
"fieldNotes": "Notiz (optional)",
"errorDosesTotal": "Dosen pro Pen müssen zwischen 1 und 100 liegen",
"state": {
"active": "Im Kühlschrank",
"inUse": "In Verwendung · {days} Tage übrig",
"expired": "Abgelaufen",
"usedUp": "Verbraucht"
}
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"siteAbdomenLeft": "Bauch, unten links",
@@ -2792,7 +2820,9 @@
"eventWithingsSyncFailed": "Withings-Sync-Fehler",
"eventWithingsSyncFailedDesc": "Meldung bei Fehlern beim automatischen Withings-Datenabgleich.",
"eventSystemAlert": "System-Hinweise",
"eventSystemAlertDesc": "Wichtige Systemnachrichten und administrative Hinweise."
"eventSystemAlertDesc": "Wichtige Systemnachrichten und administrative Hinweise.",
"eventPersonalRecord": "Persönlicher Rekord",
"eventPersonalRecordDesc": "Push bei einem neuen Bestwert für eine erfasste Kennzahl oder ein Training. Standardmäßig aus."
},
"format": {
"dateShort": "{day}.{month}.{year}",
+31 -1
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventory",
"addPen": "Add new pen",
"addPenTitle": "Register new pen",
"loading": "Loading inventory…",
"empty": "No pens tracked yet. Add the next one to start the 30-day in-use clock.",
"dosesLabel": "doses",
"printedExpiry": "Carton expiry {date}",
"purchasedAt": "Bought {date}",
"pastTitle": "Past pens ({count})",
"markAsInUse": "Mark in use",
"markAsUsedUp": "Mark used up",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"fieldDosesTotal": "Doses per pen",
"fieldDosesTotalHelp": "Number of doses this pen ships with. Mounjaro KwikPen = 4, Trulicity single-dose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Printed expiry date",
"fieldPurchasedAt": "Date purchased",
"fieldNotes": "Notes (optional)",
"errorDosesTotal": "Doses per pen must be between 1 and 100",
"state": {
"active": "Refrigerated",
"inUse": "In use · {days} days left",
"expired": "Expired",
"usedUp": "Used up"
}
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -2792,7 +2820,9 @@
"eventWithingsSyncFailed": "Withings Sync Failed",
"eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.",
"eventSystemAlert": "System Alert",
"eventSystemAlertDesc": "Important system messages and administrative notices."
"eventSystemAlertDesc": "Important system messages and administrative notices.",
"eventPersonalRecord": "Personal Record",
"eventPersonalRecordDesc": "Push when an all-time best is reached for a tracked metric or workout. Off by default."
},
"format": {
"dateShort": "{month}/{day}/{year}",
+32 -2
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventario",
"addPen": "Añadir nueva pluma",
"addPenTitle": "Registrar nueva pluma",
"loading": "Cargando inventario…",
"empty": "Aún no hay plumas registradas. Añade la siguiente para iniciar el contador de 30 días.",
"dosesLabel": "dosis",
"printedExpiry": "Caducidad de la caja: {date}",
"purchasedAt": "Comprado el {date}",
"pastTitle": "Plumas anteriores ({count})",
"markAsInUse": "Marcar como abierta",
"markAsUsedUp": "Marcar como agotada",
"delete": "Eliminar",
"cancel": "Cancelar",
"save": "Guardar",
"fieldDosesTotal": "Dosis por pluma",
"fieldDosesTotalHelp": "Número de dosis que contiene la pluma. KwikPen de Mounjaro = 4, Trulicity dosis única = 1, Byetta = 60.",
"fieldPrintedExpiry": "Fecha de caducidad impresa",
"fieldPurchasedAt": "Fecha de compra",
"fieldNotes": "Nota (opcional)",
"errorDosesTotal": "Las dosis por pluma deben estar entre 1 y 100",
"state": {
"active": "Refrigerada",
"inUse": "En uso · {days} días restantes",
"expired": "Caducada",
"usedUp": "Agotada"
}
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -2791,8 +2819,10 @@
"eventComplianceLowDesc": "Notification when medication compliance drops below a threshold.",
"eventWithingsSyncFailed": "Withings Sync Failed",
"eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.",
"eventSystemAlert": "System Alert",
"eventSystemAlertDesc": "Important system messages and administrative notices."
"eventSystemAlert": "Alerta del sistema",
"eventSystemAlertDesc": "Mensajes importantes del sistema y avisos administrativos.",
"eventPersonalRecord": "Récord personal",
"eventPersonalRecordDesc": "Notificación cuando se alcanza un nuevo récord en una métrica o entrenamiento. Desactivado por defecto."
},
"format": {
"dateShort": "{month}/{day}/{year}",
+32 -2
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventaire",
"addPen": "Ajouter un nouveau stylo",
"addPenTitle": "Enregistrer un nouveau stylo",
"loading": "Chargement de l'inventaire…",
"empty": "Aucun stylo suivi. Ajoutez le prochain pour démarrer le compte à rebours de 30 jours.",
"dosesLabel": "doses",
"printedExpiry": "Péremption sur la boîte : {date}",
"purchasedAt": "Acheté le {date}",
"pastTitle": "Anciens stylos ({count})",
"markAsInUse": "Marquer comme entamé",
"markAsUsedUp": "Marquer comme épuisé",
"delete": "Supprimer",
"cancel": "Annuler",
"save": "Enregistrer",
"fieldDosesTotal": "Doses par stylo",
"fieldDosesTotalHelp": "Nombre de doses contenues dans ce stylo. KwikPen Mounjaro = 4, Trulicity dose unique = 1, Byetta = 60.",
"fieldPrintedExpiry": "Date de péremption imprimée",
"fieldPurchasedAt": "Date d'achat",
"fieldNotes": "Note (facultatif)",
"errorDosesTotal": "Le nombre de doses par stylo doit être compris entre 1 et 100",
"state": {
"active": "Au réfrigérateur",
"inUse": "En cours · {days} jours restants",
"expired": "Périmé",
"usedUp": "Épuisé"
}
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -2791,8 +2819,10 @@
"eventComplianceLowDesc": "Notification when medication compliance drops below a threshold.",
"eventWithingsSyncFailed": "Withings Sync Failed",
"eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.",
"eventSystemAlert": "System Alert",
"eventSystemAlertDesc": "Important system messages and administrative notices."
"eventSystemAlert": "Alerte système",
"eventSystemAlertDesc": "Messages système importants et avis administratifs.",
"eventPersonalRecord": "Record personnel",
"eventPersonalRecordDesc": "Notification lorsqu'un nouveau record est atteint pour une mesure ou un entraînement. Désactivé par défaut."
},
"format": {
"dateShort": "{month}/{day}/{year}",
+32 -2
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Inventario",
"addPen": "Aggiungi nuova penna",
"addPenTitle": "Registra nuova penna",
"loading": "Caricamento inventario…",
"empty": "Nessuna penna registrata. Aggiungi la prossima per avviare il conto alla rovescia di 30 giorni.",
"dosesLabel": "dosi",
"printedExpiry": "Scadenza sulla scatola: {date}",
"purchasedAt": "Acquistata il {date}",
"pastTitle": "Penne precedenti ({count})",
"markAsInUse": "Segna come in uso",
"markAsUsedUp": "Segna come esaurita",
"delete": "Elimina",
"cancel": "Annulla",
"save": "Salva",
"fieldDosesTotal": "Dosi per penna",
"fieldDosesTotalHelp": "Numero di dosi contenute nella penna. KwikPen Mounjaro = 4, Trulicity monodose = 1, Byetta = 60.",
"fieldPrintedExpiry": "Data di scadenza stampata",
"fieldPurchasedAt": "Data di acquisto",
"fieldNotes": "Nota (facoltativo)",
"errorDosesTotal": "Le dosi per penna devono essere comprese tra 1 e 100",
"state": {
"active": "In frigorifero",
"inUse": "In uso · {days} giorni rimasti",
"expired": "Scaduta",
"usedUp": "Esaurita"
}
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -2791,8 +2819,10 @@
"eventComplianceLowDesc": "Notification when medication compliance drops below a threshold.",
"eventWithingsSyncFailed": "Withings Sync Failed",
"eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.",
"eventSystemAlert": "System Alert",
"eventSystemAlertDesc": "Important system messages and administrative notices."
"eventSystemAlert": "Avviso di sistema",
"eventSystemAlertDesc": "Messaggi di sistema importanti e avvisi amministrativi.",
"eventPersonalRecord": "Record personale",
"eventPersonalRecordDesc": "Notifica quando viene raggiunto un nuovo record per una metrica o un allenamento. Disattivato per impostazione predefinita."
},
"format": {
"dateShort": "{month}/{day}/{year}",
+32 -2
View File
@@ -583,6 +583,34 @@
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"inventory": {
"title": "Magazyn",
"addPen": "Dodaj nowy wstrzykiwacz",
"addPenTitle": "Zarejestruj nowy wstrzykiwacz",
"loading": "Ładowanie magazynu…",
"empty": "Brak wstrzykiwaczy w magazynie. Dodaj następny, aby uruchomić 30-dniowy licznik.",
"dosesLabel": "dawki",
"printedExpiry": "Data ważności na opakowaniu: {date}",
"purchasedAt": "Zakupiono {date}",
"pastTitle": "Wcześniejsze wstrzykiwacze ({count})",
"markAsInUse": "Oznacz jako otwarty",
"markAsUsedUp": "Oznacz jako zużyty",
"delete": "Usuń",
"cancel": "Anuluj",
"save": "Zapisz",
"fieldDosesTotal": "Dawki na wstrzykiwacz",
"fieldDosesTotalHelp": "Liczba dawek w jednym wstrzykiwaczu. Mounjaro KwikPen = 4, Trulicity jednodawkowy = 1, Byetta = 60.",
"fieldPrintedExpiry": "Nadrukowana data ważności",
"fieldPurchasedAt": "Data zakupu",
"fieldNotes": "Notatka (opcjonalnie)",
"errorDosesTotal": "Liczba dawek na wstrzykiwacz musi mieścić się w zakresie 1–100",
"state": {
"active": "W lodówce",
"inUse": "W użyciu · pozostało {days} dni",
"expired": "Wygasł",
"usedUp": "Zużyty"
}
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -2791,8 +2819,10 @@
"eventComplianceLowDesc": "Notification when medication compliance drops below a threshold.",
"eventWithingsSyncFailed": "Withings Sync Failed",
"eventWithingsSyncFailedDesc": "Alert when the automatic Withings data sync fails.",
"eventSystemAlert": "System Alert",
"eventSystemAlertDesc": "Important system messages and administrative notices."
"eventSystemAlert": "Alert systemowy",
"eventSystemAlertDesc": "Ważne komunikaty systemowe i powiadomienia administracyjne.",
"eventPersonalRecord": "Rekord osobisty",
"eventPersonalRecordDesc": "Powiadomienie po osiągnięciu nowego rekordu w metryce lub treningu. Domyślnie wyłączone."
},
"format": {
"dateShort": "{month}/{day}/{year}",
+9 -2
View File
@@ -8,6 +8,10 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
interface ChannelInfo {
id: string;
@@ -115,8 +119,11 @@ export default function NotificationsPage() {
const pref = data?.preferences?.find(
(p) => p.channelId === channelId && p.eventType === eventType,
);
// Opt-out model: default is ON when no preference row exists
return pref ? pref.enabled : true;
if (pref) return pref.enabled;
// v1.4.25 W16c — per-event default. Most events stay opt-out
// (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF)
// so a backfill doesn't fire hundreds of pushes on first sync.
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
}
if (authLoading || isLoading) {
@@ -309,6 +309,80 @@ describe("dispatchNotification — cooldown skip", () => {
});
});
describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", () => {
it("PERSONAL_RECORD defaults OFF when no preference row exists", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
await dispatchNotification({
eventType: "PERSONAL_RECORD",
userId: "u-1",
title: "PR",
message: "Best ever",
});
// No row → opt-in for this event type → sender NOT called.
expect(sendViaNtfyMock).not.toHaveBeenCalled();
});
it("PERSONAL_RECORD fires when the user has explicitly opted in", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [{ eventType: "PERSONAL_RECORD", enabled: true }],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
await dispatchNotification({
eventType: "PERSONAL_RECORD",
userId: "u-1",
title: "PR",
message: "Best ever",
});
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
});
it("legacy events (MEDICATION_REMINDER) still default ON when no row exists", async () => {
const channel = makeChannel({
type: "NTFY",
config: JSON.stringify({
serverUrl: "https://ntfy.example",
topic: "t",
}),
preferences: [],
});
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
channel,
] as never);
sendViaNtfyMock.mockResolvedValueOnce({ ok: true });
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
userId: "u-1",
title: "Reminder",
message: "Take it",
});
expect(sendViaNtfyMock).toHaveBeenCalledTimes(1);
});
});
describe("dispatchNotification — Telegram hard reject", () => {
it("auto-disables on 'chat not found'", async () => {
const channel = makeChannel({
+15 -2
View File
@@ -5,7 +5,9 @@ import type {
TelegramChannelConfig,
NtfyChannelConfig,
ChannelType,
EventType,
} from "@/lib/notifications/types";
import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
@@ -113,10 +115,21 @@ export async function dispatchNotification(
channels.sort((a, b) => channelPriority(a.type) - channelPriority(b.type));
const now = new Date();
// v1.4.25 W16c — per-event default policy. Most events stay
// opt-out (no row = enabled); PERSONAL_RECORD flips to opt-in
// (no row = disabled) so the iOS backfill doesn't saturate the
// lock-screen on first launch. The user enables it explicitly
// from /settings/notifications once they've seen the badge a few
// times and decided they want push.
const defaultEnabled =
EVENT_DEFAULT_ENABLED[payload.eventType as EventType] ?? true;
for (const channel of channels) {
// Opt-out model: if no preference row exists, default to enabled
const pref = channel.preferences[0];
if (pref && !pref.enabled) continue;
if (pref) {
if (!pref.enabled) continue;
} else if (!defaultEnabled) {
continue;
}
// Backoff cooldown — skip the channel until `nextRetryAt`. Without
// this guard, a flapping upstream would burn API quota every
+25
View File
@@ -13,6 +13,10 @@ export const EVENT_TYPES = [
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"SYSTEM_ALERT",
// v1.4.25 W16c — PR detection. Default OFF (see DEFAULT_EVENT_PREFS
// below) so a multi-year Apple Health backfill doesn't fire hundreds
// of pushes during initial sync.
"PERSONAL_RECORD",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -22,6 +26,27 @@ export const EVENT_TYPE_LABELS: Record<EventType, string> = {
COMPLIANCE_LOW: "Low compliance",
WITHINGS_SYNC_FAILED: "Withings sync failed",
SYSTEM_ALERT: "System alerts",
PERSONAL_RECORD: "Personal records",
};
/**
* Per-event default policy when no `NotificationPreference` row
* exists. The dispatcher reads this to decide whether to fire on a
* channel that has never been explicitly toggled for the event.
*
* Every event defaults to ON (the original opt-out model) EXCEPT
* `PERSONAL_RECORD`: PRs are silent by default and require the user
* to explicitly enable them in `/settings/notifications`. Rationale —
* a fresh iOS install backfilling years of HealthKit history would
* otherwise saturate the user's lock-screen on first launch.
*/
export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
MEDICATION_REMINDER: true,
MEASUREMENT_ANOMALY: true,
COMPLIANCE_LOW: true,
WITHINGS_SYNC_FAILED: true,
SYSTEM_ALERT: true,
PERSONAL_RECORD: false,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {