Merge branch 'fix/v1473-switches' into release/v1.47.3

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:18:57 +01:00
commit 493154817d
43 files changed
+843 -1178

No files matched your search

+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Einnahmeverlauf",
"importButton": "Importieren",
"rowActions": {
"openMenu": "Zeilen-Aktionen öffnen",
"edit": "Bearbeiten",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Zeile auswählen"
},
"bulkDelete": {
"selectionCount": "{count} Einträge ausgewählt",
"deleteButton": "Auswahl löschen",
"cancelButton": "Abbrechen",
"confirmTitle": "Ausgewählte Einträge löschen?",
"confirmBody": "{count} Einträge werden dauerhaft entfernt. Das Medikament bleibt.",
"confirmAction": "Löschen",
"toast": "Auswahl gelöscht",
"failed": "Auswahl konnte nicht gelöscht werden."
"cancelButton": "Abbrechen"
},
"edit": {
"dialogTitle": "Einnahme bearbeiten",
@@ -2846,11 +2838,6 @@
"bmiEmptyAction": "Account-Einstellungen öffnen",
"noAnalysisYet": "Noch keine Einschätzung vorhanden.",
"assessmentPreparing": "Einschätzung wird vorbereitet…",
"personalRecord": {
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
},
"loadError": "Insights konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerating": "Wird generiert",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"rowActions": {
"openMenu": "Open row actions",
"edit": "Edit",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Select row"
},
"bulkDelete": {
"selectionCount": "{count} entries selected",
"deleteButton": "Delete selection",
"cancelButton": "Cancel",
"confirmTitle": "Delete the selected entries?",
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
"confirmAction": "Delete",
"toast": "Selection deleted",
"failed": "Could not delete the selection."
"cancelButton": "Cancel"
},
"edit": {
"dialogTitle": "Edit intake",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Set your height to see BMI",
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
"personalRecord": {
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}",
"benchmark": "Typical at that age: {low} to {high} {unit} ({source})"
},
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerating": "Regenerating",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Historial de tomas",
"importButton": "Importar",
"rowActions": {
"openMenu": "Abrir acciones de la fila",
"edit": "Editar",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Seleccionar fila"
},
"bulkDelete": {
"selectionCount": "{count} entradas seleccionadas",
"deleteButton": "Eliminar selección",
"cancelButton": "Cancelar",
"confirmTitle": "¿Eliminar las entradas seleccionadas?",
"confirmBody": "Se eliminarán {count} entradas de forma permanente. El medicamento se conserva.",
"confirmAction": "Eliminar",
"toast": "Selección eliminada",
"failed": "No se pudo eliminar la selección."
"cancelButton": "Cancelar"
},
"edit": {
"dialogTitle": "Editar toma",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
"personalRecord": {
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}",
"benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})"
},
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerating": "Generando",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Historique des prises",
"importButton": "Importer",
"rowActions": {
"openMenu": "Ouvrir les actions de la ligne",
"edit": "Modifier",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Sélectionner la ligne"
},
"bulkDelete": {
"selectionCount": "{count} entrées sélectionnées",
"deleteButton": "Supprimer la sélection",
"cancelButton": "Annuler",
"confirmTitle": "Supprimer les entrées sélectionnées ?",
"confirmBody": "{count} entrées seront supprimées définitivement. Le médicament est conservé.",
"confirmAction": "Supprimer",
"toast": "Sélection supprimée",
"failed": "Impossible de supprimer la sélection."
"cancelButton": "Annuler"
},
"edit": {
"dialogTitle": "Modifier la prise",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Ajoutez votre taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite votre taille. Renseignez-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}",
"benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})"
},
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur vos 90 derniers jours",
"heroRegenerating": "Génération en cours",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Storico delle assunzioni",
"importButton": "Importa",
"rowActions": {
"openMenu": "Apri azioni della riga",
"edit": "Modifica",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Seleziona riga"
},
"bulkDelete": {
"selectionCount": "{count} voci selezionate",
"deleteButton": "Elimina selezione",
"cancelButton": "Annulla",
"confirmTitle": "Eliminare le voci selezionate?",
"confirmBody": "{count} voci verranno rimosse definitivamente. Il farmaco resta.",
"confirmAction": "Elimina",
"toast": "Selezione eliminata",
"failed": "Impossibile eliminare la selezione."
"cancelButton": "Annulla"
},
"edit": {
"dialogTitle": "Modifica assunzione",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}",
"benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})"
},
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerating": "Generazione in corso",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "복용 기록",
"importButton": "불러오기",
"rowActions": {
"openMenu": "행 작업 열기",
"edit": "편집",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "행 선택"
},
"bulkDelete": {
"selectionCount": "{count}개 항목 선택됨",
"deleteButton": "선택 항목 삭제",
"cancelButton": "취소",
"confirmTitle": "선택한 항목을 삭제할까요?",
"confirmBody": "{count}개 항목이 영구히 삭제돼요. 약 자체는 그대로 남아요.",
"confirmAction": "삭제",
"toast": "선택 항목을 삭제했어요",
"failed": "선택 항목을 삭제하지 못했어요."
"cancelButton": "취소"
},
"edit": {
"dialogTitle": "복용 기록 편집",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "BMI를 보려면 키를 입력해 주세요",
"bmiEmptyDescription": "BMI에는 키가 필요해요. 설정 → 계정에서 추가해 주세요.",
"bmiEmptyAction": "계정 설정 열기",
"personalRecord": {
"badge": "신기록",
"tooltip": "개인 최고 기록: {date} {value}{unit}",
"benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})"
},
"loadError": "인사이트를 불러오지 못했어요",
"heroPersonalBaseline": "최근 90일 기준",
"heroRegenerating": "다시 생성 중",
@@ -9314,16 +9301,8 @@
},
"eventMedicationReminder": "복약 알림",
"eventMedicationReminderDesc": "예정된 복용 시각마다 보내요.",
"eventMeasurementAnomaly": "이상 측정값",
"eventMeasurementAnomalyDesc": "측정값이 평소 범위를 크게 벗어나면 보내요.",
"eventComplianceLow": "낮은 복약 순응도",
"eventComplianceLowDesc": "최근 며칠 복약 순응도가 80% 아래로 떨어지면 보내요.",
"eventWithingsSyncFailed": "Withings 동기화 실패",
"eventWithingsSyncFailedDesc": "Withings 동기화가 여러 번 연달아 실패하면 보내요.",
"eventSystemAlert": "시스템 공지",
"eventSystemAlertDesc": "HealthLog 서버에서 오는 일반 메시지예요. 예를 들어 점검 시간이나 계정 안내.",
"eventPersonalRecord": "개인 기록",
"eventPersonalRecordDesc": "측정값이 이전 최고 기록을 넘으면 보내요.",
"eventMoodReminder": "기분 알림",
"eventMoodReminderDesc": "지금 기분을 기록하도록 매일 가볍게 알려줘요.",
"eventCyclePeriodSoon": "생리 예정",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Innamegeschiedenis",
"importButton": "Importeren",
"rowActions": {
"openMenu": "Rijacties openen",
"edit": "Bewerken",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Rij selecteren"
},
"bulkDelete": {
"selectionCount": "{count} registraties geselecteerd",
"deleteButton": "Selectie verwijderen",
"cancelButton": "Annuleren",
"confirmTitle": "Geselecteerde registraties verwijderen?",
"confirmBody": "{count} registraties worden definitief verwijderd. Het medicijn zelf blijft bestaan.",
"confirmAction": "Verwijderen",
"toast": "Selectie verwijderd",
"failed": "De selectie kon niet worden verwijderd."
"cancelButton": "Annuleren"
},
"edit": {
"dialogTitle": "Inname bewerken",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Stel je lengte in om je BMI te zien",
"bmiEmptyDescription": "Voor de BMI is je lengte nodig. Voeg die toe onder Instellingen → Account.",
"bmiEmptyAction": "Accountinstellingen openen",
"personalRecord": {
"badge": "PR",
"tooltip": "Persoonlijk record: {value} {unit} op {date}",
"benchmark": "Gebruikelijk op die leeftijd: {low} tot {high} {unit} ({source})"
},
"loadError": "Inzichten kunnen niet worden geladen",
"heroPersonalBaseline": "Op basis van je afgelopen 90 dagen",
"heroRegenerating": "Opnieuw genereren",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Historia przyjęć",
"importButton": "Importuj",
"rowActions": {
"openMenu": "Otwórz akcje wiersza",
"edit": "Edytuj",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Zaznacz wiersz"
},
"bulkDelete": {
"selectionCount": "{count} wpisów zaznaczono",
"deleteButton": "Usuń zaznaczone",
"cancelButton": "Anuluj",
"confirmTitle": "Usunąć zaznaczone wpisy?",
"confirmBody": "{count} wpisów zostanie trwale usuniętych. Sam lek pozostaje.",
"confirmAction": "Usuń",
"toast": "Zaznaczone usunięto",
"failed": "Nie udało się usunąć zaznaczonych."
"cancelButton": "Anuluj"
},
"edit": {
"dialogTitle": "Edytuj przyjęcie",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
"personalRecord": {
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}",
"benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})"
},
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerating": "Generowanie",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "Histórico de tomadas",
"importButton": "Importar",
"rowActions": {
"openMenu": "Abrir ações da linha",
"edit": "Editar",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Selecionar linha"
},
"bulkDelete": {
"selectionCount": "{count} entradas selecionadas",
"deleteButton": "Excluir seleção",
"cancelButton": "Cancelar",
"confirmTitle": "Excluir as entradas selecionadas?",
"confirmBody": "{count} entradas serão removidas permanentemente. O medicamento em si permanece.",
"confirmAction": "Excluir",
"toast": "Seleção excluída",
"failed": "Não foi possível excluir a seleção."
"cancelButton": "Cancelar"
},
"edit": {
"dialogTitle": "Editar tomada",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Defina sua altura para ver o IMC",
"bmiEmptyDescription": "O IMC precisa da sua altura. Adicione-a em Configurações → Conta.",
"bmiEmptyAction": "Abrir configurações da conta",
"personalRecord": {
"badge": "Recorde",
"tooltip": "Recorde pessoal: {value} {unit} em {date}",
"benchmark": "Típico nessa idade: {low} a {high} {unit} ({source})"
},
"loadError": "Falha ao carregar os insights",
"heroPersonalBaseline": "Com base nos seus últimos 90 dias",
"heroRegenerating": "Regenerando",
@@ -9314,16 +9301,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",
+1 -22
View File
@@ -1955,7 +1955,6 @@
},
"intake": {
"title": "История приёма",
"importButton": "Импорт",
"rowActions": {
"openMenu": "Открыть действия строки",
"edit": "Изменить",
@@ -1965,14 +1964,7 @@
"rowToggleLabel": "Выбрать строку"
},
"bulkDelete": {
"selectionCount": "Выбрано записей: {count}",
"deleteButton": "Удалить выбранное",
"cancelButton": "Отмена",
"confirmTitle": "Удалить выбранные записи?",
"confirmBody": "Записи ({count}) будут удалены навсегда. Само лекарство останется.",
"confirmAction": "Удалить",
"toast": "Выбранное удалено",
"failed": "Не удалось удалить выбранное."
"cancelButton": "Отмена"
},
"edit": {
"dialogTitle": "Изменить приём",
@@ -2846,11 +2838,6 @@
"bmiEmptyTitle": "Укажите рост, чтобы увидеть ИМТ",
"bmiEmptyDescription": "Для ИМТ нужен ваш рост. Добавьте его в разделе «Настройки» → «Аккаунт».",
"bmiEmptyAction": "Открыть настройки аккаунта",
"personalRecord": {
"badge": "Рекорд",
"tooltip": "Личный рекорд: {value} {unit} {date}",
"benchmark": "Типично в этом возрасте: {low} до {high} {unit} ({source})"
},
"loadError": "Не удалось загрузить аналитику",
"heroPersonalBaseline": "На основе ваших последних 90 дней",
"heroRegenerating": "Обновление",
@@ -9314,16 +9301,8 @@
},
"eventMedicationReminder": "Напоминание о лекарстве",
"eventMedicationReminderDesc": "Отправляется в каждое запланированное время приёма.",
"eventMeasurementAnomaly": "Необычное измерение",
"eventMeasurementAnomalyDesc": "Отправляется, когда измерение заметно выходит за ваш обычный диапазон.",
"eventComplianceLow": "Низкая приверженность",
"eventComplianceLowDesc": "Отправляется, когда приверженность за последние несколько дней падает ниже 80 %.",
"eventWithingsSyncFailed": "Сбой синхронизации Withings",
"eventWithingsSyncFailedDesc": "Отправляется, когда синхронизация Withings не удаётся несколько раз подряд.",
"eventSystemAlert": "Системное уведомление",
"eventSystemAlertDesc": "Общие сообщения от сервера HealthLog, например об окнах обслуживания или заметки об аккаунте.",
"eventPersonalRecord": "Личный рекорд",
"eventPersonalRecordDesc": "Отправляется, когда измерение превосходит ваш прежний лучший результат.",
"eventMoodReminder": "Напоминание о настроении",
"eventMoodReminderDesc": "Ежедневное напоминание отметить текущее настроение.",
"eventCyclePeriodSoon": "Скоро менструация",
@@ -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,
@@ -153,7 +153,6 @@
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
@@ -339,8 +338,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,
@@ -869,7 +869,6 @@
"components/insights/__tests__/metric-stat-strip.test.tsx": 3,
"components/insights/__tests__/metric-target-summary-unit-preference.test.tsx": 1,
"components/insights/__tests__/metric-target-summary.test.tsx": 8,
"components/insights/__tests__/personal-record-badge.test.tsx": 1,
"components/insights/__tests__/rhythm-events-card.test.tsx": 1,
"components/insights/__tests__/sleep-stage-stacked-bar.test.tsx": 5,
"components/insights/__tests__/sub-page-shell.test.tsx": 4,
@@ -1031,7 +1030,6 @@
"components/insights/nutrients/micronutrients-card.tsx": 3,
"components/insights/nutrients/types.ts": 4,
"components/insights/period-narrative-card.tsx": 1,
"components/insights/personal-record-badge.tsx": 2,
"components/insights/prose-blocks.tsx": 1,
"components/insights/recovery/recovery-metric-block.tsx": 2,
"components/insights/recovery/recovery-section.tsx": 3,
@@ -1127,7 +1125,6 @@
"components/medications/__tests__/intake-edit-dialog-seed.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2.test.tsx": 4,
"components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx": 2,
"components/medications/__tests__/inventory-section-actions.test.tsx": 1,
"components/medications/__tests__/inventory-section-summary.test.tsx": 2,
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
@@ -1163,7 +1160,6 @@
"components/medications/glp1-medication-card.tsx": 36,
"components/medications/injection-site-picker.tsx": 2,
"components/medications/intake-edit-dialog.tsx": 2,
"components/medications/intake-history-editable.tsx": 2,
"components/medications/intake-history-list-v2.tsx": 13,
"components/medications/intake-import-dialog.tsx": 1,
"components/medications/log-injection-site-dialog.tsx": 2,
@@ -1200,7 +1196,6 @@
"components/medications/sections/courses-section.tsx": 1,
"components/medications/sections/data-portability-row.tsx": 1,
"components/medications/sections/destructive-zone-section.tsx": 5,
"components/medications/sections/intake-history-preview.tsx": 3,
"components/medications/sections/intake-tracking-section.tsx": 1,
"components/medications/sections/inventory-dialogs.tsx": 3,
"components/medications/sections/inventory-section.tsx": 8,
@@ -2333,7 +2328,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,
@@ -2606,7 +2601,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,
@@ -2786,7 +2781,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,
@@ -234,7 +234,6 @@ describe("daily-reads refetch guard", () => {
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
@@ -528,13 +528,6 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/intake-history-editable.tsx",
destroys: "one intake event, or every selected intake event",
triggers: ["components/medications/intake-history-list-v2.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
-49
View File
@@ -15,7 +15,6 @@
* - insights.emptyState.* (B4 — per-metric empty states)
* - notifications.admin.* (B5 — admin notification messages)
* - notifications.user.* (B5 — user-facing Telegram test body)
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
@@ -46,18 +45,6 @@ for (const locale of LOCALES) {
FLAT_BY_LOCALE.set(locale, flat);
}
function keysForPrefix(prefix: string): Set<string> {
// Source of truth: EN. The locale-integrity test ensures every
// locale carries the same key shape, so EN's view is canonical.
const enFlat = FLAT_BY_LOCALE.get("en");
if (!enFlat) throw new Error("EN bundle did not load");
const set = new Set<string>();
for (const k of enFlat.keys()) {
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
}
return set;
}
const REQUIRED_KEY_SETS: Array<{
label: string;
required: string[];
@@ -143,39 +130,3 @@ describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale"
});
}
});
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
// BL-P4-8: PR and Workout strings were the trigger for the
// drift-guard ask. Personal-record badge / tooltip live under
// `insights.personalRecord.*`. Workout strings are not yet in the
// bundles — the v1.4.23 batch ingest landed the API, the UI
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
// namespace anchor live so the workout copy work in v1.4.28 lands
// with parity from the first commit.
const prKeys = [...keysForPrefix("insights.personalRecord")];
it("insights.personalRecord namespace has at least one key (sanity)", () => {
expect(
prKeys.length,
"insights.personalRecord.* keys exist in en.json",
).toBeGreaterThan(0);
});
it.each(LOCALES)(
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of prKeys) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the personal-record key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
@@ -444,12 +444,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/personal-records benchmark",
props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
pattern: /fresh\.benchmark/,
},
],
iosOnly: {
reason:
"the band was added for the native records screen (ios #116 item 5); no web surface reads `/api/personal-records` since the never-mounted badge that did was deleted",
},
},
{
field: "GET /api/mood/insights patterns",
@@ -284,9 +284,6 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
"src/components/medications/intake-history-editable.tsx": {
"toast.success": 2,
},
"src/components/medications/intake-history-list-v2.tsx": {
"text-success": 1,
},
+13 -8
View File
@@ -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
@@ -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<string, unknown> = MEDICATION,
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
@@ -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();
});
});
@@ -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", () => {
@@ -1,109 +0,0 @@
/**
* v1.4.25 — `<PersonalRecordBadge>` rendering.
*
* The badge renders a small "PR" pill when the user achieved an
* all-time best for the metric in the last 30 days. Tests cover:
* 1. Renders the pill when a fresh PR exists.
* 2. Stays silent when no PR exists for the metric.
* 3. Stays silent when the only PR is older than 30 days.
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
* 5. EN + DE locale parity.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// React-Query is mocked the same way the other insights tests do it —
// we control the `data` field per-test so the SSR tree is deterministic.
type Row = {
id: string;
metricType: string;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
};
let mockRows: Row[] = [];
vi.mock("@tanstack/react-query", () => ({
// `dataUpdatedAt` mirrors what TanStack Query produces after a
// successful refetch — `Date.now()` is fine inside the test
// (the React-purity rule is component-scoped).
useQuery: () => ({
data: mockRows,
isLoading: false,
dataUpdatedAt: Date.now(),
}),
}));
import { PersonalRecordBadge } from "../personal-record-badge";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function freshPR(over: Partial<Row> = {}): Row {
return {
id: "pr-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
value: 18432,
unit: "count",
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
...over,
};
}
describe("<PersonalRecordBadge>", () => {
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
mockRows = [freshPR()];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).toMatch(/data-slot="insights-pr-badge"/);
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
expect(html).toContain("PR");
});
it("renders nothing when no PR exists for the metric", () => {
mockRows = [];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("stays silent when the PR is older than 30 days", () => {
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
mockRows = [freshPR({ achievedAt: stale })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("ignores workout-slot rows on the plain-metric badge", () => {
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("uses the same 'PR' literal across EN and DE locales", () => {
mockRows = [freshPR()];
const en = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"en",
);
const de = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"de",
);
expect(en).toContain("PR");
expect(de).toContain("PR");
});
});
@@ -1,171 +0,0 @@
"use client";
/**
* v1.4.25 — Personal-record "PR" pill.
*
* Renders a small, text-only "PR" tag next to a metric label when the
* given metric reached an all-time best in the last 30 days. Pulls
* directly from `GET /api/personal-records?metricType=<type>` so the
* server's `PersonalRecord` rows are the single source of truth — no
* client-side recomputation, no risk of drift against the doctor
* report's PR list.
*
* Design choices:
* - Text-only (no celebratory emoji / no animation). The badge is
* informational and the same Recharts-restrained aesthetic the
* rest of the trend tiles use.
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
* tile strip already runs a `TooltipProvider`, so the badge can
* drop straight in. Standalone uses (correlation cards, doctor
* report) pass `withTooltip={false}` to keep the DOM minimal.
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
* small enough that 14 idle queries on the dashboard cost less
* than one Recharts re-render. TanStack Query coalesces.
*/
import { useQuery } from "@tanstack/react-query";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
import { ScorePill } from "@/components/ui/score-pill";
interface PersonalRecordRow {
id: string;
metricType: MeasurementType;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
/**
* v1.43 — the cited band for the age the record was set at, resolved on
* the server; null (or absent on an older server) when none applies.
*/
benchmark?: {
low: number;
high: number;
unit: string;
source: string;
sourceLabel: string;
} | null;
}
interface PersonalRecordBadgeProps {
metricType: MeasurementType;
/** Render a wrapping Tooltip with the PR value + date. Default true. */
withTooltip?: boolean;
/** Optional className passthrough so callers can position the pill. */
className?: string;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Pure freshness check kept out of the component body so the React
* purity linter doesn't trip on the `Date.now()` reference; callers
* pass `now` from the surrounding query refetch boundary where one
* stale-time tick = one new clock reading.
*/
function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
const ts = new Date(achievedAtIso).getTime();
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
}
export function PersonalRecordBadge({
metricType,
withTooltip = true,
className,
}: PersonalRecordBadgeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, dataUpdatedAt } = useQuery({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet<PersonalRecordRow[] | null>(
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
);
return data ?? [];
} catch {
// The badge is decorative — degrade to "no records", as before.
return [] as PersonalRecordRow[];
}
},
// Per-tile data: stale-while-revalidate cadence matches the
// dashboard's existing trend queries. 5 minutes is enough to
// absorb the worker's 30-minute fallback cadence without
// surfacing stale state for long.
staleTime: 5 * 60 * 1000,
});
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
// — a deterministic clock reading that React's purity linter
// accepts because it's hook-derived. Each refetch advances it,
// shifting the freshness window forward by exactly the stale time.
// On the very first render (before the query has resolved)
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null &&
isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
const pill = (
<ScorePill
tone="success"
numeric
data-slot="insights-pr-badge"
data-metric-type={metricType}
className={cn("tracking-wide uppercase", className)}
aria-label={t("insights.personalRecord.badge")}
>
{t("insights.personalRecord.badge")}
</ScorePill>
);
if (!withTooltip) return pill;
const formattedDate = fmt.date(new Date(fresh.achievedAt));
const tooltipText = t("insights.personalRecord.tooltip", {
value: fmt.number(fresh.value, 1),
unit: fresh.unit,
date: formattedDate,
});
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent
className="bg-muted border-border text-foreground"
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
{fresh.benchmark ? (
<span
className="text-muted-foreground block text-xs"
data-slot="insights-pr-badge-benchmark"
>
{t("insights.personalRecord.benchmark", {
low: fmt.number(fresh.benchmark.low, 1),
high: fmt.number(fresh.benchmark.high, 1),
unit: fresh.benchmark.unit,
source: fresh.benchmark.sourceLabel,
})}
</span>
) : null}
</TooltipContent>
</Tooltip>
);
}
@@ -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<string>(),
expandedDayKeys: new Set<string>(),
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(
<I18nProvider initialLocale="en">
<MeasurementRowActions
m={m}
rows={rows}
isGrouped={false}
isExpanded={false}
drilldownId="d1"
layout="desktop"
/>
</I18nProvider>,
);
}
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"');
},
);
});
@@ -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<Measurement, "source">): 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" : ""}`}
/>
</Button>
) : (
) : isServerComputed(m) ? null : (
<>
{canManage && (
<Button
@@ -1,108 +0,0 @@
/**
* v1.5.5 — `<IntakeHistoryPreview>` smoke tests.
*
* The preview mounts in SSR with an empty selection so the bulk-
* delete toolbar stays hidden by default. Selection / dialog flips
* are interactive surfaces and are exercised by the e2e suite.
*
* The kebab + selection wiring is covered by
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
* wrapper-level contract (no toolbar by default, wired through the
* v1.5.5 endpoints).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
function seed(client: QueryClient) {
client.setQueryData(
queryKeys.medicationIntakeList("med-1", {
sortBy: "takenAt",
sortDir: "desc",
limit: 14,
offset: 0,
status: "completed",
}),
{
events: [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
meta: { total: 1, limit: 14, offset: 0 },
},
);
}
function render(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
});
it("renders the row-action kebab and selection checkbox on every row", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
// Per-row affordances feature 15 + 16 — the row carries both.
expect(html).toContain("intake-history-row-kebab");
expect(html).toContain("intake-history-row-select");
// The import header CTA stays alongside the new affordances.
expect(html).toContain("intake-history-import");
});
});
@@ -1,283 +0,0 @@
"use client";
/**
* v1.7.0 — shared editable intake-history surface.
*
* Extracted from `<IntakeHistoryPreview>` so the detail-page preview
* (14-row, `takenAt` sort) and the full-history view (25-row,
* `scheduledFor` sort) share one state machine: multi-select
* bulk-delete, per-row edit (`<IntakeEditDialog>`) and per-row delete
* (`<AlertDialog>`). The component owns the table + every editing
* dialog; it does NOT own the CSV-import dialog (that stays lifted to
* the hosting page so it mounts exactly once).
*
* Both surfaces render the same `<IntakeHistoryListV2>`; the wrapper
* just changes `pageSize` + `defaultSortBy`. The full-history route
* defaults to `scheduledFor desc` so skipped rows (`takenAt: null`)
* never float to the top of the table (O-1).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import {
IntakeHistoryListV2,
type IntakeEvent,
type IntakeHistorySortKey,
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
medicationId: string;
/** Override the default page size — preview 14, full history 25. */
pageSize?: number;
/**
* Initial sort column. The full-history view passes `"scheduledFor"`
* so the chronological order stays clean even for skipped rows
* (`takenAt: null`); the preview keeps the legacy `"takenAt"`.
*/
defaultSortBy?: IntakeHistorySortKey;
}
export function IntakeHistoryEditable({
medicationId,
pageSize,
defaultSortBy,
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [rowDeleteBusy, setRowDeleteBusy] = useState(false);
function onToggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function clearSelection() {
setSelected(new Set());
}
async function confirmBulkDelete() {
if (bulkBusy || selected.size === 0) return;
setBulkBusy(true);
const ids = Array.from(selected);
try {
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
eventIds: ids,
});
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
clearSelection();
} catch (failure) {
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
cause: failure,
});
} finally {
setBulkBusy(false);
}
}
async function confirmRowDelete() {
if (rowDeleteBusy || !pendingDeleteId) return;
setRowDeleteBusy(true);
const id = pendingDeleteId;
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
// Drop the deleted row from the pending selection so the
// bulk-toolbar count stays honest.
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
} catch (failure) {
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
cause: failure,
});
} finally {
setRowDeleteBusy(false);
}
}
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{canManageMedications && selected.size > 0 && (
<div
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
role="region"
aria-label={t(
"medications.detail.intake.bulkDelete.selectionCount",
{ count: selected.size },
)}
data-slot="intake-history-bulk-delete-toolbar"
>
<span className="text-foreground text-sm font-medium">
{t("medications.detail.intake.bulkDelete.selectionCount", {
count: selected.size,
})}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={clearSelection}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-cancel"
>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setBulkConfirmOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-delete"
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.bulkDelete.deleteButton")}
</Button>
</div>
</div>
)}
<IntakeHistoryListV2
medicationId={medicationId}
pageSize={pageSize}
defaultSortBy={defaultSortBy}
onEditIntake={setEditingEvent}
onDeleteIntake={setPendingDeleteId}
selection={{
mode: "multi",
selected,
onToggle,
}}
/>
</div>
<IntakeEditDialog
medicationId={medicationId}
event={
editingEvent
? {
id: editingEvent.id,
takenAt: editingEvent.takenAt,
skipped: editingEvent.skipped,
scheduledFor: editingEvent.scheduledFor,
}
: null
}
onClose={() => setEditingEvent(null)}
/>
<AlertDialog
open={bulkConfirmOpen}
onOpenChange={(open) => {
if (!open) setBulkConfirmOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.bulkDelete.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.bulkDelete.confirmBody", {
count: selected.size,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
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")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(open) => {
if (!open) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.deleteRow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.deleteRow.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rowDeleteBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
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")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,74 +0,0 @@
"use client";
/**
* v1.5.5 D-3 §9.5 — Intake-history preview.
*
* Section chrome via `<MedicationDetailSection>`. Header CTA carries a
* CSV-import trigger. The body re-uses the shared
* `<IntakeHistoryEditable>` (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 (
<>
<MedicationDetailSection
titleId="medication-detail-intake-history-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
canManageMedications ? (
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
) : null
}
>
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
</MedicationDetailSection>
{importOpen && (
<IntakeImportDialog
medicationId={medicationId}
onClose={() => onImportOpenChange(false)}
/>
)}
</>
);
}
@@ -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();
});
});
@@ -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({
@@ -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"),
@@ -108,8 +111,10 @@ 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";
function medicationWithSchedule(
schedule: Record<string, unknown>,
@@ -304,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();
@@ -860,6 +913,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
@@ -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";
+44 -23
View File
@@ -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
@@ -47,6 +47,13 @@ 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";
import {
makeFormatters,
type DateFormatPreference,
type TimeFormatPreference,
} from "@/lib/format-locale";
export interface ReminderCheckPayload {
triggeredAt: string;
@@ -65,9 +72,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<typeof getWorkerPrisma>) {
return prisma.medication.findMany({
async function loadRemindableMedications(
prisma: ReturnType<typeof getWorkerPrisma>,
) {
const medications = await prisma.medication.findMany({
where: {
active: true,
asNeeded: false,
@@ -85,10 +98,45 @@ function loadRemindableMedications(prisma: ReturnType<typeof getWorkerPrisma>) {
// 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,
},
},
},
});
const moduleOff = new Set<string>();
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));
}
/**
* 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,
);
}
/**
@@ -594,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") {
@@ -644,7 +692,7 @@ export async function handleReminderCheck(
currentPhase,
med.name,
doseInfo,
timeWindow,
timeWindow(recipientLocale),
Math.ceil(minutesToEnd),
recipientLocale,
);
@@ -653,7 +701,7 @@ export async function handleReminderCheck(
currentPhase,
med.name,
doseInfo,
timeWindow,
timeWindow(locale),
Math.ceil(minutesToEnd),
locale,
);
@@ -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", () => {
@@ -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);
});
});
@@ -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<EventType, …>` 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<SentEventType, …>` 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<EventType> = 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);
});
});
+13 -10
View File
@@ -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<EventType, …>`, 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<SentEventType, …>`, 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<EventType, string | null> = {
export const NOTIFICATION_TAP_PATHS: Record<SentEventType, string | null> = {
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<Record<EventType, string | null>> =
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;
+39 -17
View File
@@ -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<EventType, boolean> = {
MEDICATION_REMINDER: true,
@@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
/** 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<ChannelType, string> = {
+2 -2
View File
@@ -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({
+76
View File
@@ -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;
}
+22 -44
View File
@@ -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 {
@@ -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,
}),
]);
});
});