mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
chore: remove three components nothing renders
personal-record-badge.tsx, intake-history-editable.tsx and intake-history-preview.tsx had no importer outside their own tests and the preview, so they rendered nowhere while four guard tests kept them listed as live surfaces. They go with their tests, their guard entries and the strings only they used. GET /api/personal-records stays: the native app reads it. The resolved-field guard now records its benchmark band as a native-only field instead of naming the deleted badge as its reader.
This commit is contained in:
1 parent
af673a63da
commit
5449933c4f
22 files changed
+14
-957
No files matched your search
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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": "다시 생성 중",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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": "Обновление",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -234,7 +234,6 @@ describe("daily-reads refetch guard", () => {
|
||||
const routedThroughHelper = [
|
||||
"components/medications/take-all-due.ts",
|
||||
"components/medications/intake-edit-dialog.tsx",
|
||||
"components/medications/intake-history-editable.tsx",
|
||||
"components/medications/intake-import-dialog.tsx",
|
||||
"components/medications/sections/destructive-zone-section.tsx",
|
||||
"components/medications/use-medication-intake.ts",
|
||||
|
||||
@@ -528,13 +528,6 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/intake-history-editable.tsx",
|
||||
destroys: "one intake event, or every selected intake event",
|
||||
triggers: ["components/medications/intake-history-list-v2.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/medications/dose-history/use-dose-history-ledger.ts",
|
||||
destroys: "one intake event",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
* - insights.emptyState.* (B4 — per-metric empty states)
|
||||
* - notifications.admin.* (B5 — admin notification messages)
|
||||
* - notifications.user.* (B5 — user-facing Telegram test body)
|
||||
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -46,18 +45,6 @@ for (const locale of LOCALES) {
|
||||
FLAT_BY_LOCALE.set(locale, flat);
|
||||
}
|
||||
|
||||
function keysForPrefix(prefix: string): Set<string> {
|
||||
// Source of truth: EN. The locale-integrity test ensures every
|
||||
// locale carries the same key shape, so EN's view is canonical.
|
||||
const enFlat = FLAT_BY_LOCALE.get("en");
|
||||
if (!enFlat) throw new Error("EN bundle did not load");
|
||||
const set = new Set<string>();
|
||||
for (const k of enFlat.keys()) {
|
||||
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
const REQUIRED_KEY_SETS: Array<{
|
||||
label: string;
|
||||
required: string[];
|
||||
@@ -143,39 +130,3 @@ describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
|
||||
// BL-P4-8: PR and Workout strings were the trigger for the
|
||||
// drift-guard ask. Personal-record badge / tooltip live under
|
||||
// `insights.personalRecord.*`. Workout strings are not yet in the
|
||||
// bundles — the v1.4.23 batch ingest landed the API, the UI
|
||||
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
|
||||
// namespace anchor live so the workout copy work in v1.4.28 lands
|
||||
// with parity from the first commit.
|
||||
const prKeys = [...keysForPrefix("insights.personalRecord")];
|
||||
|
||||
it("insights.personalRecord namespace has at least one key (sanity)", () => {
|
||||
expect(
|
||||
prKeys.length,
|
||||
"insights.personalRecord.* keys exist in en.json",
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(LOCALES)(
|
||||
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
|
||||
(locale) => {
|
||||
const flat = FLAT_BY_LOCALE.get(locale)!;
|
||||
const missing: string[] = [];
|
||||
const empty: string[] = [];
|
||||
for (const key of prKeys) {
|
||||
const v = flat.get(key);
|
||||
if (v === undefined) missing.push(key);
|
||||
else if (v.trim() === "") empty.push(key);
|
||||
}
|
||||
expect(
|
||||
{ missing, empty },
|
||||
`Locale ${locale} drifted from the personal-record key set.`,
|
||||
).toEqual({ missing: [], empty: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -444,12 +444,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
|
||||
{
|
||||
field: "GET /api/personal-records benchmark",
|
||||
props: ["awards.ts#benchmark"],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/personal-record-badge.tsx",
|
||||
pattern: /fresh\.benchmark/,
|
||||
},
|
||||
],
|
||||
iosOnly: {
|
||||
reason:
|
||||
"the band was added for the native records screen (ios #116 item 5); no web surface reads `/api/personal-records` since the never-mounted badge that did was deleted",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "GET /api/mood/insights patterns",
|
||||
|
||||
@@ -284,9 +284,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 4,
|
||||
},
|
||||
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
|
||||
"src/components/medications/intake-history-editable.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 — `<PersonalRecordBadge>` rendering.
|
||||
*
|
||||
* The badge renders a small "PR" pill when the user achieved an
|
||||
* all-time best for the metric in the last 30 days. Tests cover:
|
||||
* 1. Renders the pill when a fresh PR exists.
|
||||
* 2. Stays silent when no PR exists for the metric.
|
||||
* 3. Stays silent when the only PR is older than 30 days.
|
||||
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
|
||||
* 5. EN + DE locale parity.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// React-Query is mocked the same way the other insights tests do it —
|
||||
// we control the `data` field per-test so the SSR tree is deterministic.
|
||||
type Row = {
|
||||
id: string;
|
||||
metricType: string;
|
||||
metricSlot: string | null;
|
||||
value: number;
|
||||
unit: string;
|
||||
achievedAt: string;
|
||||
};
|
||||
let mockRows: Row[] = [];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
// `dataUpdatedAt` mirrors what TanStack Query produces after a
|
||||
// successful refetch — `Date.now()` is fine inside the test
|
||||
// (the React-purity rule is component-scoped).
|
||||
useQuery: () => ({
|
||||
data: mockRows,
|
||||
isLoading: false,
|
||||
dataUpdatedAt: Date.now(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PersonalRecordBadge } from "../personal-record-badge";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function freshPR(over: Partial<Row> = {}): Row {
|
||||
return {
|
||||
id: "pr-1",
|
||||
metricType: "ACTIVITY_STEPS",
|
||||
metricSlot: null,
|
||||
value: 18432,
|
||||
unit: "count",
|
||||
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<PersonalRecordBadge>", () => {
|
||||
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
|
||||
mockRows = [freshPR()];
|
||||
const html = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-pr-badge"/);
|
||||
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
|
||||
expect(html).toContain("PR");
|
||||
});
|
||||
|
||||
it("renders nothing when no PR exists for the metric", () => {
|
||||
mockRows = [];
|
||||
const html = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
);
|
||||
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
|
||||
});
|
||||
|
||||
it("stays silent when the PR is older than 30 days", () => {
|
||||
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
|
||||
mockRows = [freshPR({ achievedAt: stale })];
|
||||
const html = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
);
|
||||
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
|
||||
});
|
||||
|
||||
it("ignores workout-slot rows on the plain-metric badge", () => {
|
||||
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
|
||||
const html = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
);
|
||||
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
|
||||
});
|
||||
|
||||
it("uses the same 'PR' literal across EN and DE locales", () => {
|
||||
mockRows = [freshPR()];
|
||||
const en = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
"en",
|
||||
);
|
||||
const de = render(
|
||||
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
|
||||
"de",
|
||||
);
|
||||
expect(en).toContain("PR");
|
||||
expect(de).toContain("PR");
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.25 — Personal-record "PR" pill.
|
||||
*
|
||||
* Renders a small, text-only "PR" tag next to a metric label when the
|
||||
* given metric reached an all-time best in the last 30 days. Pulls
|
||||
* directly from `GET /api/personal-records?metricType=<type>` so the
|
||||
* server's `PersonalRecord` rows are the single source of truth — no
|
||||
* client-side recomputation, no risk of drift against the doctor
|
||||
* report's PR list.
|
||||
*
|
||||
* Design choices:
|
||||
* - Text-only (no celebratory emoji / no animation). The badge is
|
||||
* informational and the same Recharts-restrained aesthetic the
|
||||
* rest of the trend tiles use.
|
||||
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
|
||||
* tile strip already runs a `TooltipProvider`, so the badge can
|
||||
* drop straight in. Standalone uses (correlation cards, doctor
|
||||
* report) pass `withTooltip={false}` to keep the DOM minimal.
|
||||
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
|
||||
* small enough that 14 idle queries on the dashboard cost less
|
||||
* than one Recharts re-render. TanStack Query coalesces.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
|
||||
interface PersonalRecordRow {
|
||||
id: string;
|
||||
metricType: MeasurementType;
|
||||
metricSlot: string | null;
|
||||
value: number;
|
||||
unit: string;
|
||||
achievedAt: string;
|
||||
/**
|
||||
* v1.43 — the cited band for the age the record was set at, resolved on
|
||||
* the server; null (or absent on an older server) when none applies.
|
||||
*/
|
||||
benchmark?: {
|
||||
low: number;
|
||||
high: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PersonalRecordBadgeProps {
|
||||
metricType: MeasurementType;
|
||||
/** Render a wrapping Tooltip with the PR value + date. Default true. */
|
||||
withTooltip?: boolean;
|
||||
/** Optional className passthrough so callers can position the pill. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pure freshness check kept out of the component body so the React
|
||||
* purity linter doesn't trip on the `Date.now()` reference; callers
|
||||
* pass `now` from the surrounding query refetch boundary where one
|
||||
* stale-time tick = one new clock reading.
|
||||
*/
|
||||
function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
|
||||
const ts = new Date(achievedAtIso).getTime();
|
||||
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
|
||||
}
|
||||
|
||||
export function PersonalRecordBadge({
|
||||
metricType,
|
||||
withTooltip = true,
|
||||
className,
|
||||
}: PersonalRecordBadgeProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, dataUpdatedAt } = useQuery({
|
||||
// Per-metric queryKey so two tiles for two different metrics never
|
||||
// share a cache slot (HealthLog convention — see
|
||||
// `feedback_react_query_key_collision`).
|
||||
queryKey: queryKeys.personalRecordsByMetric(metricType),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await apiGet<PersonalRecordRow[] | null>(
|
||||
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
|
||||
);
|
||||
return data ?? [];
|
||||
} catch {
|
||||
// The badge is decorative — degrade to "no records", as before.
|
||||
return [] as PersonalRecordRow[];
|
||||
}
|
||||
},
|
||||
// Per-tile data: stale-while-revalidate cadence matches the
|
||||
// dashboard's existing trend queries. 5 minutes is enough to
|
||||
// absorb the worker's 30-minute fallback cadence without
|
||||
// surfacing stale state for long.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
|
||||
// — a deterministic clock reading that React's purity linter
|
||||
// accepts because it's hook-derived. Each refetch advances it,
|
||||
// shifting the freshness window forward by exactly the stale time.
|
||||
// On the very first render (before the query has resolved)
|
||||
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
|
||||
// until the response lands — desired behaviour for the SSR path.
|
||||
const fresh = (data ?? []).find(
|
||||
(row) =>
|
||||
row.metricSlot === null &&
|
||||
isPRWithin30Days(row.achievedAt, dataUpdatedAt),
|
||||
);
|
||||
|
||||
if (!fresh) return null;
|
||||
|
||||
const pill = (
|
||||
<ScorePill
|
||||
tone="success"
|
||||
numeric
|
||||
data-slot="insights-pr-badge"
|
||||
data-metric-type={metricType}
|
||||
className={cn("tracking-wide uppercase", className)}
|
||||
aria-label={t("insights.personalRecord.badge")}
|
||||
>
|
||||
{t("insights.personalRecord.badge")}
|
||||
</ScorePill>
|
||||
);
|
||||
|
||||
if (!withTooltip) return pill;
|
||||
|
||||
const formattedDate = fmt.date(new Date(fresh.achievedAt));
|
||||
const tooltipText = t("insights.personalRecord.tooltip", {
|
||||
value: fmt.number(fresh.value, 1),
|
||||
unit: fresh.unit,
|
||||
date: formattedDate,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="bg-muted border-border text-foreground"
|
||||
data-slot="insights-pr-badge-tooltip"
|
||||
>
|
||||
<span className="text-xs">{tooltipText}</span>
|
||||
{fresh.benchmark ? (
|
||||
<span
|
||||
className="text-muted-foreground block text-xs"
|
||||
data-slot="insights-pr-badge-benchmark"
|
||||
>
|
||||
{t("insights.personalRecord.benchmark", {
|
||||
low: fmt.number(fresh.benchmark.low, 1),
|
||||
high: fmt.number(fresh.benchmark.high, 1),
|
||||
unit: fresh.benchmark.unit,
|
||||
source: fresh.benchmark.sourceLabel,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* v1.5.5 — `<IntakeHistoryPreview>` smoke tests.
|
||||
*
|
||||
* The preview mounts in SSR with an empty selection so the bulk-
|
||||
* delete toolbar stays hidden by default. Selection / dialog flips
|
||||
* are interactive surfaces and are exercised by the e2e suite.
|
||||
*
|
||||
* The kebab + selection wiring is covered by
|
||||
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
|
||||
* wrapper-level contract (no toolbar by default, wired through the
|
||||
* v1.5.5 endpoints).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function seed(client: QueryClient) {
|
||||
client.setQueryData(
|
||||
queryKeys.medicationIntakeList("med-1", {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 14,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
}),
|
||||
{
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
meta: { total: 1, limit: 14, offset: 0 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
|
||||
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
|
||||
});
|
||||
|
||||
it("renders the row-action kebab and selection checkbox on every row", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
// Per-row affordances feature 15 + 16 — the row carries both.
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
expect(html).toContain("intake-history-row-select");
|
||||
// The import header CTA stays alongside the new affordances.
|
||||
expect(html).toContain("intake-history-import");
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.0 — shared editable intake-history surface.
|
||||
*
|
||||
* Extracted from `<IntakeHistoryPreview>` so the detail-page preview
|
||||
* (14-row, `takenAt` sort) and the full-history view (25-row,
|
||||
* `scheduledFor` sort) share one state machine: multi-select
|
||||
* bulk-delete, per-row edit (`<IntakeEditDialog>`) and per-row delete
|
||||
* (`<AlertDialog>`). The component owns the table + every editing
|
||||
* dialog; it does NOT own the CSV-import dialog (that stays lifted to
|
||||
* the hosting page so it mounts exactly once).
|
||||
*
|
||||
* Both surfaces render the same `<IntakeHistoryListV2>`; the wrapper
|
||||
* just changes `pageSize` + `defaultSortBy`. The full-history route
|
||||
* defaults to `scheduledFor desc` so skipped rows (`takenAt: null`)
|
||||
* never float to the top of the table (O-1).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import {
|
||||
IntakeHistoryListV2,
|
||||
type IntakeEvent,
|
||||
type IntakeHistorySortKey,
|
||||
} from "@/components/medications/intake-history-list-v2";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface IntakeHistoryEditableProps {
|
||||
medicationId: string;
|
||||
/** Override the default page size — preview 14, full history 25. */
|
||||
pageSize?: number;
|
||||
/**
|
||||
* Initial sort column. The full-history view passes `"scheduledFor"`
|
||||
* so the chronological order stays clean even for skipped rows
|
||||
* (`takenAt: null`); the preview keeps the legacy `"takenAt"`.
|
||||
*/
|
||||
defaultSortBy?: IntakeHistorySortKey;
|
||||
}
|
||||
|
||||
export function IntakeHistoryEditable({
|
||||
medicationId,
|
||||
pageSize,
|
||||
defaultSortBy,
|
||||
}: IntakeHistoryEditableProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [rowDeleteBusy, setRowDeleteBusy] = useState(false);
|
||||
|
||||
function onToggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
async function confirmBulkDelete() {
|
||||
if (bulkBusy || selected.size === 0) return;
|
||||
setBulkBusy(true);
|
||||
const ids = Array.from(selected);
|
||||
try {
|
||||
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
|
||||
eventIds: ids,
|
||||
});
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.bulkDelete.toast"));
|
||||
setBulkConfirmOpen(false);
|
||||
clearSelection();
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRowDelete() {
|
||||
if (rowDeleteBusy || !pendingDeleteId) return;
|
||||
setRowDeleteBusy(true);
|
||||
const id = pendingDeleteId;
|
||||
try {
|
||||
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.deleteRow.toast"));
|
||||
setPendingDeleteId(null);
|
||||
// Drop the deleted row from the pending selection so the
|
||||
// bulk-toolbar count stays honest.
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setRowDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3" data-slot="intake-history-editable-body">
|
||||
{canManageMedications && selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
aria-label={t(
|
||||
"medications.detail.intake.bulkDelete.selectionCount",
|
||||
{ count: selected.size },
|
||||
)}
|
||||
data-slot="intake-history-bulk-delete-toolbar"
|
||||
>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.intake.bulkDelete.selectionCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-bulk-cancel"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBulkConfirmOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-bulk-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.bulkDelete.deleteButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IntakeHistoryListV2
|
||||
medicationId={medicationId}
|
||||
pageSize={pageSize}
|
||||
defaultSortBy={defaultSortBy}
|
||||
onEditIntake={setEditingEvent}
|
||||
onDeleteIntake={setPendingDeleteId}
|
||||
selection={{
|
||||
mode: "multi",
|
||||
selected,
|
||||
onToggle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IntakeEditDialog
|
||||
medicationId={medicationId}
|
||||
event={
|
||||
editingEvent
|
||||
? {
|
||||
id: editingEvent.id,
|
||||
takenAt: editingEvent.takenAt,
|
||||
skipped: editingEvent.skipped,
|
||||
scheduledFor: editingEvent.scheduledFor,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setEditingEvent(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={bulkConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBulkConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.intake.bulkDelete.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.intake.bulkDelete.confirmBody", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkBusy}>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmBulkDelete();
|
||||
}}
|
||||
disabled={bulkBusy}
|
||||
aria-busy={bulkBusy || undefined}
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!pendingDeleteId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.intake.deleteRow.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.intake.deleteRow.confirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={rowDeleteBusy}>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmRowDelete();
|
||||
}}
|
||||
disabled={rowDeleteBusy}
|
||||
aria-busy={rowDeleteBusy || undefined}
|
||||
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §9.5 — Intake-history preview.
|
||||
*
|
||||
* Section chrome via `<MedicationDetailSection>`. Header CTA carries a
|
||||
* CSV-import trigger. The body re-uses the shared
|
||||
* `<IntakeHistoryEditable>` (extracted in v1.7.0) so the table + sort +
|
||||
* pagination + multi-select bulk-delete + per-row edit/delete stay
|
||||
* identical between this preview and the full-history view.
|
||||
*
|
||||
* v1.7.0 — the footer "View full history →" link is gone; the detail
|
||||
* header's History button now routes directly to the full view, so the
|
||||
* redundant link is removed (R-medui §4.3).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface IntakeHistoryPreviewProps {
|
||||
medicationId: string;
|
||||
importOpen: boolean;
|
||||
onImportOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function IntakeHistoryPreview({
|
||||
medicationId,
|
||||
importOpen,
|
||||
onImportOpenChange,
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — importing dose history is not a delegated verb.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
|
||||
return (
|
||||
<>
|
||||
<MedicationDetailSection
|
||||
titleId="medication-detail-intake-history-heading"
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-detail-intake-history-section"
|
||||
headerExtras={
|
||||
canManageMedications ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onImportOpenChange(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-import"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
|
||||
</MedicationDetailSection>
|
||||
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={medicationId}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user