diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..af2696232 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..f2431efbd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a2b612bad..53854bcb5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index af97df036..7ef41308c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index fed51449c..0914581c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..ca64435fc 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "생리 예정", diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..cd9ea2f36 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..d0f197732 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..4c63dec15 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..0ccc1e798 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -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": "Скоро менструация", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e6d9b833e..7705268f3 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1341, + "lib/telegram-webhook-handlers.ts": 1313, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..3fd584a0a 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..684b663f2 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 5e030daba..11879bff6 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -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", diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 1dd48b750..3bdc790fb 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.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", diff --git a/src/__tests__/i18n-drift-guard.test.ts b/src/__tests__/i18n-drift-guard.test.ts index 2b20160a0..934296bba 100644 --- a/src/__tests__/i18n-drift-guard.test.ts +++ b/src/__tests__/i18n-drift-guard.test.ts @@ -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 { - // 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(); - 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: [] }); - }, - ); -}); diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index ff204c0d8..0ff2f9ee6 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -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", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 44e33938c..8683ff27f 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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, }, diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 2a05c0a13..9d4518230 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -84,20 +84,25 @@ export const GET = apiHandler(async () => { globallyEnabled: globalToggles[ch.type] ?? true, })); + // Admin-only events (backup health) are listed for admins only, and an + // event nothing sends is listed for nobody: a switch for either would + // switch nothing. A stored row for an unlisted event stays in the table and + // is not served. + const eventTypes: string[] = eventTypesForRole(user.role); const preferences = channels.flatMap((ch) => - ch.preferences.map((p) => ({ - channelId: p.channelId, - eventType: p.eventType, - enabled: p.enabled, - })), + ch.preferences + .filter((p) => eventTypes.includes(p.eventType)) + .map((p) => ({ + channelId: p.channelId, + eventType: p.eventType, + enabled: p.enabled, + })), ); return apiSuccess({ channels: channelData, preferences, - // Admin-only events (backup health) are listed for admins only: nobody - // else is ever sent one, so a switch for it would switch nothing. - eventTypes: eventTypesForRole(user.role), + eventTypes, // What an event does on a channel with no stored row, resolved for this // account by the same function the dispatcher asks. A client renders a // switch without a row from this map, never from its own copy of the diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index d0dd4e058..4781f80c2 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -97,6 +97,8 @@ vi.mock("@/lib/medications/dose-day-refresh", () => ({ refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -112,6 +114,7 @@ import { } from "@/lib/mood/create-from-telegram"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +import { isModuleEnabled } from "@/lib/modules/gate"; const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; @@ -134,6 +137,7 @@ const MEDICATION = { id: "med-1", name: "Ramipril" }; function wireMedicationFindFirst( schedules: unknown[] = [], scheduleRevisions: unknown[] = [], + medication: Record = MEDICATION, ) { vi.mocked(prisma.medication.findFirst).mockImplementation(((args: { select?: Record; @@ -149,7 +153,7 @@ function wireMedicationFindFirst( scheduleRevisions, }); } - return Promise.resolve(MEDICATION); + return Promise.resolve(medication); }) as never); } @@ -162,6 +166,7 @@ beforeEach(() => { resetAt: Date.now() + 60_000, } as never); vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); wireMedicationFindFirst(); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.update).mockResolvedValue({} as never); @@ -1236,3 +1241,86 @@ describe("POST /api/telegram/webhook — record-only medications", () => { }); }); }); + +/** + * With the Medications module switched off the bot offers no dose and books + * none: the web shows no medication surface and the reminder jobs are + * silent, so a reminder button left in the chat, the /add list and the + * "taken" keyword do nothing either. + */ +describe("POST /api/telegram/webhook — Medications module off", () => { + function textUpdate(text: string) { + return { + update_id: 400, + message: { message_id: 40, text, chat: { id: 7777 }, from: { id: 7777 } }, + }; + } + + beforeEach(() => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + }); + + it.each(["taken:med-1", "skip:med-1", "add:med-1"])( + "the %s button books nothing", + async (data) => { + const res = await POST(tgRequest(callbackUpdate(data))); + expect(res.status).toBe(200); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }, + ); + + it("the snooze button snoozes nothing", async () => { + await POST(tgRequest(callbackUpdate("snooze:med-1:60"))); + expect(prisma.medication.update).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found/i, + ); + }); + + it("/add offers nothing", async () => { + await POST(tgRequest(textUpdate("/add"))); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /no active medications/i, + ); + }); + + it("the 'taken' keyword records nothing", async () => { + await POST(tgRequest(textUpdate("taken Ramipril"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +/** + * A reminder button left in the chat takes no dose for a course that has + * since ended, which would also take a unit off the stock. The list side is + * pinned in `src/lib/__tests__/telegram-dose-medications.test.ts`. + */ +describe("POST /api/telegram/webhook — ended course", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const ENDED = { + ...MEDICATION, + dose: "5 mg", + startsOn: new Date(Date.now() - 20 * DAY_MS), + endsOn: new Date(Date.now() - 3 * DAY_MS), + endedAt: null, + }; + it("the taken button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("taken:med-1"))); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2]).toMatch( + /not found or inactive/i, + ); + }); + + it("the skip button on an ended course books nothing", async () => { + wireMedicationFindFirst([], [], ENDED); + await POST(tgRequest(callbackUpdate("skip:med-1"))); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx index 83b3160cd..14017af1f 100644 --- a/src/app/notifications/__tests__/page.test.tsx +++ b/src/app/notifications/__tests__/page.test.tsx @@ -143,8 +143,8 @@ describe("NotificationsPage", () => { }, ], preferences: [], - eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"], - eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false }, + eventTypes: ["MOOD_REMINDER", "DAILY_BRIEFING"], + eventDefaults: { MOOD_REMINDER: true, DAILY_BRIEFING: false }, }; const html = render(); const state = (label: string) => @@ -152,10 +152,10 @@ describe("NotificationsPage", () => { `aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`, ).exec(html); const mood = state("Mood reminder"); - const record = state("Personal record"); + const briefing = state("Daily briefing"); expect(mood, "mood switch rendered").not.toBeNull(); expect(mood?.[1] ?? mood?.[2]).toBe("checked"); - expect(record?.[1] ?? record?.[2]).toBe("unchecked"); + expect(briefing?.[1] ?? briefing?.[2]).toBe("unchecked"); }); it("a stored row still wins over the default", () => { diff --git a/src/components/insights/__tests__/personal-record-badge.test.tsx b/src/components/insights/__tests__/personal-record-badge.test.tsx deleted file mode 100644 index d195b3673..000000000 --- a/src/components/insights/__tests__/personal-record-badge.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * v1.4.25 — `` 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( - {node}, - ); -} - -function freshPR(over: Partial = {}): 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("", () => { - it("renders the PR pill when an all-time best lands in the last 30 days", () => { - mockRows = [freshPR()]; - const html = render( - , - ); - 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( - , - ); - 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( - , - ); - 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( - , - ); - 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( - , - "en", - ); - const de = render( - , - "de", - ); - expect(en).toContain("PR"); - expect(de).toContain("PR"); - }); -}); diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx deleted file mode 100644 index dc5fc50b5..000000000 --- a/src/components/insights/personal-record-badge.tsx +++ /dev/null @@ -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=` 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( - `/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 = ( - - {t("insights.personalRecord.badge")} - - ); - - 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 ( - - {pill} - - {tooltipText} - {fresh.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, - })} - - ) : null} - - - ); -} diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx new file mode 100644 index 000000000..e481a9427 --- /dev/null +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -0,0 +1,75 @@ +/** + * A computed score offers neither edit nor delete in the measurement list; + * a reading of the person's own, or one a device or provider sent, keeps + * both. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canManageDomain: () => true, + inSharedRecord: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { Measurement } from "../list-model"; +import { + MeasurementRowActions, + type MeasurementRowContext, +} from "../row-actions"; + +const rows: MeasurementRowContext = { + canManage: true, + selectedIds: new Set(), + expandedDayKeys: new Set(), + toggleExpand: () => {}, + navigateForType: () => {}, + startEdit: () => {}, + onToggleRow: () => {}, + onDelete: () => {}, + // The row's value formatting; the actions never read it. + display: {} as MeasurementRowContext["display"], +}; + +function render(source: string): string { + const m: Measurement = { + id: "m1", + type: "RECOVERY_SCORE", + value: 85, + unit: "score", + source, + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }; + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementRowActions", () => { + it("offers no edit and no delete for a computed score", () => { + const html = render("COMPUTED"); + expect(html).not.toContain('aria-label="Edit"'); + expect(html).not.toContain('aria-label="Delete"'); + }); + + it.each(["MANUAL", "WITHINGS", "APPLE_HEALTH"])( + "keeps edit and delete for a %s reading", + (source) => { + const html = render(source); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete"'); + }, + ); +}); diff --git a/src/components/measurements/list/row-actions.tsx b/src/components/measurements/list/row-actions.tsx index 92a0c0244..cf40248d3 100644 --- a/src/components/measurements/list/row-actions.tsx +++ b/src/components/measurements/list/row-actions.tsx @@ -43,10 +43,22 @@ export function rowState(m: Measurement, rows: MeasurementRowContext) { }; } +/** + * A score the server computes (recovery, strain, stress) offers neither + * edit nor delete: its value is the engine's, the server refuses a changed + * one on save, and the edit sheet has nothing of the person's to correct. + * Readings from a device or a provider keep both: the server takes a note or + * time edit on them, and a wrong scale reading has to be removable. + */ +export function isServerComputed(m: Pick): boolean { + return m.source === "COMPUTED"; +} + /** * A row's controls: the chevron into a day's or night's samples for a - * grouped row; edit (owner only) and delete for a reading. The table's - * buttons are 40 px, the phone list's the large icon size. + * grouped row; edit (owner only) and delete for a reading, neither for a + * computed score. The table's buttons are 40 px, the phone list's the large + * icon size. */ export function MeasurementRowActions({ m, @@ -85,7 +97,7 @@ export function MeasurementRowActions({ className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} /> - ) : ( + ) : isServerComputed(m) ? null : ( <> {canManage && ( - - - - )} - - - - - setEditingEvent(null)} - /> - - { - if (!open) setBulkConfirmOpen(false); - }} - > - - - - {t("medications.detail.intake.bulkDelete.confirmTitle")} - - - {t("medications.detail.intake.bulkDelete.confirmBody", { - count: selected.size, - })} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmBulkDelete(); - }} - disabled={bulkBusy} - aria-busy={bulkBusy || undefined} - className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white" - > - {t("medications.detail.intake.bulkDelete.confirmAction")} - - - - - - { - if (!open) setPendingDeleteId(null); - }} - > - - - - {t("medications.detail.intake.deleteRow.confirmTitle")} - - - {t("medications.detail.intake.deleteRow.confirmBody")} - - - - - {t("medications.detail.intake.bulkDelete.cancelButton")} - - { - e.preventDefault(); - void confirmRowDelete(); - }} - disabled={rowDeleteBusy} - aria-busy={rowDeleteBusy || undefined} - className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white" - > - {t("medications.detail.intake.deleteRow.confirmAction")} - - - - - - ); -} diff --git a/src/components/medications/sections/intake-history-preview.tsx b/src/components/medications/sections/intake-history-preview.tsx deleted file mode 100644 index 45a53e1fa..000000000 --- a/src/components/medications/sections/intake-history-preview.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -/** - * v1.5.5 D-3 §9.5 — Intake-history preview. - * - * Section chrome via ``. Header CTA carries a - * CSV-import trigger. The body re-uses the shared - * `` (extracted in v1.7.0) so the table + sort + - * pagination + multi-select bulk-delete + per-row edit/delete stay - * identical between this preview and the full-history view. - * - * v1.7.0 — the footer "View full history →" link is gone; the detail - * header's History button now routes directly to the full view, so the - * redundant link is removed (R-medui §4.3). - */ - -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { Upload } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; -import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; -import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; -import { useTranslations } from "@/lib/i18n/context"; - -export interface IntakeHistoryPreviewProps { - medicationId: string; - importOpen: boolean; - onImportOpenChange: (open: boolean) => void; -} - -export function IntakeHistoryPreview({ - medicationId, - importOpen, - onImportOpenChange, -}: IntakeHistoryPreviewProps) { - const { t } = useTranslations(); - // v1.36.x — importing dose history is not a delegated verb. - const { canManageDomain } = useRecordCapabilities(); - const canManageMedications = canManageDomain("medications"); - - return ( - <> - onImportOpenChange(true)} - className="min-h-11 sm:min-h-9" - data-slot="intake-history-import" - > - - - {importOpen && ( - onImportOpenChange(false)} - /> - )} - - ); -} diff --git a/src/lib/__tests__/telegram-dose-medications.test.ts b/src/lib/__tests__/telegram-dose-medications.test.ts new file mode 100644 index 000000000..a8fceac86 --- /dev/null +++ b/src/lib/__tests__/telegram-dose-medications.test.ts @@ -0,0 +1,113 @@ +/** + * Which medications the Telegram bot may offer a dose for or record one + * against: tracked and active, with the Medications module on, and not past + * the end of the course on the person's clock. A course that has not started + * yet stays. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + moduleOn: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: db.findMany, findFirst: db.findFirst }, + }, +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: db.moduleOn })); + +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const day = (offset: number) => new Date(Date.now() + offset * DAY_MS); + +const CHRONIC = { + id: "chronic", + name: "Ramipril", + dose: "5 mg", + startsOn: null, + endsOn: null, + endedAt: null, +}; +const ENDED = { + ...CHRONIC, + id: "ended", + startsOn: day(-12), + endsOn: day(-2), +}; +const ENDED_NOW = { + ...CHRONIC, + id: "ended-now", + startsOn: day(-12), + endsOn: day(0), + endedAt: new Date(Date.now() - 60_000), +}; +const UPCOMING = { ...CHRONIC, id: "upcoming", startsOn: day(3) }; + +beforeEach(() => { + vi.clearAllMocks(); + db.moduleOn.mockResolvedValue(true); +}); + +describe("listTelegramDoseMedications", () => { + it("leaves an ended course out and keeps a current or upcoming one", async () => { + db.findMany.mockResolvedValue([CHRONIC, ENDED, ENDED_NOW, UPCOMING]); + const listed = await listTelegramDoseMedications("user-1", "Europe/Berlin"); + expect(listed.map((m) => m.id)).toEqual(["chronic", "upcoming"]); + expect(db.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", active: true, trackIntake: true }, + }), + ); + }); + + it("offers nothing with the Medications module off", async () => { + db.moduleOn.mockResolvedValue(false); + expect(await listTelegramDoseMedications("user-1", null)).toEqual([]); + expect(db.moduleOn).toHaveBeenCalledWith("user-1", "medications"); + expect(db.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("findTelegramDoseMedication", () => { + it("resolves a current course", async () => { + db.findFirst.mockResolvedValue(CHRONIC); + const found = await findTelegramDoseMedication("user-1", null, { + id: "chronic", + }); + expect(found?.id).toBe("chronic"); + expect(db.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: "chronic", + userId: "user-1", + active: true, + trackIntake: true, + }, + }), + ); + }); + + it("refuses an ended course", async () => { + db.findFirst.mockResolvedValue(ENDED_NOW); + expect( + await findTelegramDoseMedication("user-1", "Europe/Berlin", { + id: "ended-now", + }), + ).toBeNull(); + }); + + it("refuses everything with the Medications module off", async () => { + db.moduleOn.mockResolvedValue(false); + expect( + await findTelegramDoseMedication("user-1", null, { id: "chronic" }), + ).toBeNull(); + expect(db.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts index 5aa3d9b6c..7e0f6b33e 100644 --- a/src/lib/jobs/__tests__/medication-low-stock.test.ts +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -7,7 +7,10 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); + import type { PrismaClient } from "@/generated/prisma/client"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { buildLowStockPayload, decideLowStockAction, @@ -280,6 +283,7 @@ describe("runMedicationLowStockTick", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); }); it("notifies a below-threshold medication and stamps the crossing", async () => { @@ -426,6 +430,56 @@ describe("runMedicationLowStockTick", () => { expect(dispatch).not.toHaveBeenCalled(); }); + it("the Medications module switched off skips the user without reading medications", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [userRow()], + medications: [medRow()], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "medications"); + expect(summary.skippedModuleDisabled).toBe(1); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("skips a medication whose course has ended, keeps one not yet started", async () => { + const dispatch = okDispatch(); + const prisma = prismaMock({ + users: [{ ...userRow(), timezone: "Europe/Berlin" }], + medications: [ + medRow({ id: "med-ended", endsOn: daysAgo(3), startsOn: daysAgo(13) }), + medRow({ id: "med-ended-today", endsOn: now, endedAt: daysAgo(0.01) }), + medRow({ + id: "med-upcoming", + startsOn: new Date(now.getTime() + 3 * MS_PER_DAY), + }), + ], + }); + + const summary = await runMedicationLowStockTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + + expect(summary.skippedCourseEnded).toBe(2); + expect(summary.notified).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ medicationId: "med-upcoming" }), + }), + ); + }); + it("a schedule-less medication never notifies (no runway derivable)", async () => { const dispatch = okDispatch(); const prisma = prismaMock({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 5126e9765..053d222c3 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -99,6 +99,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); vi.mock("@/lib/notifications/reminder-dedup", () => ({ claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), @@ -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, @@ -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 diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 75f250ab7..3b3ba5a49 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -124,6 +124,9 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ })); vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "token") })); vi.mock("@/lib/telegram", () => ({ deleteMessage: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(true), +})); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 99b3fa8bf..16a8b2571 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -52,7 +52,11 @@ * follows the COACH_NUDGE posture (server-only, prefs-gated). * * Inactive / paused medications are skipped: nothing consumes their - * stock, so their runway is not falling. + * stock, so their runway is not falling. So is a medication whose course has + * ended on the person's clock; a course that has not started yet still warns, + * since its stock is about to be needed. And so is every medication of a + * person who switched the Medications module off, as the mood reminder does + * for the Mood module. * * As-needed (PRN) medications have no cadence and so no runway in days. * Once the runway math stopped counting a PRN schedule as a daily dose, @@ -86,6 +90,12 @@ import { coerceLocale, type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { annotate, getEvent } from "@/lib/logging/context"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * An as-needed medication warns when this many doses or fewer are left: no @@ -93,9 +103,23 @@ import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; */ export const AS_NEEDED_LOW_STOCK_DOSES = 2; +/** + * A medication whose stock the pass watches: active, unpaused, with intake + * tracked (#1033, no dose consumes the stock of one kept as a record) and + * with tracked inventory (any container ever registered). + */ +const WATCHED_MEDICATION_WHERE = { + active: true, + pausedAt: null, + ...TRACKED_INTAKE_WHERE, + inventoryItems: { some: {} }, +}; + export interface MedicationLowStockSummary { usersScanned: number; skippedThresholdOff: number; + skippedModuleDisabled: number; + skippedCourseEnded: number; medicationsEvaluated: number; notified: number; rearmed: number; @@ -376,6 +400,8 @@ export async function runMedicationLowStockTick( const summary: MedicationLowStockSummary = { usersScanned: 0, skippedThresholdOff: 0, + skippedModuleDisabled: 0, + skippedCourseEnded: 0, medicationsEvaluated: 0, notified: 0, rearmed: 0, @@ -389,22 +415,10 @@ export async function runMedicationLowStockTick( const notifiedMeta: LowStockEventMeta[] = []; const skippedMeta: LowStockEventMeta[] = []; - // Only users who own at least one ACTIVE, unpaused medication with - // tracked inventory (any container ever registered) are candidates. + // Only users who own at least one watched medication are candidates. const users = await prisma.user.findMany({ - where: activeAccount({ - medications: { - some: { - active: true, - pausedAt: null, - // v1.39.1 (#1033) — no low-stock notice for a medication with - // intake tracking off: no dose consumes its stock. - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, - }, - }), - select: { id: true, locale: true, notificationPrefs: true }, + where: activeAccount({ medications: { some: WATCHED_MEDICATION_WHERE } }), + select: { id: true, locale: true, timezone: true, notificationPrefs: true }, }); for (const user of users) { @@ -418,15 +432,15 @@ export async function runMedicationLowStockTick( summary.skippedThresholdOff += 1; continue; } + // After the threshold, so the module read runs only for users the + // alert is on for. + if (!(await isModuleEnabled(user.id, "medications"))) { + summary.skippedModuleDisabled += 1; + continue; + } const medications = await prisma.medication.findMany({ - where: { - userId: user.id, - active: true, - pausedAt: null, - ...TRACKED_INTAKE_WHERE, - inventoryItems: { some: {} }, - }, + where: { userId: user.id, ...WATCHED_MEDICATION_WHERE }, select: { id: true, name: true, @@ -436,6 +450,7 @@ export async function runMedicationLowStockTick( reorderLeadDays: true, lowStockNotifiedAt: true, lowStockNotifiedThresholdDays: true, + ...MEDICATION_WINDOW_SELECT, inventoryItems: { select: { state: true, unitsTotal: true, unitsRemaining: true }, }, @@ -458,7 +473,13 @@ export async function runMedicationLowStockTick( }, }); + const timeZone = user.timezone || DEFAULT_TIMEZONE; for (const med of medications) { + // Nothing consumes the stock of an ended course any more. + if (courseStatusAt(med, now, timeZone) === "ENDED") { + summary.skippedCourseEnded += 1; + continue; + } summary.medicationsEvaluated += 1; // Decimal → number for the per-slot units so the runway math and diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 2c9e85e5b..b57f86942 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -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) { - return prisma.medication.findMany({ +async function loadRemindableMedications( + prisma: ReturnType, +) { + const medications = await prisma.medication.findMany({ where: { active: true, asNeeded: false, @@ -85,10 +98,45 @@ function loadRemindableMedications(prisma: ReturnType) { // 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(); + 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, ); diff --git a/src/lib/notifications/__tests__/event-labels.test.ts b/src/lib/notifications/__tests__/event-labels.test.ts index ff2464768..b0ea9bd5a 100644 --- a/src/lib/notifications/__tests__/event-labels.test.ts +++ b/src/lib/notifications/__tests__/event-labels.test.ts @@ -1,7 +1,8 @@ /** * Every event type the /notifications matrix renders has words to render. * - * The matrix builds its rows from `EVENT_TYPES` and derives each row's label + * The matrix builds its rows from the event list the preferences read serves + * (`eventTypesForRole`) and derives each row's label * and description key from the event name, so a new event type silently gets a * row whether or not anybody wrote the two strings for it. That is not a * theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key @@ -20,7 +21,10 @@ import { describe, expect, it } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { EVENT_TYPES } from "@/lib/notifications/types"; +import { + UNSENT_EVENT_TYPES, + eventTypesForRole, +} from "@/lib/notifications/types"; import { eventDescriptionTranslationKey, eventTranslationKey, @@ -60,14 +64,16 @@ describe("notification event labels", () => { ); }); - it("has a label and a description for every event type, in every locale", () => { + it("has a label and a description for every listed event type, in every locale", () => { expect(LOCALES.length).toBeGreaterThanOrEqual(6); - expect(EVENT_TYPES.length).toBeGreaterThan(10); + // ADMIN sees the widest list: every event that has a switch at all. + const listed = eventTypesForRole("ADMIN"); + expect(listed.length).toBeGreaterThan(10); const missing: string[] = []; for (const locale of LOCALES) { const root = bundle(locale); - for (const eventType of EVENT_TYPES) { + for (const eventType of listed) { for (const key of [ eventTranslationKey(eventType), eventDescriptionTranslationKey(eventType), @@ -82,6 +88,24 @@ describe("notification event labels", () => { expect(missing).toEqual([]); }); + + it("keeps no label for an event nothing sends", () => { + const leftover: string[] = []; + for (const locale of LOCALES) { + const root = bundle(locale); + for (const eventType of UNSENT_EVENT_TYPES) { + for (const key of [ + eventTranslationKey(eventType), + eventDescriptionTranslationKey(eventType), + ]) { + if (resolve(root, key) !== undefined) { + leftover.push(`${locale} still has ${key}`); + } + } + } + } + expect(leftover).toEqual([]); + }); }); describe("the delegated-dose push copy", () => { diff --git a/src/lib/notifications/__tests__/event-types-for-role.test.ts b/src/lib/notifications/__tests__/event-types-for-role.test.ts new file mode 100644 index 000000000..9c0ea467f --- /dev/null +++ b/src/lib/notifications/__tests__/event-types-for-role.test.ts @@ -0,0 +1,40 @@ +/** + * The event types a person is shown a switch for. + * + * The preferences read lists `eventTypesForRole(role)` as the matrix rows and + * serves stored rows only for those. An event nothing sends is listed for + * nobody; backup health for admins only. + */ +import { describe, expect, it } from "vitest"; + +import { + EVENT_TYPES, + UNSENT_EVENT_TYPES, + eventTypesForRole, + isSentEventType, +} from "@/lib/notifications/types"; + +describe("eventTypesForRole", () => { + it.each(["USER", "ADMIN"])("lists no event nothing sends (%s)", (role) => { + const listed: string[] = eventTypesForRole(role); + expect(listed).toContain("MEDICATION_REMINDER"); + for (const unsent of UNSENT_EVENT_TYPES) { + expect(listed).not.toContain(unsent); + } + }); + + it("lists backup health for an admin only", () => { + expect(eventTypesForRole("USER")).not.toContain("BACKUP_HEALTH"); + expect(eventTypesForRole("ADMIN")).toContain("BACKUP_HEALTH"); + }); + + it("keeps the declaration order", () => { + expect(eventTypesForRole("ADMIN")).toEqual( + EVENT_TYPES.filter((type) => isSentEventType(type)), + ); + }); + + it("knows no event outside the union", () => { + expect(isSentEventType("NOT_AN_EVENT")).toBe(false); + }); +}); diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts index 258a4edce..31b903d67 100644 --- a/src/lib/notifications/__tests__/tap-target.test.ts +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -1,9 +1,9 @@ /** * The event → tap-target map and its resolver. * - * Walks `EVENT_TYPES` rather than the map's own keys, so an event type added - * to the union without a decision about where it leads fails here (the - * `Record` type already refuses to compile without one; this + * Walks the sent event types rather than the map's own keys, so an event type + * added to the union without a decision about where it leads fails here (the + * `Record` type already refuses to compile without one; this * pins that every entry is also a usable path). */ import { afterEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +19,8 @@ import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy"; import { buildWebhookBody } from "@/lib/notifications/senders/webhook"; import { EVENT_TYPES, + UNSENT_EVENT_TYPES, + isSentEventType, type EventType, type NotificationPayload, } from "@/lib/notifications/types"; @@ -35,6 +37,8 @@ import { /** Events never shown to the user, so they carry no target. */ const SILENT: ReadonlySet = new Set(["MEDICATION_INTAKE_SYNC"]); +const SENT = EVENT_TYPES.filter(isSentEventType); + const ORIGINAL = { APP_URL: process.env.APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, @@ -48,9 +52,9 @@ afterEach(() => { }); describe("NOTIFICATION_TAP_PATHS", () => { - it("names a target for every event type", () => { - expect(EVENT_TYPES.length).toBeGreaterThan(0); - for (const eventType of EVENT_TYPES) { + it("names a target for every sent event type", () => { + expect(SENT.length).toBeGreaterThan(0); + for (const eventType of SENT) { expect( Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType), `${eventType} has no tap target`, @@ -68,17 +72,20 @@ describe("NOTIFICATION_TAP_PATHS", () => { it("keeps the targets the issue names", () => { expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1"); expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/"); - expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe( - "/settings/integrations", - ); expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications"); }); - it("has no entry for an event type outside the union", () => { + it("has no entry for an event type outside the sent set", () => { expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual( - [...EVENT_TYPES].sort(), + [...SENT].sort(), ); }); + + it("gives an event nothing sends no target", () => { + for (const eventType of UNSENT_EVENT_TYPES) { + expect(resolveTapPath({ eventType }), eventType).toBeNull(); + } + }); }); describe("resolveTapPath", () => { @@ -212,7 +219,7 @@ describe("tap target on every kind and channel", () => { it.each(variants)("$name", async ({ extra, neutral }) => { process.env.APP_URL = "https://health.example"; let checked = 0; - for (const eventType of EVENT_TYPES) { + for (const eventType of SENT) { const payload = await withDeliveryLinks( { userId: "u1", title: "t", message: "m", eventType, ...extra }, "guardian-1", @@ -244,6 +251,6 @@ describe("tap target on every kind and channel", () => { ).toBe(url); checked += 1; } - expect(checked).toBe(EVENT_TYPES.length - SILENT.size); + expect(checked).toBe(SENT.length - SILENT.size); }); }); diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index 60e1ac7c1..5e894ca8e 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -13,29 +13,30 @@ * - Webhook → `url` in the body (absolute URL) * - Email → a link line under the body (absolute URL) * - * The map is typed `Record`, so a new event type without a - * target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a - * target that stops resolving fails there too. + * The map is typed `Record`, so a new event type without a + * target does not compile, and `tap-target.test.ts` walks the sent event + * types so a target that stops resolving fails there too. An event type + * nothing sends (`UNSENT_EVENT_TYPES`) has no entry and resolves to no target. * * A producer may name a more precise path in `metadata.url` (the low-stock * alert opens the medication's stock tab, the Coach nudge opens its own * conversation). That override is accepted only as a same-origin path, so a * value that names another host can never become a click target. */ -import type { EventType, NotificationPayload } from "@/lib/notifications/types"; +import type { + EventType, + NotificationPayload, + SentEventType, +} from "@/lib/notifications/types"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; /** * The default in-app path per event type. `null` means the event is never * shown to the user (a silent background push), so it carries no target. */ -export const NOTIFICATION_TAP_PATHS: Record = { +export const NOTIFICATION_TAP_PATHS: Record = { MEDICATION_REMINDER: "/medications", - MEASUREMENT_ANOMALY: "/measurements", - COMPLIANCE_LOW: "/insights/medications", - WITHINGS_SYNC_FAILED: "/settings/integrations", SYSTEM_ALERT: "/", - PERSONAL_RECORD: "/achievements", // `?add=1` opens the mood entry sheet on the first paint, and only in the // recipient's own record: a session switched into another record opens the // page without the sheet (`src/app/mood/page-client.tsx`). No other target @@ -97,7 +98,9 @@ export function resolveTapPath( "eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId" >, ): string | null { - const fallback = NOTIFICATION_TAP_PATHS[payload.eventType]; + const paths: Partial> = + NOTIFICATION_TAP_PATHS; + const fallback = paths[payload.eventType]; if (fallback === null || fallback === undefined) return null; if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH; if (payload.discreet) return NEUTRAL_TAP_PATH; diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ea05bf42a..a86526350 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -2,9 +2,8 @@ import type { Locale } from "@/lib/i18n/config"; import type { NotificationQuickAction } from "@/lib/notifications/action-token"; /** - * Notification system type definitions. - * Channels: Telegram, ntfy, Web Push - * Events: medication reminders, anomalies, compliance, etc. + * Notification system type definitions: the channels a notification travels + * on and the event types it can carry. */ export const CHANNEL_TYPES = [ @@ -30,13 +29,12 @@ export type ChannelType = (typeof CHANNEL_TYPES)[number]; export const EVENT_TYPES = [ "MEDICATION_REMINDER", + // The next three and PERSONAL_RECORD are never sent; see + // `UNSENT_EVENT_TYPES`. "MEASUREMENT_ANOMALY", "COMPLIANCE_LOW", "WITHINGS_SYNC_FAILED", "SYSTEM_ALERT", - // v1.4.25 — PR detection. Default OFF (see DEFAULT_EVENT_PREFS - // below) so a multi-year Apple Health backfill doesn't fire hundreds - // of pushes during initial sync. "PERSONAL_RECORD", // v0.5.4 ios-coord — daily mood-reminder nudge. Default OFF on the // per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated @@ -112,8 +110,7 @@ export const EVENT_TYPES = [ // a deterministic floor). It is a nudge, NEVER the anomaly channel: it is // dispatched non-urgent, fired at most once per user per local day (the // `push_attempts` ledger is the frequency anchor), and only inside a local - // morning window, so a concerning reading always stays on - // `MEASUREMENT_ANOMALY` and this never bypasses Focus. + // morning window, so it never bypasses Focus. "DAILY_BRIEFING", // v1.36.x — somebody with a write grant on this record marked one of the // owner's doses. The only notification a delegated write produces: every @@ -154,11 +151,9 @@ export type ManagedGuardianFanoutEvent = * exists. The dispatcher reads this to decide whether to fire on a * channel that has never been explicitly toggled for the event. * - * Every event defaults to ON (the original opt-out model) EXCEPT - * `PERSONAL_RECORD`: PRs are silent by default and require the user - * to explicitly enable them in `/settings/notifications`. Rationale — - * a fresh iOS install backfilling years of HealthKit history would - * otherwise saturate the user's lock-screen on first launch. + * Events default to ON (the original opt-out model); the ones that + * default OFF say why beside their entry. The entries for + * `UNSENT_EVENT_TYPES` decide nothing, since nothing dispatches them. */ export const EVENT_DEFAULT_ENABLED: Record = { MEDICATION_REMINDER: true, @@ -229,11 +224,38 @@ export const EVENT_DEFAULT_ENABLED: Record = { /** Event types only an admin is ever sent, and only an admin is shown. */ export const ADMIN_ONLY_EVENT_TYPES: readonly EventType[] = ["BACKUP_HEALTH"]; +/** + * Event types nothing sends. They stay in `EVENT_TYPES` because the + * preference write and stored preference rows still name them, but nobody is + * shown a switch for them and they carry no tap target: a switch for an event + * nobody emits would switch nothing. Withings failures reach admins as + * `SYSTEM_ALERT`. + */ +export const UNSENT_EVENT_TYPES = [ + "MEASUREMENT_ANOMALY", + "COMPLIANCE_LOW", + "WITHINGS_SYNC_FAILED", + "PERSONAL_RECORD", +] as const satisfies readonly EventType[]; + +/** The event types something sends. */ +export type SentEventType = Exclude< + EventType, + (typeof UNSENT_EVENT_TYPES)[number] +>; + +export function isSentEventType(type: string): type is SentEventType { + return ( + (EVENT_TYPES as readonly string[]).includes(type) && + !(UNSENT_EVENT_TYPES as readonly string[]).includes(type) + ); +} + /** The event types a person with `role` can receive, in matrix order. */ -export function eventTypesForRole(role: string): EventType[] { - return role === "ADMIN" - ? [...EVENT_TYPES] - : EVENT_TYPES.filter((type) => !ADMIN_ONLY_EVENT_TYPES.includes(type)); +export function eventTypesForRole(role: string): SentEventType[] { + return EVENT_TYPES.filter(isSentEventType).filter( + (type) => role === "ADMIN" || !ADMIN_ONLY_EVENT_TYPES.includes(type), + ); } export const CHANNEL_TYPE_LABELS: Record = { diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index 7c639e155..b3ab7d429 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -76,7 +76,7 @@ const notificationPreferencesResponse = z eventTypes: z .array(z.string()) .describe( - "Every event category the server knows, in declaration order — the list a client renders rows for.", + "The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.", ), eventDefaults: z .record(eventTypeEnum, z.boolean()) @@ -87,7 +87,7 @@ const notificationPreferencesResponse = z .meta({ id: "NotificationPreferencesResponse", description: - "The caller's notification channels, their stored per-event overrides, and the full event vocabulary.", + "The caller's notification channels, their stored per-event overrides, and the event categories they can switch.", }); const notificationPreferenceRequest = notificationPreferenceSchema.meta({ diff --git a/src/lib/telegram-dose-medications.ts b/src/lib/telegram-dose-medications.ts new file mode 100644 index 000000000..7820d860c --- /dev/null +++ b/src/lib/telegram-dose-medications.ts @@ -0,0 +1,76 @@ +/** + * The medications the Telegram bot may offer a dose for or record one + * against: the `/add` list, the "taken" keyword, and the reminder buttons. + * + * Active and with intake tracked, as every bot path has asked since #1033, + * and two conditions the bot used to skip: + * + * - The Medications module is on. With it off the web shows no medication + * surface and the reminder jobs send nothing, so the bot offers and books + * nothing either; re-enabling the module finds every row where it was. + * - The course has not ended on the person's clock (`courseStatusAt`). The + * web card stops offering a dose then, and a dose booked through the bot + * after the end would take a unit off the stock. A course that has not + * started yet is left to the bot as before. + * + * A paused medication is `active: false` (pausing writes both), so the + * `active` filter already keeps it out. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + MEDICATION_WINDOW_SELECT, + courseStatusAt, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const DOSE_MEDICATION_SELECT = { + id: true, + name: true, + dose: true, + ...MEDICATION_WINDOW_SELECT, +} as const; + +function courseEnded( + medication: { + startsOn: Date | null; + endsOn: Date | null; + endedAt: Date | null; + }, + timeZone: string | null, +): boolean { + return ( + courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) === + "ENDED" + ); +} + +/** Every medication the person may pick a dose from, by name. */ +export async function listTelegramDoseMedications( + userId: string, + timeZone: string | null, +) { + if (!(await isModuleEnabled(userId, "medications"))) return []; + const medications = await prisma.medication.findMany({ + where: { userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + orderBy: [{ name: "asc" }, { id: "asc" }], + }); + return medications.filter((m) => !courseEnded(m, timeZone)); +} + +/** The one medication `where` names, or null when the bot may not act on it. */ +export async function findTelegramDoseMedication( + userId: string, + timeZone: string | null, + where: Prisma.MedicationWhereInput, +) { + if (!(await isModuleEnabled(userId, "medications"))) return null; + const medication = await prisma.medication.findFirst({ + where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE }, + select: DOSE_MEDICATION_SELECT, + }); + return medication && !courseEnded(medication, timeZone) ? medication : null; +} diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index b1f2e920e..c152bff52 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -37,10 +37,12 @@ import { isTelegramCapturableType, logTelegramMeasurement, } from "@/lib/measurements/create-from-telegram"; -// v1.39.1 (#1033) — every Telegram path that offers, records or snoozes a -// dose (the /add list, the "taken" keyword, the reminder buttons) leaves a -// medication kept as a record out, as the web and the today list do. -import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +// Every Telegram path that offers, records or snoozes a dose resolves the +// medication here: tracked, active, course not ended, Medications module on. +import { + findTelegramDoseMedication, + listTelegramDoseMedications, +} from "@/lib/telegram-dose-medications"; import { GREETING_WORDS, HELP_WORDS } from "@/lib/telegram-bare-words"; /** @@ -264,9 +266,8 @@ async function markMedicationTaken( reminderRef?: { chatId: string; messageId: number }, ): Promise<{ ok: boolean; message: string; medicationName?: string }> { const { t } = getServerTranslator(locale); - const medication = await prisma.medication.findFirst({ - where: { id: medicationId, userId, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, + const medication = await findTelegramDoseMedication(userId, userTz, { + id: medicationId, }); if (!medication) { return { ok: false, message: t("telegram.errorMedicationInactive") }; @@ -481,15 +482,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -528,15 +525,11 @@ export async function handleCallback(update: TelegramUpdate) { return; } - const medication = await prisma.medication.findFirst({ - where: { - id: medicationId, - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - }, - select: { id: true, name: true }, - }); + const medication = await findTelegramDoseMedication( + user.id, + user.timezone, + { id: medicationId }, + ); if (!medication) { await answerTelegramCallbackQuery( botToken, @@ -1155,11 +1148,7 @@ export async function handleTextMessage(update: TelegramUpdate) { if (/^\/add\b/i.test(text)) { const userMsgId = message?.message_id; - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true, dose: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 0) { const resp = await sendTelegramMessage( @@ -1260,23 +1249,12 @@ export async function handleTextMessage(update: TelegramUpdate) { let medicationId: string | null = null; if (nameInput) { - const med = await prisma.medication.findFirst({ - where: { - userId: user.id, - active: true, - ...TRACKED_INTAKE_WHERE, - name: { equals: nameInput, mode: "insensitive" }, - }, - select: { id: true }, + const med = await findTelegramDoseMedication(user.id, user.timezone, { + name: { equals: nameInput, mode: "insensitive" }, }); medicationId = med?.id ?? null; } else { - const meds = await prisma.medication.findMany({ - where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, - select: { id: true, name: true }, - orderBy: [{ name: "asc" }, { id: "asc" }], - take: 2, - }); + const meds = await listTelegramDoseMedications(user.id, user.timezone); if (meds.length === 1) { medicationId = meds[0].id; } else { diff --git a/tests/integration/notification-event-defaults.test.ts b/tests/integration/notification-event-defaults.test.ts index 588a4700b..be8d325e3 100644 --- a/tests/integration/notification-event-defaults.test.ts +++ b/tests/integration/notification-event-defaults.test.ts @@ -131,3 +131,49 @@ describe("event defaults: the matrix shows what delivery does", () => { ); }); }); + +describe("events nothing sends", () => { + it("are listed for nobody, and their stored rows are not served", async () => { + const userId = await seedAccount(false); + const prisma = getPrismaClient(); + const channel = await prisma.notificationChannel.findFirstOrThrow({ + where: { userId }, + }); + // Rows written while the four switches were still offered. + await prisma.notificationPreference.createMany({ + data: [ + { channelId: channel.id, eventType: "PERSONAL_RECORD", enabled: true }, + { + channelId: channel.id, + eventType: "MEASUREMENT_ANOMALY", + enabled: false, + }, + { + channelId: channel.id, + eventType: "MEDICATION_REMINDER", + enabled: false, + }, + ], + }); + + const { GET } = await import("@/app/api/notifications/preferences/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + eventTypes: string[]; + preferences: { eventType: string; enabled: boolean }[]; + }; + }; + const { UNSENT_EVENT_TYPES } = await import("@/lib/notifications/types"); + for (const unsent of UNSENT_EVENT_TYPES) { + expect(body.data.eventTypes).not.toContain(unsent); + } + expect(body.data.preferences).toEqual([ + expect.objectContaining({ + eventType: "MEDICATION_REMINDER", + enabled: false, + }), + ]); + }); +});