From 15a72bb329080afa3d057ac96a97e73a106e8e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:51:05 +0200 Subject: [PATCH] feat(notifications): personal-records push opt-in toggle (default OFF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- messages/de.json | 32 +++++++- messages/en.json | 32 +++++++- messages/es.json | 34 ++++++++- messages/fr.json | 34 ++++++++- messages/it.json | 34 ++++++++- messages/pl.json | 34 ++++++++- src/app/notifications/page.tsx | 11 ++- .../__tests__/dispatcher.test.ts | 74 +++++++++++++++++++ src/lib/notifications/dispatcher.ts | 17 ++++- src/lib/notifications/types.ts | 25 +++++++ 10 files changed, 313 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index 04ac97c51..3567a4bd5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}", diff --git a/messages/en.json b/messages/en.json index ccac34260..295cb1e44 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/messages/es.json b/messages/es.json index e78a4972e..34082cf78 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}", diff --git a/messages/fr.json b/messages/fr.json index 46b7cde40..abdc8e126 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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}", diff --git a/messages/it.json b/messages/it.json index 2c0310cd3..b3a10a931 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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}", diff --git a/messages/pl.json b/messages/pl.json index a5fa4f664..0a2cda780 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}", diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 5174278ac..0ac110b2e 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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) { diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 685dd82d7..ef5983257 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -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({ diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 08691916d..07d07198f 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -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 diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a44522ee6..749a37d0e 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -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 = { 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 = { + 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 = {