From 8057bb20bc87036b0ee96899e82dae9d00231df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:45:36 +0100 Subject: [PATCH 1/5] fix(notifications): drop the four switches nothing sends The notification matrix listed "Unusual measurement", "Low adherence", "Withings sync failed" and "Personal record" with a switch per channel, three of them on by default, yet nothing dispatches any of them: a reading far outside its range or a week of missed doses produced no message. eventTypesForRole now leaves them out for every role, next to the admin-only filter, and the preferences read no longer serves rows stored for them. Their labels in all locales and their tap targets are deleted. The types stay in EVENT_TYPES, so the preference write and the stored rows keep parsing; no migration. The label and tap-target tests walked EVENT_TYPES and so pinned the four in place; they now walk the event types that are sent. --- messages/de.json | 8 --- messages/en.json | 8 --- messages/es.json | 8 --- messages/fr.json | 8 --- messages/it.json | 8 --- messages/ko.json | 8 --- messages/nl.json | 8 --- messages/pl.json | 8 --- messages/pt-BR.json | 8 --- messages/ru.json | 8 --- .../baselines/version-stamp-comments.json | 2 +- .../api/notifications/preferences/route.ts | 21 ++++--- src/app/notifications/__tests__/page.test.tsx | 8 +-- .../__tests__/event-labels.test.ts | 34 +++++++++-- .../__tests__/event-types-for-role.test.ts | 40 +++++++++++++ .../__tests__/tap-target.test.ts | 33 ++++++----- src/lib/notifications/tap-target.ts | 23 ++++---- src/lib/notifications/types.ts | 56 +++++++++++++------ src/lib/openapi/routes/notifications.ts | 4 +- .../notification-event-defaults.test.ts | 46 +++++++++++++++ 20 files changed, 207 insertions(+), 140 deletions(-) create mode 100644 src/lib/notifications/__tests__/event-types-for-role.test.ts diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..f4f2254d2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Medikamenten-Erinnerung", "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", - "eventMeasurementAnomaly": "Auffälliger Messwert", - "eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.", - "eventComplianceLow": "Niedrige Therapietreue", - "eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.", - "eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen", - "eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.", "eventSystemAlert": "Systemmeldung", "eventSystemAlertDesc": "Allgemeine Mitteilungen vom HealthLog-Server, etwa Wartungsfenster oder Konto-Hinweise.", - "eventPersonalRecord": "Persönlicher Rekord", - "eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.", "eventMoodReminder": "Stimmungs-Erinnerung", "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.", "eventCyclePeriodSoon": "Periode steht bevor", diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..891a3d113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Medication reminder", "eventMedicationReminderDesc": "Sent at each scheduled dose time.", - "eventMeasurementAnomaly": "Unusual measurement", - "eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.", - "eventComplianceLow": "Low adherence", - "eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.", - "eventWithingsSyncFailed": "Withings sync failed", - "eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.", "eventSystemAlert": "System notice", "eventSystemAlertDesc": "General messages from the HealthLog server, e.g. maintenance windows or account notes.", - "eventPersonalRecord": "Personal record", - "eventPersonalRecordDesc": "Sent when a reading beats your previous best.", "eventMoodReminder": "Mood reminder", "eventMoodReminderDesc": "Daily nudge to log your current mood.", "eventCyclePeriodSoon": "Period coming up", diff --git a/messages/es.json b/messages/es.json index a2b612bad..ba52d239e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Recordatorio de medicación", "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", - "eventMeasurementAnomaly": "Medición inusual", - "eventMeasurementAnomalyDesc": "Se envía cuando una lectura está claramente fuera de tu rango habitual.", - "eventComplianceLow": "Cumplimiento bajo", - "eventComplianceLowDesc": "Se envía cuando tu tasa de toma de los últimos días baja del 80 %.", - "eventWithingsSyncFailed": "Error de sincronización con Withings", - "eventWithingsSyncFailedDesc": "Se envía cuando la sincronización con Withings falla varios intentos seguidos.", "eventSystemAlert": "Aviso del sistema", "eventSystemAlertDesc": "Mensajes generales del servidor HealthLog, por ejemplo ventanas de mantenimiento o avisos de cuenta.", - "eventPersonalRecord": "Récord personal", - "eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.", "eventMoodReminder": "Recordatorio de ánimo", "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.", "eventCyclePeriodSoon": "Período próximo", diff --git a/messages/fr.json b/messages/fr.json index af97df036..e9de10274 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Rappel de médicament", "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", - "eventMeasurementAnomaly": "Mesure inhabituelle", - "eventMeasurementAnomalyDesc": "Envoyé quand une valeur sort nettement de votre plage habituelle.", - "eventComplianceLow": "Observance faible", - "eventComplianceLowDesc": "Envoyé quand votre taux de prise des derniers jours passe sous les 80 %.", - "eventWithingsSyncFailed": "Échec de la synchronisation Withings", - "eventWithingsSyncFailedDesc": "Envoyé quand la synchronisation Withings échoue plusieurs fois d'affilée.", "eventSystemAlert": "Notification système", "eventSystemAlertDesc": "Messages généraux du serveur HealthLog, par exemple maintenance ou avis de compte.", - "eventPersonalRecord": "Record personnel", - "eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.", "eventMoodReminder": "Rappel d'humeur", "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.", "eventCyclePeriodSoon": "Règles à venir", diff --git a/messages/it.json b/messages/it.json index fed51449c..8c812c639 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Promemoria farmaco", "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", - "eventMeasurementAnomaly": "Misurazione insolita", - "eventMeasurementAnomalyDesc": "Inviato quando un valore esce nettamente dal tuo intervallo abituale.", - "eventComplianceLow": "Aderenza bassa", - "eventComplianceLowDesc": "Inviato quando il tuo tasso di assunzione degli ultimi giorni scende sotto l'80 %.", - "eventWithingsSyncFailed": "Sincronizzazione Withings non riuscita", - "eventWithingsSyncFailedDesc": "Inviato quando la sincronizzazione Withings fallisce per più tentativi consecutivi.", "eventSystemAlert": "Avviso di sistema", "eventSystemAlertDesc": "Messaggi generali dal server HealthLog, ad esempio finestre di manutenzione o note sull'account.", - "eventPersonalRecord": "Record personale", - "eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.", "eventMoodReminder": "Promemoria umore", "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.", "eventCyclePeriodSoon": "Ciclo in arrivo", diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..9c3a66065 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "복약 알림", "eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.", - "eventMeasurementAnomaly": "이상 측정값", - "eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.", - "eventComplianceLow": "낮은 복약 순응도", - "eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.", - "eventWithingsSyncFailed": "Withings 동기화 실패", - "eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.", "eventSystemAlert": "시스템 공지", "eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.", - "eventPersonalRecord": "개인 기록", - "eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.", "eventMoodReminder": "기분 알림", "eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.", "eventCyclePeriodSoon": "생리 예정", diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..e8140cc87 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Medicatieherinnering", "eventMedicationReminderDesc": "Wordt verstuurd op elk gepland innamemoment.", - "eventMeasurementAnomaly": "Ongebruikelijke meting", - "eventMeasurementAnomalyDesc": "Wordt verstuurd als een meetwaarde ver buiten je gebruikelijke bereik ligt.", - "eventComplianceLow": "Lage therapietrouw", - "eventComplianceLowDesc": "Wordt verstuurd als je therapietrouw over de afgelopen dagen onder 80% zakt.", - "eventWithingsSyncFailed": "Withings-synchronisatie mislukt", - "eventWithingsSyncFailedDesc": "Wordt verstuurd als de Withings-synchronisatie meerdere keren achter elkaar mislukt.", "eventSystemAlert": "Systeembericht", "eventSystemAlertDesc": "Algemene berichten van de HealthLog-server, bijv. onderhoudsvensters of opmerkingen over je account.", - "eventPersonalRecord": "Persoonlijk record", - "eventPersonalRecordDesc": "Wordt verstuurd als een meetwaarde je vorige beste overtreft.", "eventMoodReminder": "Stemmingsherinnering", "eventMoodReminderDesc": "Dagelijks seintje om je huidige stemming vast te leggen.", "eventCyclePeriodSoon": "Menstruatie op komst", diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..123971e26 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Przypomnienie o leku", "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", - "eventMeasurementAnomaly": "Nietypowy pomiar", - "eventMeasurementAnomalyDesc": "Wysyłane, gdy odczyt znacznie odbiega od Twojego zwykłego zakresu.", - "eventComplianceLow": "Niska zgodność", - "eventComplianceLowDesc": "Wysyłane, gdy odsetek przyjętych dawek z ostatnich dni spadnie poniżej 80 %.", - "eventWithingsSyncFailed": "Synchronizacja Withings nie powiodła się", - "eventWithingsSyncFailedDesc": "Wysyłane, gdy synchronizacja Withings nie powiedzie się kilka razy z rzędu.", "eventSystemAlert": "Powiadomienie systemowe", "eventSystemAlertDesc": "Ogólne komunikaty z serwera HealthLog, na przykład okna serwisowe lub uwagi do konta.", - "eventPersonalRecord": "Rekord osobisty", - "eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.", "eventMoodReminder": "Przypomnienie o nastroju", "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.", "eventCyclePeriodSoon": "Zbliża się okres", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..ea15f0429 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Lembrete de medicamento", "eventMedicationReminderDesc": "Enviado em cada horário programado de dose.", - "eventMeasurementAnomaly": "Medição incomum", - "eventMeasurementAnomalyDesc": "Enviado quando uma leitura está bem fora da sua faixa habitual.", - "eventComplianceLow": "Adesão baixa", - "eventComplianceLowDesc": "Enviado quando sua adesão nos últimos dias cai abaixo de 80%.", - "eventWithingsSyncFailed": "Falha na sincronização do Withings", - "eventWithingsSyncFailedDesc": "Enviado quando a sincronização do Withings falha várias tentativas seguidas.", "eventSystemAlert": "Aviso do sistema", "eventSystemAlertDesc": "Mensagens gerais do servidor do HealthLog, como janelas de manutenção ou notas da conta.", - "eventPersonalRecord": "Recorde pessoal", - "eventPersonalRecordDesc": "Enviado quando uma leitura supera seu recorde anterior.", "eventMoodReminder": "Lembrete de humor", "eventMoodReminderDesc": "Lembrete diário para registrar seu humor atual.", "eventCyclePeriodSoon": "Menstruação se aproximando", diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..6ca4cdc82 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -9314,16 +9314,8 @@ }, "eventMedicationReminder": "Напоминание о лекарстве", "eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.", - "eventMeasurementAnomaly": "Необычное измерение", - "eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.", - "eventComplianceLow": "Низкая приверженность", - "eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.", - "eventWithingsSyncFailed": "Сбой синхронизации Withings", - "eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.", "eventSystemAlert": "Системное уведомление", "eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.", - "eventPersonalRecord": "Личный рекорд", - "eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.", "eventMoodReminder": "Напоминание о настроении", "eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.", "eventCyclePeriodSoon": "Скоро менструация", diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..dd511fc2a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2606,7 +2606,7 @@ "lib/notifications/senders/telegram.ts": 4, "lib/notifications/senders/web-push.ts": 5, "lib/notifications/senders/webhook.ts": 1, - "lib/notifications/types.ts": 21, + "lib/notifications/types.ts": 20, "lib/notifications/web-push-clear.ts": 1, "lib/nutrients/__tests__/catalog.test.ts": 1, "lib/nutrients/catalog.ts": 8, diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 2a05c0a13..9d4518230 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -84,20 +84,25 @@ export const GET = apiHandler(async () => { globallyEnabled: globalToggles[ch.type] ?? true, })); + // Admin-only events (backup health) are listed for admins only, and an + // event nothing sends is listed for nobody: a switch for either would + // switch nothing. A stored row for an unlisted event stays in the table and + // is not served. + const eventTypes: string[] = eventTypesForRole(user.role); const preferences = channels.flatMap((ch) => - ch.preferences.map((p) => ({ - channelId: p.channelId, - eventType: p.eventType, - enabled: p.enabled, - })), + ch.preferences + .filter((p) => eventTypes.includes(p.eventType)) + .map((p) => ({ + channelId: p.channelId, + eventType: p.eventType, + enabled: p.enabled, + })), ); return apiSuccess({ channels: channelData, preferences, - // Admin-only events (backup health) are listed for admins only: nobody - // else is ever sent one, so a switch for it would switch nothing. - eventTypes: eventTypesForRole(user.role), + eventTypes, // What an event does on a channel with no stored row, resolved for this // account by the same function the dispatcher asks. A client renders a // switch without a row from this map, never from its own copy of the diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx index 83b3160cd..14017af1f 100644 --- a/src/app/notifications/__tests__/page.test.tsx +++ b/src/app/notifications/__tests__/page.test.tsx @@ -143,8 +143,8 @@ describe("NotificationsPage", () => { }, ], preferences: [], - eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"], - eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false }, + eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"], + eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false }, }; const html = render(); const state = (label: string) => @@ -152,10 +152,10 @@ describe("NotificationsPage", () => { `aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`, ).exec(html); const mood = state("Mood reminder"); - const record = state("Personal record"); + const briefing = state("Daily briefing"); expect(mood, "mood switch rendered").not.toBeNull(); expect(mood?.[1] ?? mood?.[2]).toBe("checked"); - expect(record?.[1] ?? record?.[2]).toBe("unchecked"); + expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked"); }); it("a stored row still wins over the default", () => { diff --git a/src/lib/notifications/__tests__/event-labels.test.ts b/src/lib/notifications/__tests__/event-labels.test.ts index ff2464768..b0ea9bd5a 100644 --- a/src/lib/notifications/__tests__/event-labels.test.ts +++ b/src/lib/notifications/__tests__/event-labels.test.ts @@ -1,7 +1,8 @@ /** * Every event type the /notifications matrix renders has words to render. * - * The matrix builds its rows from `EVENT_TYPES` and derives each row's label + * The matrix builds its rows from the event list the preferences read serves + * (`eventTypesForRole`) and derives each row's label * and description key from the event name, so a new event type silently gets a * row whether or not anybody wrote the two strings for it. That is not a * theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key @@ -20,7 +21,10 @@ import { describe, expect, it } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { EVENT_TYPES } from "@/lib/notifications/types"; +import { + UNSENT_EVENT_TYPES, + eventTypesForRole, +} from "@/lib/notifications/types"; import { eventDescriptionTranslationKey, eventTranslationKey, @@ -60,14 +64,16 @@ describe("notification event labels", () => { ); }); - it("has a label and a description for every event type, in every locale", () => { + it("has a label and a description for every listed event type, in every locale", () => { expect(LOCALES.length).toBeGreaterThanOrEqual(6); - expect(EVENT_TYPES.length).toBeGreaterThan(10); + // ADMIN sees the widest list: every event that has a switch at all. + const listed = eventTypesForRole("ADMIN"); + expect(listed.length).toBeGreaterThan(10); const missing: string[] = []; for (const locale of LOCALES) { const root = bundle(locale); - for (const eventType of EVENT_TYPES) { + for (const eventType of listed) { for (const key of [ eventTranslationKey(eventType), eventDescriptionTranslationKey(eventType), @@ -82,6 +88,24 @@ describe("notification event labels", () => { expect(missing).toEqual([]); }); + + it("keeps no label for an event nothing sends", () => { + const leftover: string[] = []; + for (const locale of LOCALES) { + const root = bundle(locale); + for (const eventType of UNSENT_EVENT_TYPES) { + for (const key of [ + eventTranslationKey(eventType), + eventDescriptionTranslationKey(eventType), + ]) { + if (resolve(root, key) !== undefined) { + leftover.push(`${locale} still has ${key}`); + } + } + } + } + expect(leftover).toEqual([]); + }); }); describe("the delegated-dose push copy", () => { diff --git a/src/lib/notifications/__tests__/event-types-for-role.test.ts b/src/lib/notifications/__tests__/event-types-for-role.test.ts new file mode 100644 index 000000000..9c0ea467f --- /dev/null +++ b/src/lib/notifications/__tests__/event-types-for-role.test.ts @@ -0,0 +1,40 @@ +/** + * The event types a person is shown a switch for. + * + * The preferences read lists `eventTypesForRole(role)` as the matrix rows and + * serves stored rows only for those. An event nothing sends is listed for + * nobody; backup health for admins only. + */ +import { describe, expect, it } from "vitest"; + +import { + EVENT_TYPES, + UNSENT_EVENT_TYPES, + eventTypesForRole, + isSentEventType, +} from "@/lib/notifications/types"; + +describe("eventTypesForRole", () => { + it.each(["USER", "ADMIN"])("lists no event nothing sends (%s)", (role) => { + const listed: string[] = eventTypesForRole(role); + expect(listed).toContain("MEDICATION_REMINDER"); + for (const unsent of UNSENT_EVENT_TYPES) { + expect(listed).not.toContain(unsent); + } + }); + + it("lists backup health for an admin only", () => { + expect(eventTypesForRole("USER")).not.toContain("BACKUP_HEALTH"); + expect(eventTypesForRole("ADMIN")).toContain("BACKUP_HEALTH"); + }); + + it("keeps the declaration order", () => { + expect(eventTypesForRole("ADMIN")).toEqual( + EVENT_TYPES.filter((type) => isSentEventType(type)), + ); + }); + + it("knows no event outside the union", () => { + expect(isSentEventType("NOT_AN_EVENT")).toBe(false); + }); +}); diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts index 258a4edce..31b903d67 100644 --- a/src/lib/notifications/__tests__/tap-target.test.ts +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -1,9 +1,9 @@ /** * The event → tap-target map and its resolver. * - * Walks `EVENT_TYPES` rather than the map's own keys, so an event type added - * to the union without a decision about where it leads fails here (the - * `Record` type already refuses to compile without one; this + * Walks the sent event types rather than the map's own keys, so an event type + * added to the union without a decision about where it leads fails here (the + * `Record` type already refuses to compile without one; this * pins that every entry is also a usable path). */ import { afterEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +19,8 @@ import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy"; import { buildWebhookBody } from "@/lib/notifications/senders/webhook"; import { EVENT_TYPES, + UNSENT_EVENT_TYPES, + isSentEventType, type EventType, type NotificationPayload, } from "@/lib/notifications/types"; @@ -35,6 +37,8 @@ import { /** Events never shown to the user, so they carry no target. */ const SILENT: ReadonlySet = new Set(["MEDICATION_INTAKE_SYNC"]); +const SENT = EVENT_TYPES.filter(isSentEventType); + const ORIGINAL = { APP_URL: process.env.APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, @@ -48,9 +52,9 @@ afterEach(() => { }); describe("NOTIFICATION_TAP_PATHS", () => { - it("names a target for every event type", () => { - expect(EVENT_TYPES.length).toBeGreaterThan(0); - for (const eventType of EVENT_TYPES) { + it("names a target for every sent event type", () => { + expect(SENT.length).toBeGreaterThan(0); + for (const eventType of SENT) { expect( Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType), `${eventType} has no tap target`, @@ -68,17 +72,20 @@ describe("NOTIFICATION_TAP_PATHS", () => { it("keeps the targets the issue names", () => { expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1"); expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/"); - expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe( - "/settings/integrations", - ); expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications"); }); - it("has no entry for an event type outside the union", () => { + it("has no entry for an event type outside the sent set", () => { expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual( - [...EVENT_TYPES].sort(), + [...SENT].sort(), ); }); + + it("gives an event nothing sends no target", () => { + for (const eventType of UNSENT_EVENT_TYPES) { + expect(resolveTapPath({ eventType }), eventType).toBeNull(); + } + }); }); describe("resolveTapPath", () => { @@ -212,7 +219,7 @@ describe("tap target on every kind and channel", () => { it.each(variants)("$name", async ({ extra, neutral }) => { process.env.APP_URL = "https://health.example"; let checked = 0; - for (const eventType of EVENT_TYPES) { + for (const eventType of SENT) { const payload = await withDeliveryLinks( { userId: "u1", title: "t", message: "m", eventType, ...extra }, "guardian-1", @@ -244,6 +251,6 @@ describe("tap target on every kind and channel", () => { ).toBe(url); checked += 1; } - expect(checked).toBe(EVENT_TYPES.length - SILENT.size); + expect(checked).toBe(SENT.length - SILENT.size); }); }); diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index 60e1ac7c1..5e894ca8e 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -13,29 +13,30 @@ * - Webhook → `url` in the body (absolute URL) * - Email → a link line under the body (absolute URL) * - * The map is typed `Record`, so a new event type without a - * target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a - * target that stops resolving fails there too. + * The map is typed `Record`, so a new event type without a + * target does not compile, and `tap-target.test.ts` walks the sent event + * types so a target that stops resolving fails there too. An event type + * nothing sends (`UNSENT_EVENT_TYPES`) has no entry and resolves to no target. * * A producer may name a more precise path in `metadata.url` (the low-stock * alert opens the medication's stock tab, the Coach nudge opens its own * conversation). That override is accepted only as a same-origin path, so a * value that names another host can never become a click target. */ -import type { EventType, NotificationPayload } from "@/lib/notifications/types"; +import type { + EventType, + NotificationPayload, + SentEventType, +} from "@/lib/notifications/types"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; /** * The default in-app path per event type. `null` means the event is never * shown to the user (a silent background push), so it carries no target. */ -export const NOTIFICATION_TAP_PATHS: Record = { +export const NOTIFICATION_TAP_PATHS: Record = { MEDICATION_REMINDER: "/medications", - MEASUREMENT_ANOMALY: "/measurements", - COMPLIANCE_LOW: "/insights/medications", - WITHINGS_SYNC_FAILED: "/settings/integrations", SYSTEM_ALERT: "/", - PERSONAL_RECORD: "/achievements", // `?add=1` opens the mood entry sheet on the first paint, and only in the // recipient's own record: a session switched into another record opens the // page without the sheet (`src/app/mood/page-client.tsx`). No other target @@ -97,7 +98,9 @@ export function resolveTapPath( "eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId" >, ): string | null { - const fallback = NOTIFICATION_TAP_PATHS[payload.eventType]; + const paths: Partial> = + NOTIFICATION_TAP_PATHS; + const fallback = paths[payload.eventType]; if (fallback === null || fallback === undefined) return null; if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH; if (payload.discreet) return NEUTRAL_TAP_PATH; diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ea05bf42a..a86526350 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -2,9 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import type { NotificationQuickAction } from "@/lib/notifications/action-token"; /** - * Notification system type definitions. - * Channels: Telegram, ntfy, Web Push - * Events: medication reminders, anomalies, compliance, etc. + * Notification system type definitions: the channels a notification travels + * on and the event types it can carry. */ export const CHANNEL_TYPES = [ @@ -30,13 +29,12 @@ export type ChannelType = (typeof CHANNEL_TYPES)[number]; export const EVENT_TYPES = [ "MEDICATION_REMINDER", + // The next three and PERSONAL_RECORD are never sent; see + // `UNSENT_EVENT_TYPES`. "MEASUREMENT_ANOMALY", "COMPLIANCE_LOW", "WITHINGS_SYNC_FAILED", "SYSTEM_ALERT", - // v1.4.25 — 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", // v0.5.4 ios-coord — daily mood-reminder nudge. Default OFF on the // per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated @@ -112,8 +110,7 @@ export const EVENT_TYPES = [ // a deterministic floor). It is a nudge, NEVER the anomaly channel: it is // dispatched non-urgent, fired at most once per user per local day (the // `push_attempts` ledger is the frequency anchor), and only inside a local - // morning window, so a concerning reading always stays on - // `MEASUREMENT_ANOMALY` and this never bypasses Focus. + // morning window, so it never bypasses Focus. "DAILY_BRIEFING", // v1.36.x — somebody with a write grant on this record marked one of the // owner's doses. The only notification a delegated write produces: every @@ -154,11 +151,9 @@ export type ManagedGuardianFanoutEvent = * 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. + * Events default to ON (the original opt-out model); the ones that + * default OFF say why beside their entry. The entries for + * `UNSENT_EVENT_TYPES` decide nothing, since nothing dispatches them. */ export const EVENT_DEFAULT_ENABLED: Record = { MEDICATION_REMINDER: true, @@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record = { /** Event types only an admin is ever sent, and only an admin is shown. */ export const ADMIN_ONLY_EVENT_TYPES: readonly EventType[] = ["BACKUP_HEALTH"]; +/** + * Event types nothing sends. They stay in `EVENT_TYPES` because the + * preference write and stored preference rows still name them, but nobody is + * shown a switch for them and they carry no tap target: a switch for an event + * nobody emits would switch nothing. Withings failures reach admins as + * `SYSTEM_ALERT`. + */ +export const UNSENT_EVENT_TYPES = [ + "MEASUREMENT_ANOMALY", + "COMPLIANCE_LOW", + "WITHINGS_SYNC_FAILED", + "PERSONAL_RECORD", +] as const satisfies readonly EventType[]; + +/** The event types something sends. */ +export type SentEventType = Exclude< + EventType, + (typeof UNSENT_EVENT_TYPES)[number] +>; + +export function isSentEventType(type: string): type is SentEventType { + return ( + (EVENT_TYPES as readonly string[]).includes(type) && + !(UNSENT_EVENT_TYPES as readonly string[]).includes(type) + ); +} + /** The event types a person with `role` can receive, in matrix order. */ -export function eventTypesForRole(role: string): EventType[] { - return role === "ADMIN" - ? [...EVENT_TYPES] - : EVENT_TYPES.filter((type) => !ADMIN_ONLY_EVENT_TYPES.includes(type)); +export function eventTypesForRole(role: string): SentEventType[] { + return EVENT_TYPES.filter(isSentEventType).filter( + (type) => role === "ADMIN" || !ADMIN_ONLY_EVENT_TYPES.includes(type), + ); } export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index 7c639e155..b3ab7d429 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -76,7 +76,7 @@ const notificationPreferencesResponse = z eventTypes: z .array(z.string()) .describe( - "Every event category the server knows, in declaration order — the list a client renders rows for.", + "The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.", ), eventDefaults: z .record(eventTypeEnum, z.boolean()) @@ -87,7 +87,7 @@ const notificationPreferencesResponse = z .meta({ id: "NotificationPreferencesResponse", description: - "The caller's notification channels, their stored per-event overrides, and the full event vocabulary.", + "The caller's notification channels, their stored per-event overrides, and the event categories they can switch.", }); const notificationPreferenceRequest = notificationPreferenceSchema.meta({ diff --git a/tests/integration/notification-event-defaults.test.ts b/tests/integration/notification-event-defaults.test.ts index 588a4700b..be8d325e3 100644 --- a/tests/integration/notification-event-defaults.test.ts +++ b/tests/integration/notification-event-defaults.test.ts @@ -131,3 +131,49 @@ describe("event defaults: the matrix shows what delivery does", () => { ); }); }); + +describe("events nothing sends", () => { + it("are listed for nobody, and their stored rows are not served", async () => { + const userId = await seedAccount(false); + const prisma = getPrismaClient(); + const channel = await prisma.notificationChannel.findFirstOrThrow({ + where: { userId }, + }); + // Rows written while the four switches were still offered. + await prisma.notificationPreference.createMany({ + data: [ + { channelId: channel.id, eventType: "PERSONAL_RECORD", enabled: true }, + { + channelId: channel.id, + eventType: "MEASUREMENT_ANOMALY", + enabled: false, + }, + { + channelId: channel.id, + eventType: "MEDICATION_REMINDER", + enabled: false, + }, + ], + }); + + const { GET } = await import("@/app/api/notifications/preferences/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + eventTypes: string[]; + preferences: { eventType: string; enabled: boolean }[]; + }; + }; + const { UNSENT_EVENT_TYPES } = await import("@/lib/notifications/types"); + for (const unsent of UNSENT_EVENT_TYPES) { + expect(body.data.eventTypes).not.toContain(unsent); + } + expect(body.data.preferences).toEqual([ + expect.objectContaining({ + eventType: "MEDICATION_REMINDER", + enabled: false, + }), + ]); + }); +}); From 71fe874ef2bc755b0a3b4be7712e14e48b0946c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:55:09 +0100 Subject: [PATCH 2/5] fix(medications): silence a switched-off module and an ended course With the Medications module switched off, dose reminders, missed-dose rows and low-stock alerts kept coming, a tap on one landed on "Medications is switched off", and the Telegram buttons still booked intakes. The reminder tick now drops the medications of an owner with the module off, one module read per owner and tick, so neither a reminder nor a missed-dose placeholder is written while it is off. The low-stock pass skips such a user, as the mood reminder does for its module, and the bot offers and books nothing. A medication whose course had ended still counted as live: the low-stock pass kept warning about its stock, and the bot's /add list offered it and booked a dose after the end, taking a unit off the stock. The low-stock pass and every bot path now skip a course that has ended on the person's clock. A course that has not started yet keeps its reorder warning and stays in the bot. A paused medication is already active: false, so the existing filter keeps it out. The bot's six copies of the medication lookup go through one helper. --- .../baselines/source-file-lines.json | 2 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- .../telegram/webhook/__tests__/route.test.ts | 90 +++++++++++++- .../telegram-dose-medications.test.ts | 113 ++++++++++++++++++ .../__tests__/medication-low-stock.test.ts | 54 +++++++++ .../medication-reminder-check.test.ts | 24 ++++ .../medication-reminder-dedup.test.ts | 3 + src/lib/jobs/medication-low-stock.ts | 67 +++++++---- .../reminder/medication-reminder-check.ts | 23 +++- src/lib/telegram-dose-medications.ts | 76 ++++++++++++ src/lib/telegram-webhook-handlers.ts | 66 ++++------ 12 files changed, 451 insertions(+), 75 deletions(-) create mode 100644 src/lib/__tests__/telegram-dose-medications.test.ts create mode 100644 src/lib/telegram-dose-medications.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e6d9b833e..7705268f3 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1341, + "lib/telegram-webhook-handlers.ts": 1313, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..ec7212f25 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -339,8 +339,8 @@ "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 330, "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, - "lib/telegram-webhook-handlers.ts :: handleCallback": 599, - "lib/telegram-webhook-handlers.ts :: handleTextMessage": 337, + "lib/telegram-webhook-handlers.ts :: handleCallback": 582, + "lib/telegram-webhook-handlers.ts :: handleTextMessage": 322, "lib/withings/sync-activity.ts :: syncUserActivity": 377, "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index dd511fc2a..22e2f5bfa 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2333,7 +2333,7 @@ "lib/jobs/med-notes-encryption-backfill.ts": 2, "lib/jobs/medication-intake-import.ts": 1, "lib/jobs/medication-inventory-expire.ts": 1, - "lib/jobs/medication-low-stock.ts": 13, + "lib/jobs/medication-low-stock.ts": 12, "lib/jobs/mood-reminder.ts": 4, "lib/jobs/note-encryption-backfill.ts": 2, "lib/jobs/oidc-handoff-cleanup.ts": 1, @@ -2786,7 +2786,7 @@ "lib/targets/types.ts": 2, "lib/targets/vitals-builder.ts": 2, "lib/telegram-cleanup.ts": 1, - "lib/telegram-webhook-handlers.ts": 17, + "lib/telegram-webhook-handlers.ts": 16, "lib/telegram.ts": 2, "lib/time-window-format.ts": 1, "lib/timeline/__tests__/lane-access.test.ts": 1, diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index d0dd4e058..4781f80c2 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -97,6 +97,8 @@ vi.mock("@/lib/medications/dose-day-refresh", () => ({ refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -112,6 +114,7 @@ import { } from "@/lib/mood/create-from-telegram"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { isModuleEnabled } from "@/lib/modules/gate"; const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; @@ -134,6 +137,7 @@ const MEDICATION = { id: "med-1", name: "Ramipril" }; function wireMedicationFindFirst( schedules: unknown[] = [], scheduleRevisions: unknown[] = [], + medication: Record = MEDICATION, ) { vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { select?: Record; @@ -149,7 +153,7 @@ function wireMedicationFindFirst( scheduleRevisions, }); } - return Promise.resolve(MEDICATION); + return Promise.resolve(medication); }) as never); } @@ -162,6 +166,7 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); wireMedicationFindFirst(); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); @@ -1236,3 +1241,86 @@ describe("POST /api/telegram/webhook — record-only medications", () => { }); }); }); + +/** + * With the Medications module switched off the bot offers no dose and books + * none: the web shows no medication surface and the reminder jobs are + * silent, so a reminder button left in the chat, the /add list and the + * "taken" keyword do nothing either. + */ +describe("POST /api/telegram/webhook — Medications module off", () => { + function textUpdate(text: string) { + return { + update_id: 400, + message: { message_id: 40, text, chat: { id: 7777 }, from: { id: 7777 } }, + }; + } + + beforeEach(() => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + }); + + it.each(["taken:med-1", "skip:med-1", "add:med-1"])( + "the %s button books nothing", + async (data) => { + const res = await POST(tgRequest(callbackUpdate(data))); + expect(res.status).toBe(200); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }, + ); + + it("the snooze button snoozes nothing", async () => { + await POST(tgRequest(callbackUpdate("snooze:med-1:60"))); + expect(prisma.medication.update).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found/i, + ); + }); + + it("/add offers nothing", async () => { + await POST(tgRequest(textUpdate("/add"))); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /no active medications/i, + ); + }); + + it("the 'taken' keyword records nothing", async () => { + await POST(tgRequest(textUpdate("taken Ramipril"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +/** + * A reminder button left in the chat takes no dose for a course that has + * since ended, which would also take a unit off the stock. The list side is + * pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`. + */ +describe("POST /api/telegram/webhook — ended course", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const ENDED = { + ...MEDICATION, + dose: "5 mg", + startsOn: new Date(Date.now() - 20 * DAY_MS), + endsOn: new Date(Date.now() - 3 * DAY_MS), + endedAt: null, + }; + it("the taken button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found or inactive/i, + ); + }); + + it("the skip button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/__tests__/telegram-dose-medications.test.ts b/src/lib/__tests__/telegram-dose-medications.test.ts new file mode 100644 index 000000000..a8fceac86 --- /dev/null +++ b/src/lib/__tests__/telegram-dose-medications.test.ts @@ -0,0 +1,113 @@ +/** + * Which medications the Telegram bot may offer a dose for or record one + * against: tracked and active, with the Medications module on, and not past + * the end of the course on the person's clock. A course that has not started + * yet stays. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + moduleOn: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: db.findMany, findFirst: db.findFirst }, + }, +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: db.moduleOn })); + +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const day = (offset: number) => new Date(Date.now() + offset * DAY_MS); + +const CHRONIC = { + id: "chronic", + name: "Ramipril", + dose: "5 mg", + startsOn: null, + endsOn: null, + endedAt: null, +}; +const ENDED = { + ...CHRONIC, + id: "ended", + startsOn: day(-12), + endsOn: day(-2), +}; +const ENDED_NOW = { + ...CHRONIC, + id: "ended-now", + startsOn: day(-12), + endsOn: day(0), + endedAt: new Date(Date.now() - 60_000), +}; +const UPCOMING = { ...CHRONIC, id: "upcoming", startsOn: day(3) }; + +beforeEach(() => { + vi.clearAllMocks(); + db.moduleOn.mockResolvedValue(true); +}); + +describe("listTelegramDoseMedications", () => { + it("leaves an ended course out and keeps a current or upcoming one", async () => { + db.findMany.mockResolvedValue([CHRONIC, ENDED, ENDED_NOW, UPCOMING]); + const listed = await listTelegramDoseMedications("user-1", "Europe/Berlin"); + expect(listed.map((m) => m.id)).toEqual(["chronic", "upcoming"]); + expect(db.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", active: true, trackIntake: true }, + }), + ); + }); + + it("offers nothing with the Medications module off", async () => { + db.moduleOn.mockResolvedValue(false); + expect(await listTelegramDoseMedications("user-1", null)).toEqual([]); + expect(db.moduleOn).toHaveBeenCalledWith("user-1", "medications"); + expect(db.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("findTelegramDoseMedication", () => { + it("resolves a current course", async () => { + db.findFirst.mockResolvedValue(CHRONIC); + const found = await findTelegramDoseMedication("user-1", null, { + id: "chronic", + }); + expect(found?.id).toBe("chronic"); + expect(db.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: "chronic", + userId: "user-1", + active: true, + trackIntake: true, + }, + }), + ); + }); + + it("refuses an ended course", async () => { + db.findFirst.mockResolvedValue(ENDED_NOW); + expect( + await findTelegramDoseMedication("user-1", "Europe/Berlin", { + id: "ended-now", + }), + ).toBeNull(); + }); + + it("refuses everything with the Medications module off", async () => { + db.moduleOn.mockResolvedValue(false); + expect( + await findTelegramDoseMedication("user-1", null, { id: "chronic" }), + ).toBeNull(); + expect(db.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 5aa3d9b6c..7e0f6b33e 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -7,7 +7,10 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import type { PrismaClient } from "@/generated/prisma/client"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { buildLowStockPayload, decideLowStockAction, @@ -280,6 +283,7 @@ describe("runMedicationLowStockTick", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); }); it("notifies a below-threshold medication and stamps the crossing", async () => { @@ -426,6 +430,56 @@ describe("runMedicationLowStockTick", () => { expect(dispatch).not.toHaveBeenCalled(); }); + it("the Medications module switched off skips the user without reading medications", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [userRow()], + medications: [medRow()], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(summary.skippedModuleDisabled).toBe(1); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("skips a medication whose course has ended, keeps one not yet started", async () => { + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [{ ...userRow(), timezone: "Europe/Berlin" }], + medications: [ + medRow({ id: "med-ended", endsOn: daysAgo(3), startsOn: daysAgo(13) }), + medRow({ id: "med-ended-today", endsOn: now, endedAt: daysAgo(0.01) }), + medRow({ + id: "med-upcoming", + startsOn: new Date(now.getTime() + 3 * MS_PER_DAY), + }), + ], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(summary.skippedCourseEnded).toBe(2); + expect(summary.notified).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ medicationId: "med-upcoming" }), + }), + ); + }); + it("a schedule-less medication never notifies (no runway derivable)", async () => { const dispatch = okDispatch(); const prisma = prismaMock({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 5126e9765..a47225f06 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -99,6 +99,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); vi.mock("@/lib/notifications/reminder-dedup", () => ({ claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), @@ -110,6 +113,7 @@ import { nextSlotHeldBySnooze, } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { isModuleEnabled } from "@/lib/modules/gate"; function medicationWithSchedule( schedule: Record, @@ -860,6 +864,26 @@ describe("issue #664 — exact occurrence identity survives local midnight", () ); }); + it("with the Medications module off, neither reminds nor mints the missed row", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + const medication = crossMidnightMedication("Europe/Berlin"); + prismaMock.medication.findMany.mockResolvedValue([ + medication, + { ...medication, id: "medication-2" }, + ]); + setIntakeEvents([]); + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + + await handleReminderCheck([]); + + // One read for the owner, not one per medication or slot. + expect(isModuleEnabled).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + it("does not mint a second missed row for a dose taken just before midnight", async () => { // The RED placeholder is the escalation's permanent trace: mint it for a // dose the user already logged and the medication reads as missed forever diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 75f250ab7..3b3ba5a49 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -124,6 +124,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 99b3fa8bf..16a8b2571 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -52,7 +52,11 @@ * follows the COACH_NUDGE posture (server-only, prefs-gated). * * Inactive / paused medications are skipped: nothing consumes their - * stock, so their runway is not falling. + * stock, so their runway is not falling. So is a medication whose course has + * ended on the person's clock; a course that has not started yet still warns, + * since its stock is about to be needed. And so is every medication of a + * person who switched the Medications module off, as the mood reminder does + * for the Mood module. * * As-needed (PRN) medications have no cadence and so no runway in days. * Once the runway math stopped counting a PRN schedule as a daily dose, @@ -86,6 +90,12 @@ import { coerceLocale, type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { annotate, getEvent } from "@/lib/logging/context"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * An as-needed medication warns when this many doses or fewer are left: no @@ -93,9 +103,23 @@ import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; */ export const AS_NEEDED_LOW_STOCK_DOSES = 2; +/** + * A medication whose stock the pass watches: active, unpaused, with intake + * tracked (#1033, no dose consumes the stock of one kept as a record) and + * with tracked inventory (any container ever registered). + */ +const WATCHED_MEDICATION_WHERE = { + active: true, + pausedAt: null, + ...TRACKED_INTAKE_WHERE, + inventoryItems: { some: {} }, +}; + export interface MedicationLowStockSummary { usersScanned: number; skippedThresholdOff: number; + skippedModuleDisabled: number; + skippedCourseEnded: number; medicationsEvaluated: number; notified: number; rearmed: number; @@ -376,6 +400,8 @@ export async function runMedicationLowStockTick( const summary: MedicationLowStockSummary = { usersScanned: 0, skippedThresholdOff: 0, + skippedModuleDisabled: 0, + skippedCourseEnded: 0, medicationsEvaluated: 0, notified: 0, rearmed: 0, @@ -389,22 +415,10 @@ export async function runMedicationLowStockTick( const notifiedMeta: LowStockEventMeta[] = []; const skippedMeta: LowStockEventMeta[] = []; - // Only users who own at least one ACTIVE, unpaused medication with - // tracked inventory (any container ever registered) are candidates. + // Only users who own at least one watched medication are candidates. const users = await prisma.user.findMany({ - where: activeAccount({ - medications: { - some: { - active: true, - pausedAt: null, - // v1.39.1 (#1033) — no low-stock notice for a medication with - // intake tracking off: no dose consumes its stock. - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, - }, - }), - select: { id: true, locale: true, notificationPrefs: true }, + where: activeAccount({ medications: { some: WATCHED_MEDICATION_WHERE } }), + select: { id: true, locale: true, timezone: true, notificationPrefs: true }, }); for (const user of users) { @@ -418,15 +432,15 @@ export async function runMedicationLowStockTick( summary.skippedThresholdOff += 1; continue; } + // After the threshold, so the module read runs only for users the + // alert is on for. + if (!(await isModuleEnabled(user.id, "medications"))) { + summary.skippedModuleDisabled += 1; + continue; + } const medications = await prisma.medication.findMany({ - where: { - userId: user.id, - active: true, - pausedAt: null, - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, + where: { userId: user.id, ...WATCHED_MEDICATION_WHERE }, select: { id: true, name: true, @@ -436,6 +450,7 @@ export async function runMedicationLowStockTick( reorderLeadDays: true, lowStockNotifiedAt: true, lowStockNotifiedThresholdDays: true, + ...MEDICATION_WINDOW_SELECT, inventoryItems: { select: { state: true, unitsTotal: true, unitsRemaining: true }, }, @@ -458,7 +473,13 @@ export async function runMedicationLowStockTick( }, }); + const timeZone = user.timezone || DEFAULT_TIMEZONE; for (const med of medications) { + // Nothing consumes the stock of an ended course any more. + if (courseStatusAt(med, now, timeZone) === "ENDED") { + summary.skippedCourseEnded += 1; + continue; + } summary.medicationsEvaluated += 1; // Decimal → number for the per-slot units so the runway math and diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 2c9e85e5b..c7fa1dbc4 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -47,6 +47,8 @@ import { } from "@/lib/medications/scheduling/live-era"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { getEvent } from "@/lib/logging/context"; export interface ReminderCheckPayload { triggeredAt: string; @@ -65,9 +67,15 @@ const getUserTodayBounds = getUserTodayBoundsUtil; * a record, so it is excluded explicitly: no reminder on any channel and no * missed-dose placeholder (#1033). And none whose account is pending * deletion: that record is frozen until it is erased or given back. + * + * Nor any of a person who switched the Medications module off: no reminder + * and no missed-dose placeholder while it is off, as the mood reminder does + * for the Mood module. One module read per owner per tick. */ -function loadRemindableMedications(prisma: ReturnType) { - return prisma.medication.findMany({ +async function loadRemindableMedications( + prisma: ReturnType, +) { + const medications = await prisma.medication.findMany({ where: { active: true, asNeeded: false, @@ -89,6 +97,17 @@ function loadRemindableMedications(prisma: ReturnType) { }, }, }); + const moduleOff = new Set(); + for (const userId of new Set(medications.map((med) => med.user.id))) { + if (!(await isModuleEnabled(userId, "medications"))) moduleOff.add(userId); + } + if (moduleOff.size > 0) { + getEvent()?.addMeta( + "medication_reminder.skipped_module_disabled", + moduleOff.size, + ); + } + return medications.filter((med) => !moduleOff.has(med.user.id)); } /** diff --git a/src/lib/telegram-dose-medications.ts b/src/lib/telegram-dose-medications.ts new file mode 100644 index 000000000..7820d860c --- /dev/null +++ b/src/lib/telegram-dose-medications.ts @@ -0,0 +1,76 @@ +/** + * The medications the Telegram bot may offer a dose for or record one + * against: the `/add` list, the "taken" keyword, and the reminder buttons. + * + * Active and with intake tracked, as every bot path has asked since #1033, + * and two conditions the bot used to skip: + * + * - The Medications module is on. With it off the web shows no medication + * surface and the reminder jobs send nothing, so the bot offers and books + * nothing either; re-enabling the module finds every row where it was. + * - The course has not ended on the person's clock (`courseStatusAt`). The + * web card stops offering a dose then, and a dose booked through the bot + * after the end would take a unit off the stock. A course that has not + * started yet is left to the bot as before. + * + * A paused medication is `active: false` (pausing writes both), so the + * `active` filter already keeps it out. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const DOSE_MEDICATION_SELECT = { + id: true, + name: true, + dose: true, + ...MEDICATION_WINDOW_SELECT, +} as const; + +function courseEnded( + medication: { + startsOn: Date | null; + endsOn: Date | null; + endedAt: Date | null; + }, + timeZone: string | null, +): boolean { + return ( + courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) === + "ENDED" + ); +} + +/** Every medication the person may pick a dose from, by name. */ +export async function listTelegramDoseMedications( + userId: string, + timeZone: string | null, +) { + if (!(await isModuleEnabled(userId, "medications"))) return []; + const medications = await prisma.medication.findMany({ + where: { userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + orderBy: [{ name: "asc" }, { id: "asc" }], + }); + return medications.filter((m) => !courseEnded(m, timeZone)); +} + +/** The one medication `where` names, or null when the bot may not act on it. */ +export async function findTelegramDoseMedication( + userId: string, + timeZone: string | null, + where: Prisma.MedicationWhereInput, +) { + if (!(await isModuleEnabled(userId, "medications"))) return null; + const medication = await prisma.medication.findFirst({ + where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + }); + return medication && !courseEnded(medication, timeZone) ? medication : null; +} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index b1f2e920e..c152bff52 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -37,10 +37,12 @@ import { isTelegramCapturableType, logTelegramMeasurement, } from "@/lib/measurements/create-from-telegram"; -// v1.39.1 (#1033) — every Telegram path that offers, records or snoozes a -// dose (the /add list, the "taken" keyword, the reminder buttons) leaves a -// medication kept as a record out, as the web and the today list do. -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +// Every Telegram path that offers, records or snoozes a dose resolves the +// medication here: tracked, active, course not ended, Medications module on. +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; import { GREETING_WORDS, HELP_WORDS } from "@/lib/telegram-bare-words"; /** @@ -264,9 +266,8 @@ async function markMedicationTaken( reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, + const medication = await findTelegramDoseMedication(userId, userTz, { + id: medicationId, }); if (!medication) { return { ok: false, message: t("telegram.errorMedicationInactive") }; @@ -481,15 +482,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -528,15 +525,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -1155,11 +1148,7 @@ export async function handleTextMessage(update: TelegramUpdate) { if (/^\/add\b/i.test(text)) { const userMsgId = message?.message_id; - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true, dose: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 0) { const resp = await sendTelegramMessage( @@ -1260,23 +1249,12 @@ export async function handleTextMessage(update: TelegramUpdate) { let medicationId: string | null = null; if (nameInput) { - const med = await prisma.medication.findFirst({ - where: { - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - name: { equals: nameInput, mode: "insensitive" }, - }, - select: { id: true }, + const med = await findTelegramDoseMedication(user.id, user.timezone, { + name: { equals: nameInput, mode: "insensitive" }, }); medicationId = med?.id ?? null; } else { - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - take: 2, - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 1) { medicationId = meds[0].id; } else { From a28c6b55abbff6650b985ad0a2efa21d412222be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:58:09 +0100 Subject: [PATCH 3/5] fix(medications): spell the reminder's slot time in the user's clock The dose reminder printed the slot as the raw stored HH:mm, so it said "20:00" while the medication card said "08:00 PM". That hit everyone who set a 12-hour clock, and every en or ko user on the automatic setting, which resolves to 12 hours for those languages. The reminder now formats the slot's instant in the owner's zone with the same formatter the card uses, honouring the owner's hour-cycle choice. A recipient reading the message in another language gets that language's clock when the owner left the setting on automatic. --- .../medication-reminder-check.test.ts | 49 +++++++++++++++++++ .../reminder/medication-reminder-check.ts | 35 +++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index a47225f06..053d222c3 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -111,6 +111,7 @@ vi.mock("@/lib/notifications/reminder-dedup", () => ({ import { handleReminderCheck, nextSlotHeldBySnooze, + slotClock, } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -308,6 +309,54 @@ describe("handleReminderCheck per-slot reminder windows", () => { }); }); +describe("the slot time in the reminder text follows the 12/24-hour setting", () => { + // 20:00 in Berlin on a summer day. + const slot = new Date("2026-07-28T18:00:00.000Z"); + + it("AUTO follows the message's language", () => { + const clock = slotClock(slot, "Europe/Berlin", { + timeFormat: "AUTO", + dateFormat: "AUTO", + }); + expect(clock("en")).toBe("08:00 PM"); + expect(clock("de")).toBe("20:00"); + }); + + it("an explicit choice wins over the language", () => { + expect( + slotClock(slot, "Europe/Berlin", { + timeFormat: "H24", + dateFormat: "AUTO", + })("en"), + ).toBe("20:00"); + expect( + slotClock(slot, "Europe/Berlin", { + timeFormat: "H12", + dateFormat: "AUTO", + })("de"), + ).toBe("08:00 PM"); + }); + + it("the dispatched reminder carries the person's clock", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T09:15:00.000Z")); + const medication = medicationWithSchedule({ + windowEnd: "18:00", + timesOfDay: ["08:00", "18:00"], + }); + prismaMock.medication.findMany.mockResolvedValue([medication]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + const payload = vi.mocked(dispatchNotification).mock.calls[0][0]; + // An `en` owner on AUTO reads a 12-hour clock, as on the medication card. + expect(payload.message).toContain("08:00 AM"); + // A recipient in another language gets that language's clock. + expect(payload.renderForRecipient?.("de").message).toContain("08:00)"); + }); +}); + describe("handleReminderCheck medication-wide slot attribution", () => { it("lets an overlapping schedule claim the nearest medication-wide slot", async () => { vi.useFakeTimers(); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index c7fa1dbc4..b57f86942 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -49,6 +49,11 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; import { isModuleEnabled } from "@/lib/modules/gate"; import { getEvent } from "@/lib/logging/context"; +import { + makeFormatters, + type DateFormatPreference, + type TimeFormatPreference, +} from "@/lib/format-locale"; export interface ReminderCheckPayload { triggeredAt: string; @@ -93,6 +98,9 @@ async function loadRemindableMedications( // Used to localise the reminder title / message / keyboard // labels per user. Null falls back to the app default. locale: true, + // How the slot time is spelled (`slotClock`). + timeFormat: true, + dateFormat: true, }, }, }, @@ -110,6 +118,27 @@ async function loadRemindableMedications( return medications.filter((med) => !moduleOff.has(med.user.id)); } +/** + * The slot's clock time as the person reads it everywhere else in the app: + * their hour cycle, through the same formatter as the medication card. AUTO + * follows the message's language, so an `en` reminder reads "08:00 PM" where + * a `de` one reads "20:00". The slot's instant is formatted in the person's + * zone; the bare `HH:mm` would be read in the server's. + */ +export function slotClock( + slot: Date, + timeZone: string, + person: { + timeFormat: TimeFormatPreference; + dateFormat: DateFormatPreference; + }, +): (locale: Locale) => string { + return (locale) => + makeFormatters(locale, timeZone, person.timeFormat, person.dateFormat).time( + slot, + ); +} + /** * Whether a medication-level snooze holds the reminder of one slot. It * holds only while it runs (`now < snoozedUntil`), and only for a slot whose @@ -613,7 +642,7 @@ export async function handleReminderCheck( } const doseInfo = schedule.dose ?? med.dose; - const timeWindow = `${slotTime}`; + const timeWindow = slotClock(slotScheduledFor, userTz, med.user); // RED phase: create missed intake event for this slot. if (currentPhase === "RED") { @@ -663,7 +692,7 @@ export async function handleReminderCheck( currentPhase, med.name, doseInfo, - timeWindow, + timeWindow(recipientLocale), Math.ceil(minutesToEnd), recipientLocale, ); @@ -672,7 +701,7 @@ export async function handleReminderCheck( currentPhase, med.name, doseInfo, - timeWindow, + timeWindow(locale), Math.ceil(minutesToEnd), locale, ); From af673a63daea25d24e81d17dcdf9d2ab3804f4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:59:43 +0100 Subject: [PATCH 4/5] fix(measurements): offer no edit or delete on a computed score Recovery, strain and other computed rows in the measurement list carried a pencil and a bin. The pencil opened an edit sheet labelled "Value ():" whose save the server then refused, and the bin deleted a score the engine owns. Both are gone for COMPUTED rows. Readings from a device or a provider keep both controls: the server takes a note or time edit on them, and a wrong scale reading has to stay removable. The server is unchanged. --- .../list/__tests__/row-actions.test.tsx | 75 +++++++++++++++++++ .../measurements/list/row-actions.tsx | 18 ++++- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/components/measurements/list/__tests__/row-actions.test.tsx diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx new file mode 100644 index 000000000..e481a9427 --- /dev/null +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -0,0 +1,75 @@ +/** + * A computed score offers neither edit nor delete in the measurement list; + * a reading of the person's own, or one a device or provider sent, keeps + * both. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canManageDomain: () => true, + inSharedRecord: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Measurement } from "../list-model"; +import { + MeasurementRowActions, + type MeasurementRowContext, +} from "../row-actions"; + +const rows: MeasurementRowContext = { + canManage: true, + selectedIds: new Set(), + expandedDayKeys: new Set(), + toggleExpand: () => {}, + navigateForType: () => {}, + startEdit: () => {}, + onToggleRow: () => {}, + onDelete: () => {}, + // The row's value formatting; the actions never read it. + display: {} as MeasurementRowContext["display"], +}; + +function render(source: string): string { + const m: Measurement = { + id: "m1", + type: "RECOVERY_SCORE", + value: 85, + unit: "score", + source, + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }; + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementRowActions", () => { + it("offers no edit and no delete for a computed score", () => { + const html = render("COMPUTED"); + expect(html).not.toContain('aria-label="Edit"'); + expect(html).not.toContain('aria-label="Delete"'); + }); + + it.each(["MANUAL", "WITHINGS", "APPLE_HEALTH"])( + "keeps edit and delete for a %s reading", + (source) => { + const html = render(source); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete"'); + }, + ); +}); diff --git a/src/components/measurements/list/row-actions.tsx b/src/components/measurements/list/row-actions.tsx index 92a0c0244..cf40248d3 100644 --- a/src/components/measurements/list/row-actions.tsx +++ b/src/components/measurements/list/row-actions.tsx @@ -43,10 +43,22 @@ export function rowState(m: Measurement, rows: MeasurementRowContext) { }; } +/** + * A score the server computes (recovery, strain, stress) offers neither + * edit nor delete: its value is the engine's, the server refuses a changed + * one on save, and the edit sheet has nothing of the person's to correct. + * Readings from a device or a provider keep both: the server takes a note or + * time edit on them, and a wrong scale reading has to be removable. + */ +export function isServerComputed(m: Pick): boolean { + return m.source === "COMPUTED"; +} + /** * A row's controls: the chevron into a day's or night's samples for a - * grouped row; edit (owner only) and delete for a reading. The table's - * buttons are 40 px, the phone list's the large icon size. + * grouped row; edit (owner only) and delete for a reading, neither for a + * computed score. The table's buttons are 40 px, the phone list's the large + * icon size. */ export function MeasurementRowActions({ m, @@ -85,7 +97,7 @@ export function MeasurementRowActions({ className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} /> - ) : ( + ) : isServerComputed(m) ? null : ( <> {canManage && ( - - - - )} - - - - - setEditingEvent(null)} - /> - - { - if (!open) setBulkConfirmOpen(false); - }} - > - - - - {t("medications.detail.intake.bulkDelete.confirmTitle")} - - - {t("medications.detail.intake.bulkDelete.confirmBody", { - count: selected.size, - })} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmBulkDelete(); - }} - disabled={bulkBusy} - aria-busy={bulkBusy || undefined} - className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white" - > - {t("medications.detail.intake.bulkDelete.confirmAction")} - - - - - - { - if (!open) setPendingDeleteId(null); - }} - > - - - - {t("medications.detail.intake.deleteRow.confirmTitle")} - - - {t("medications.detail.intake.deleteRow.confirmBody")} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmRowDelete(); - }} - disabled={rowDeleteBusy} - aria-busy={rowDeleteBusy || undefined} - className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white" - > - {t("medications.detail.intake.deleteRow.confirmAction")} - - - - - - ); -} diff --git a/src/components/medications/sections/intake-history-preview.tsx b/src/components/medications/sections/intake-history-preview.tsx deleted file mode 100644 index 45a53e1fa..000000000 --- a/src/components/medications/sections/intake-history-preview.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -/** - * v1.5.5 D-3 §9.5 — Intake-history preview. - * - * Section chrome via ``. Header CTA carries a - * CSV-import trigger. The body re-uses the shared - * `` (extracted in v1.7.0) so the table + sort + - * pagination + multi-select bulk-delete + per-row edit/delete stay - * identical between this preview and the full-history view. - * - * v1.7.0 — the footer "View full history →" link is gone; the detail - * header's History button now routes directly to the full view, so the - * redundant link is removed (R-medui §4.3). - */ - -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { Upload } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; -import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; -import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; -import { useTranslations } from "@/lib/i18n/context"; - -export interface IntakeHistoryPreviewProps { - medicationId: string; - importOpen: boolean; - onImportOpenChange: (open: boolean) => void; -} - -export function IntakeHistoryPreview({ - medicationId, - importOpen, - onImportOpenChange, -}: IntakeHistoryPreviewProps) { - const { t } = useTranslations(); - // v1.36.x — importing dose history is not a delegated verb. - const { canManageDomain } = useRecordCapabilities(); - const canManageMedications = canManageDomain("medications"); - - return ( - <> - onImportOpenChange(true)} - className="min-h-11 sm:min-h-9" - data-slot="intake-history-import" - > - - - {importOpen && ( - onImportOpenChange(false)} - /> - )} - - ); -}