mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/v1473-switches' into release/v1.47.3
This commit is contained in:
commit
493154817d
43 files changed
+843
-1178
No files matched your search
+1
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user