mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #299 from MBombeck/release/v1.15.13
v1.15.13 — search, filter, and bulk delete
This commit is contained in:
34 files changed
+2684
-259
No files matched your search
@@ -2,6 +2,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.13] — 2026-06-07 — search, filter, and bulk delete
|
||||
|
||||
### Added
|
||||
|
||||
- **Filter your measurements and mood entries.** Both lists now filter by source (manual, Apple Health, Withings, …) and by a date range, alongside the existing type / mood filter and sorting.
|
||||
- **Select and delete in bulk.** Tick several entries on a page and remove them in one step, with a confirmation step. Deletions stay reversible on your devices the same way a single delete does.
|
||||
|
||||
## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview
|
||||
|
||||
### Fixed
|
||||
|
||||
+78
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.15.12
|
||||
version: 1.15.13
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1508,6 +1508,18 @@ paths:
|
||||
type: string
|
||||
enum:
|
||||
- rollup
|
||||
- in: query
|
||||
name: sourceEq
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- MANUAL
|
||||
- WITHINGS
|
||||
- IMPORT
|
||||
- APPLE_HEALTH
|
||||
- COMPUTED
|
||||
- WHOOP
|
||||
- FITBIT
|
||||
- in: query
|
||||
name: groupBy
|
||||
schema:
|
||||
@@ -1698,6 +1710,31 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/bulk-delete:
|
||||
post:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Bulk soft-delete measurements
|
||||
description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the
|
||||
single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited
|
||||
`measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is
|
||||
the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Bulk delete processed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/sleep/night:
|
||||
get:
|
||||
tags:
|
||||
@@ -3998,6 +4035,19 @@ components:
|
||||
- externalIds
|
||||
description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped
|
||||
(cross-user 404 guard).
|
||||
MeasurementsBulkDeleteRequest:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- ids
|
||||
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
|
||||
DeviceRegisterRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -7012,6 +7062,33 @@ components:
|
||||
required:
|
||||
- deletedCount
|
||||
additionalProperties: false
|
||||
MeasurementsBulkDeleteResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsBulkDeleteResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsBulkDeleteResult:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
SleepNightResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Energieverbrauch",
|
||||
"typeAverageHeartRate": "Durchschnittlicher Puls",
|
||||
"typeMaxHeartRate": "Maximaler Puls",
|
||||
"typeSleepDisturbanceCount": "Schlafstörungen"
|
||||
"typeSleepDisturbanceCount": "Schlafstörungen",
|
||||
"filterBySource": "Nach Quelle filtern",
|
||||
"sourceComputed": "Berechnet",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} Messungen gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
|
||||
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "Eintrag löschen?",
|
||||
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
|
||||
"pageInfo": "Seite {page} von {total}",
|
||||
"previousPage": "Vorherige Seite",
|
||||
"nextPage": "Nächste Seite",
|
||||
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
|
||||
"note": "Notiz",
|
||||
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "Konflikt",
|
||||
"factorFamily": "Familie",
|
||||
"factorSadness": "Traurigkeit"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Nach Quelle filtern",
|
||||
"sourceManual": "Manuell",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} Einträge gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
|
||||
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "Dein geschätztes fruchtbares Fenster beginnt voraussichtlich in etwa zwei Tagen. Dies ist eine Schätzung und keine medizinische oder verhütende Garantie.",
|
||||
"discreetTitle": "HealthLog-Erinnerung",
|
||||
"discreetBody": "Du hast eine Erinnerung in HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "Alle Quellen",
|
||||
"dateFrom": "Von",
|
||||
"dateTo": "Bis",
|
||||
"selectAll": "Alle auf dieser Seite auswählen",
|
||||
"selectRow": "Zeile auswählen",
|
||||
"selected": "{count} ausgewählt",
|
||||
"deleteN": "Löschen ({count})",
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"selectionBarLabel": "Auswahlaktionen"
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Energy expenditure",
|
||||
"typeAverageHeartRate": "Average heart rate",
|
||||
"typeMaxHeartRate": "Maximum heart rate",
|
||||
"typeSleepDisturbanceCount": "Sleep disturbances"
|
||||
"typeSleepDisturbanceCount": "Sleep disturbances",
|
||||
"filterBySource": "Filter by source",
|
||||
"sourceComputed": "Computed",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} measurements deleted",
|
||||
"bulkDeleteError": "Error deleting the selected measurements",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "Delete entry?",
|
||||
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
|
||||
"pageInfo": "Page {page} of {total}",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"loginRequired": "Please sign in to view mood entries.",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Add a note about how you're feeling…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "conflict",
|
||||
"factorFamily": "family",
|
||||
"factorSadness": "sadness"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Filter by source",
|
||||
"sourceManual": "Manual",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entries deleted",
|
||||
"bulkDeleteError": "Error deleting the selected entries",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} entries?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "Your estimated fertile window is predicted to start in about two days. This is an estimate, not a medical or contraceptive guarantee.",
|
||||
"discreetTitle": "HealthLog reminder",
|
||||
"discreetBody": "You have a reminder in HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "All sources",
|
||||
"dateFrom": "From",
|
||||
"dateTo": "To",
|
||||
"selectAll": "Select all on this page",
|
||||
"selectRow": "Select row",
|
||||
"selected": "{count} selected",
|
||||
"deleteN": "Delete ({count})",
|
||||
"clearSelection": "Clear selection",
|
||||
"selectionBarLabel": "Selection actions"
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Gasto energético",
|
||||
"typeAverageHeartRate": "Frecuencia cardíaca media",
|
||||
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
|
||||
"typeSleepDisturbanceCount": "Interrupciones del sueño"
|
||||
"typeSleepDisturbanceCount": "Interrupciones del sueño",
|
||||
"filterBySource": "Filtrar por fuente",
|
||||
"sourceComputed": "Calculado",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} mediciones eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "¿Eliminar entrada?",
|
||||
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
|
||||
"pageInfo": "Página {page} de {total}",
|
||||
"previousPage": "Página anterior",
|
||||
"nextPage": "Página siguiente",
|
||||
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "conflicto",
|
||||
"factorFamily": "familia",
|
||||
"factorSadness": "tristeza"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Filtrar por fuente",
|
||||
"sourceManual": "Manual",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entradas eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "Se prevé que tu ventana fértil estimada comience en unos dos días. Es una estimación, no una garantía médica ni anticonceptiva.",
|
||||
"discreetTitle": "Recordatorio de HealthLog",
|
||||
"discreetBody": "Tienes un recordatorio en HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "Todas las fuentes",
|
||||
"dateFrom": "Desde",
|
||||
"dateTo": "Hasta",
|
||||
"selectAll": "Seleccionar todo en esta página",
|
||||
"selectRow": "Seleccionar fila",
|
||||
"selected": "{count} seleccionados",
|
||||
"deleteN": "Eliminar ({count})",
|
||||
"clearSelection": "Borrar selección",
|
||||
"selectionBarLabel": "Acciones de selección"
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Dépense énergétique",
|
||||
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
|
||||
"typeMaxHeartRate": "Fréquence cardiaque maximale",
|
||||
"typeSleepDisturbanceCount": "Perturbations du sommeil"
|
||||
"typeSleepDisturbanceCount": "Perturbations du sommeil",
|
||||
"filterBySource": "Filtrer par source",
|
||||
"sourceComputed": "Calculé",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} mesures supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "Supprimer l’entrée ?",
|
||||
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
|
||||
"pageInfo": "Page {page} sur {total}",
|
||||
"previousPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "conflit",
|
||||
"factorFamily": "famille",
|
||||
"factorSadness": "tristesse"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Filtrer par source",
|
||||
"sourceManual": "Manuel",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entrées supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "Votre fenêtre fertile estimée devrait commencer dans environ deux jours. Il s'agit d'une estimation, et non d'une garantie médicale ou contraceptive.",
|
||||
"discreetTitle": "Rappel HealthLog",
|
||||
"discreetBody": "Vous avez un rappel dans HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "Toutes les sources",
|
||||
"dateFrom": "Du",
|
||||
"dateTo": "Au",
|
||||
"selectAll": "Tout sélectionner sur cette page",
|
||||
"selectRow": "Sélectionner la ligne",
|
||||
"selected": "{count} sélectionné(s)",
|
||||
"deleteN": "Supprimer ({count})",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"selectionBarLabel": "Actions de sélection"
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Dispendio energetico",
|
||||
"typeAverageHeartRate": "Frequenza cardiaca media",
|
||||
"typeMaxHeartRate": "Frequenza cardiaca massima",
|
||||
"typeSleepDisturbanceCount": "Disturbi del sonno"
|
||||
"typeSleepDisturbanceCount": "Disturbi del sonno",
|
||||
"filterBySource": "Filtra per fonte",
|
||||
"sourceComputed": "Calcolato",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} misurazioni eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "Eliminare la voce?",
|
||||
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
|
||||
"pageInfo": "Pagina {page} di {total}",
|
||||
"previousPage": "Pagina precedente",
|
||||
"nextPage": "Pagina successiva",
|
||||
"loginRequired": "Accedi per vedere le voci dell’umore.",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Aggiungi una nota su come ti senti…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "conflitto",
|
||||
"factorFamily": "famiglia",
|
||||
"factorSadness": "tristezza"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Filtra per fonte",
|
||||
"sourceManual": "Manuale",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} voci eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "La tua finestra fertile stimata dovrebbe iniziare tra circa due giorni. È una stima, non una garanzia medica o contraccettiva.",
|
||||
"discreetTitle": "Promemoria HealthLog",
|
||||
"discreetBody": "Hai un promemoria in HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "Tutte le fonti",
|
||||
"dateFrom": "Dal",
|
||||
"dateTo": "Al",
|
||||
"selectAll": "Seleziona tutto in questa pagina",
|
||||
"selectRow": "Seleziona riga",
|
||||
"selected": "{count} selezionati",
|
||||
"deleteN": "Elimina ({count})",
|
||||
"clearSelection": "Cancella selezione",
|
||||
"selectionBarLabel": "Azioni di selezione"
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -494,7 +494,15 @@
|
||||
"typeEnergyExpenditureKj": "Wydatek energetyczny",
|
||||
"typeAverageHeartRate": "Średnie tętno",
|
||||
"typeMaxHeartRate": "Maksymalne tętno",
|
||||
"typeSleepDisturbanceCount": "Zakłócenia snu"
|
||||
"typeSleepDisturbanceCount": "Zakłócenia snu",
|
||||
"filterBySource": "Filtruj według źródła",
|
||||
"sourceComputed": "Obliczone",
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -538,6 +546,8 @@
|
||||
"deleteConfirmTitle": "Usunąć wpis?",
|
||||
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
|
||||
"pageInfo": "Strona {page} z {total}",
|
||||
"previousPage": "Poprzednia strona",
|
||||
"nextPage": "Następna strona",
|
||||
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
|
||||
"note": "Notatka",
|
||||
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
|
||||
@@ -598,7 +608,16 @@
|
||||
"factorConflict": "konflikt",
|
||||
"factorFamily": "rodzina",
|
||||
"factorSadness": "smutek"
|
||||
}
|
||||
},
|
||||
"filterBySource": "Filtruj według źródła",
|
||||
"sourceManual": "Ręcznie",
|
||||
"sourceWeb": "Web",
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -5303,5 +5322,16 @@
|
||||
"fertileSoonBody": "Szacowane okno płodności rozpocznie się prawdopodobnie za około dwa dni. To szacunek, a nie gwarancja medyczna ani antykoncepcyjna.",
|
||||
"discreetTitle": "Przypomnienie HealthLog",
|
||||
"discreetBody": "Masz przypomnienie w HealthLog."
|
||||
},
|
||||
"dataList": {
|
||||
"allSources": "Wszystkie źródła",
|
||||
"dateFrom": "Od",
|
||||
"dateTo": "Do",
|
||||
"selectAll": "Zaznacz wszystko na tej stronie",
|
||||
"selectRow": "Zaznacz wiersz",
|
||||
"selected": "Zaznaczono: {count}",
|
||||
"deleteN": "Usuń ({count})",
|
||||
"clearSelection": "Wyczyść zaznaczenie",
|
||||
"selectionBarLabel": "Akcje zaznaczenia"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.12",
|
||||
"version": "1.15.13",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.15.13 — source-filter support indexes for the measurements + mood
|
||||
-- management lists. The new list surfaces add a source filter (and the
|
||||
-- existing source/value sort). No index leads with `(user_id, source)`
|
||||
-- today, so a source filter forces a `user_id` scan + in-memory sort —
|
||||
-- the landmine at 100–200k rows per user. These two additive indexes turn
|
||||
-- a source filter + recency sort into an index range scan.
|
||||
--
|
||||
-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk
|
||||
-- shape as migration 0123.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx"
|
||||
ON "measurements" ("user_id", "source", "measured_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx"
|
||||
ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC);
|
||||
@@ -850,6 +850,10 @@ model Measurement {
|
||||
// (Postgres semantics) so manual entries don't collide here.
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
// v1.15.13 — leads with `(userId, source)` so the management list's
|
||||
// source filter + recency sort is an index range scan instead of a
|
||||
// `userId` scan + in-memory sort. See migration 0136.
|
||||
@@index([userId, source, measuredAt(sort: Desc)])
|
||||
@@index([externalId])
|
||||
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
|
||||
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
|
||||
@@ -2082,6 +2086,10 @@ model MoodEntry {
|
||||
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
|
||||
// index turns that into a range scan. See migration 0123.
|
||||
@@index([userId, moodLoggedAt])
|
||||
// v1.15.13 — leads with `(userId, source)` so the mood management
|
||||
// list's source filter + recency sort is an index range scan. See
|
||||
// migration 0136.
|
||||
@@index([userId, source, moodLoggedAt(sort: Desc)])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/measurements/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the rollup recompute collapses to the
|
||||
* unique `(type, day)` set instead of firing one recompute per row; the
|
||||
* >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
// keep the real collapse so the test exercises the actual fan-out logic
|
||||
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory defaults; the best-effort
|
||||
// post-delete hooks must be re-stubbed to resolved promises (the route
|
||||
// awaits the rollup recompute and `.catch()`es the insight invalidate).
|
||||
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
|
||||
// Caller asked for 3 ids but only 2 are owned + live.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
// updateMany is ownership-scoped + filters tombstones — a forged id
|
||||
// never 404s, it's just excluded from the count.
|
||||
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["m1", "m2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
|
||||
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1", "foreign-2"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty id array with 422", async () => {
|
||||
const res = await POST(postReq({ ids: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["m1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `POST /api/measurements/bulk-delete` — multi-select soft-delete for the
|
||||
* measurements management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which
|
||||
// `(type, day)` buckets need a rollup recompute and which types need
|
||||
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
|
||||
// a forged / foreign / already-tombstoned id is excluded here exactly
|
||||
// as it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.measurement.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { type: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.measurement.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("measurement.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the deletions.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing so a 200-row delete spanning one day fires ~1 recompute
|
||||
// per type, not 200 (mirrors the batch-insert path). Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const keys = collapseToTypeDayKeys(
|
||||
affected.map((row) => ({
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
);
|
||||
try {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] bulk-delete rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the deletion
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the reduced history. Fire-and-forget: never blocks the delete.
|
||||
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
|
||||
if (affectedTypes.length > 0) {
|
||||
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
|
||||
console.warn(
|
||||
"[measurements] bulk-delete status-insight invalidate failed",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
sortDir,
|
||||
aggregate,
|
||||
source,
|
||||
sourceEq,
|
||||
groupBy,
|
||||
dayKey,
|
||||
} = parsed.data;
|
||||
@@ -110,6 +111,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// dashboard read paths) must never include them.
|
||||
deletedAt: null,
|
||||
...(type && { type: type as MeasurementType }),
|
||||
// v1.15.13 — management-list source filter. Distinct from the
|
||||
// `source=rollup` opt-in above (which selects the rollup read tier);
|
||||
// `sourceEq` narrows the plain list to one ingest source.
|
||||
...(sourceEq && { source: sourceEq }),
|
||||
...(from || to
|
||||
? {
|
||||
measuredAt: {
|
||||
|
||||
@@ -178,6 +178,38 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries — source filter (v1.15.13)", () => {
|
||||
it("threads a valid source into the list where", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await GET(getReq("source=TELEGRAM"));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
|
||||
expect(call?.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
source: "TELEGRAM",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits source from the where when not supplied", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await GET(getReq(""));
|
||||
expect(res.status).toBe(200);
|
||||
const call = vi.mocked(prisma.moodEntry.findMany).mock.calls[0][0];
|
||||
expect(call?.where).not.toHaveProperty("source");
|
||||
});
|
||||
|
||||
it("422s on an unknown source value", async () => {
|
||||
const res = await GET(getReq("source=BOGUS"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Bad `mood` enum + missing `moodLoggedAt`.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/mood-entries/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the mood rollup recompute collapses to
|
||||
* the unique day set; the >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory default; the route awaits the
|
||||
// best-effort mood-rollup recompute, so re-stub it to a resolved promise.
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and bumps syncVersion", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["e1", "e2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({ syncVersion: { increment: 1 } });
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c"] }));
|
||||
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMood).not.toHaveBeenCalled();
|
||||
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["e1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `POST /api/mood-entries/bulk-delete` — multi-select soft-delete for the
|
||||
* mood management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which days need
|
||||
// a mood-rollup recompute. Ownership-scoped + `deletedAt: null` so a
|
||||
// forged / foreign / already-tombstoned id is excluded here exactly as
|
||||
// it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.moodEntry.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("mood.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
|
||||
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
|
||||
// one day fires one recompute, not one per row. Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -71,13 +71,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data;
|
||||
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
|
||||
deletedAt: null,
|
||||
...(mood && { mood }),
|
||||
// v1.15.13 — management-list source filter.
|
||||
...(source && { source }),
|
||||
...(from || to
|
||||
? {
|
||||
date: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
} from "../selection";
|
||||
|
||||
/**
|
||||
* v1.15.13 — pure page-scoped selection math for the measurements + mood
|
||||
* management lists. These helpers back the multi-select chrome; the tests
|
||||
* pin the set algebra so the select-all tri-state + the bulk-delete
|
||||
* payload stay correct without needing a DOM render.
|
||||
*/
|
||||
describe("data-list selection math", () => {
|
||||
describe("toggleId", () => {
|
||||
it("adds an id that is not present", () => {
|
||||
const next = toggleId(new Set(["a"]), "b");
|
||||
expect([...next].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("removes an id that is present", () => {
|
||||
const next = toggleId(new Set(["a", "b"]), "a");
|
||||
expect([...next]).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("returns a new set (does not mutate the input)", () => {
|
||||
const input = new Set(["a"]);
|
||||
const next = toggleId(input, "b");
|
||||
expect(next).not.toBe(input);
|
||||
expect([...input]).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectAllState", () => {
|
||||
it("is none on an empty page", () => {
|
||||
expect(selectAllState(new Set(["a"]), [])).toBe("none");
|
||||
});
|
||||
|
||||
it("is none when nothing on the page is selected", () => {
|
||||
expect(selectAllState(new Set(["x"]), ["a", "b"])).toBe("none");
|
||||
});
|
||||
|
||||
it("is some when at least one but not all are selected", () => {
|
||||
expect(selectAllState(new Set(["a"]), ["a", "b"])).toBe("some");
|
||||
});
|
||||
|
||||
it("is all when every page id is selected", () => {
|
||||
expect(selectAllState(new Set(["a", "b"]), ["a", "b"])).toBe("all");
|
||||
});
|
||||
|
||||
it("ignores selected ids that are not on the page", () => {
|
||||
// `x` is selected but off-page; the page is fully selected.
|
||||
expect(selectAllState(new Set(["a", "b", "x"]), ["a", "b"])).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleSelectAll", () => {
|
||||
it("adds every page id when not all are selected", () => {
|
||||
const next = toggleSelectAll(new Set(["a"]), ["a", "b", "c"]);
|
||||
expect([...next].sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("clears the page ids when all are already selected", () => {
|
||||
const next = toggleSelectAll(new Set(["a", "b"]), ["a", "b"]);
|
||||
expect([...next]).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves off-page selection when clearing the page", () => {
|
||||
const next = toggleSelectAll(new Set(["a", "b", "x"]), ["a", "b"]);
|
||||
expect([...next]).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("is a no-op on an empty page", () => {
|
||||
const next = toggleSelectAll(new Set(["a"]), []);
|
||||
expect([...next]).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedIdsOnPage / selectedCountOnPage", () => {
|
||||
it("returns only selected ids that are present on the page, in page order", () => {
|
||||
const selected = new Set(["c", "a", "x"]);
|
||||
const pageIds = ["a", "b", "c"];
|
||||
expect(selectedIdsOnPage(selected, pageIds)).toEqual(["a", "c"]);
|
||||
expect(selectedCountOnPage(selected, pageIds)).toBe(2);
|
||||
});
|
||||
|
||||
it("never includes off-page ids in the bulk-delete payload", () => {
|
||||
const selected = new Set(["off-page-1", "off-page-2"]);
|
||||
const pageIds = ["a", "b"];
|
||||
expect(selectedIdsOnPage(selected, pageIds)).toEqual([]);
|
||||
expect(selectedCountOnPage(selected, pageIds)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Shared single-row delete button + confirm dialog for the data-
|
||||
* management lists (measurements + mood). Lifted from the two
|
||||
* copy-pasted `DeleteButton` helpers (v1.15.13).
|
||||
*
|
||||
* The confirm copy differs per surface (a measurement vs a mood entry),
|
||||
* so the title/description are passed in rather than reaching for a fixed
|
||||
* translation key. `cancelLabel` / `confirmLabel` default to the shared
|
||||
* `common.*` strings.
|
||||
*/
|
||||
export function DeleteButton({
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
className = "size-11",
|
||||
iconClassName = "h-3.5 w-3.5",
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`text-destructive ${className}`}
|
||||
aria-label={confirmLabel ?? t("common.delete")}
|
||||
>
|
||||
<Trash2 className={iconClassName} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel ?? t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared chrome for the data-management lists (measurements + mood).
|
||||
*
|
||||
* v1.15.13 — the `SortableHead` + single-row `DeleteButton` were copy-
|
||||
* pasted between `measurement-list.tsx` and `mood-list.tsx`; they now
|
||||
* live here once, alongside the new page-scoped multi-select selection
|
||||
* bar and its pure selection math. Each list keeps its own columns +
|
||||
* formatting; only the shared chrome lives in this module.
|
||||
*/
|
||||
export { SortableHead } from "./sortable-head";
|
||||
export { DeleteButton } from "./delete-button";
|
||||
export { SelectionActionBar } from "./selection-action-bar";
|
||||
export {
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
type SelectAllState,
|
||||
} from "./selection";
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Selection action bar for the data-management lists (measurements +
|
||||
* mood). Appears when ≥1 row on the current page is selected; shows
|
||||
* "N selected", a destructive "Delete (N)" button gated behind an
|
||||
* AlertDialog confirm, and a clear-selection affordance.
|
||||
*
|
||||
* Pure / presentational: every behaviour is a callback. The component
|
||||
* renders nothing when `count === 0`. The bar is sticky to the bottom of
|
||||
* the viewport so it stays reachable while scrolling a long page, and the
|
||||
* confirm dialog is focus-trapped by the shared `AlertDialog` primitive.
|
||||
*
|
||||
* The confirm copy is passed in because the title/body differ per surface
|
||||
* (measurements vs mood) and the count is interpolated by the caller.
|
||||
*/
|
||||
export function SelectionActionBar({
|
||||
count,
|
||||
onClear,
|
||||
onConfirmDelete,
|
||||
isDeleting,
|
||||
confirmTitle,
|
||||
confirmBody,
|
||||
}: {
|
||||
count: number;
|
||||
onClear: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
confirmTitle: string;
|
||||
confirmBody: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("dataList.selectionBarLabel")}
|
||||
// v1.15.13 HIGH-1 — on mobile the fixed bottom-nav (64px, `md:hidden`,
|
||||
// z-50) plus the iOS home-indicator inset sit over the page's lower
|
||||
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
|
||||
// destructive "Delete (N)" button. Lift the bar to clear the nav band
|
||||
// + safe area using the same offset the Coach FAB uses
|
||||
// (`layout-coach-fab.tsx`); at `sm:`/desktop the nav is gone, so drop
|
||||
// back to `bottom-4`. z-30 keeps the bar above page content without
|
||||
// needing to beat the (now-cleared) nav's z-50.
|
||||
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={onClear}
|
||||
disabled={isDeleting}
|
||||
aria-label={t("dataList.clearSelection")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium" aria-live="polite">
|
||||
{t("dataList.selected", { count: String(count) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("dataList.deleteN", { count: String(count) })}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirmBody}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure, framework-free selection math for the page-scoped multi-select on
|
||||
* the measurements + mood management lists (v1.15.13).
|
||||
*
|
||||
* Selection is scoped to the CURRENT PAGE per the v1.15.x list audit
|
||||
* (§E) — no "select across 200k rows". Every helper here operates on a
|
||||
* `Set<string>` of row ids that all belong to the rows currently painted,
|
||||
* so the math stays a few cheap set operations regardless of total count.
|
||||
*
|
||||
* Extracted so the selection logic is unit-testable without a DOM /
|
||||
* React render (`selection.test.ts`).
|
||||
*/
|
||||
|
||||
/** Toggle a single id in/out of the selection set, returning a new Set. */
|
||||
export function toggleId(selected: ReadonlySet<string>, id: string): Set<string> {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header "select all on this page" state.
|
||||
*
|
||||
* - `"all"` — every selectable id on the page is selected.
|
||||
* - `"some"` — at least one but not all are selected (indeterminate).
|
||||
* - `"none"` — nothing on the page is selected.
|
||||
*
|
||||
* A page with no selectable rows is `"none"` (the header checkbox should
|
||||
* be a no-op then).
|
||||
*/
|
||||
export type SelectAllState = "all" | "some" | "none";
|
||||
|
||||
export function selectAllState(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): SelectAllState {
|
||||
if (pageIds.length === 0) return "none";
|
||||
let selectedOnPage = 0;
|
||||
for (const id of pageIds) {
|
||||
if (selected.has(id)) selectedOnPage += 1;
|
||||
}
|
||||
if (selectedOnPage === 0) return "none";
|
||||
if (selectedOnPage === pageIds.length) return "all";
|
||||
return "some";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a "select all on page" toggle. When every selectable id on the
|
||||
* page is already selected, the toggle clears the page's ids; otherwise it
|
||||
* adds the page's ids to the existing selection. Selection from other
|
||||
* (now-unmounted) pages is dropped on a page change by the caller, so in
|
||||
* practice `selected` only ever holds current-page ids — but this stays
|
||||
* correct either way.
|
||||
*/
|
||||
export function toggleSelectAll(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): Set<string> {
|
||||
if (pageIds.length === 0) return new Set(selected);
|
||||
const allSelected = pageIds.every((id) => selected.has(id));
|
||||
const next = new Set(selected);
|
||||
if (allSelected) {
|
||||
for (const id of pageIds) next.delete(id);
|
||||
} else {
|
||||
for (const id of pageIds) next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected ids intersected with the ids actually present on the page.
|
||||
* The bulk-delete request only ever sends rows the user can currently see,
|
||||
* which keeps the payload page-bounded (≤ PAGE_SIZE) and matches the
|
||||
* server's 200-id cap with room to spare.
|
||||
*/
|
||||
export function selectedIdsOnPage(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): string[] {
|
||||
return pageIds.filter((id) => selected.has(id));
|
||||
}
|
||||
|
||||
/** Count of selected ids that are present on the current page. */
|
||||
export function selectedCountOnPage(
|
||||
selected: ReadonlySet<string>,
|
||||
pageIds: readonly string[],
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const id of pageIds) {
|
||||
if (selected.has(id)) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
|
||||
import { TableHead } from "@/components/ui/table";
|
||||
|
||||
/**
|
||||
* Shared clickable sort header for the data-management tables
|
||||
* (measurements + mood). Lifted verbatim from the two copy-pasted
|
||||
* `SortableHead` definitions (v1.15.13). Domain specifics — which
|
||||
* columns are sortable, their labels — stay in each list; only this
|
||||
* chrome moves.
|
||||
*/
|
||||
export function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.15.13 — the new filter bar (source select + date range) and the
|
||||
* page-scoped multi-select chrome (per-row + select-all checkboxes) on
|
||||
* the measurements management list. Server-render assertions only — the
|
||||
* selection action bar's count-driven render is covered by the pure
|
||||
* `selection.test.ts` math + the integration suite; here we pin that the
|
||||
* new controls actually mount with their labelled, keyboard-operable
|
||||
* checkboxes.
|
||||
*/
|
||||
|
||||
const baseMeasurements = [
|
||||
{
|
||||
id: "m-1",
|
||||
type: "WEIGHT",
|
||||
value: 81.5,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: "2026-05-09T08:30:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-2",
|
||||
type: "WEIGHT",
|
||||
value: 80.9,
|
||||
unit: "kg",
|
||||
source: "WITHINGS",
|
||||
measuredAt: "2026-05-08T08:30:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { measurements: baseMeasurements, meta: { total: 2 } },
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "marc", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MeasurementList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MeasurementList — filter bar + multi-select chrome", () => {
|
||||
it("renders the source filter control", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain("Filter by source");
|
||||
});
|
||||
|
||||
it("renders the date-range labels wired to from/to", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain('for="measurements-from"');
|
||||
expect(html).toContain('for="measurements-to"');
|
||||
});
|
||||
|
||||
it("renders labelled selection checkboxes (per-row + select-all)", () => {
|
||||
const html = render("en");
|
||||
// The shared Checkbox primitive carries data-slot="checkbox".
|
||||
const boxes = html.match(/data-slot="checkbox"/g);
|
||||
// 1 header select-all + 2 selectable rows (desktop) + 2 rows (mobile).
|
||||
expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
|
||||
expect(html).toContain('aria-label="Select all on this page"');
|
||||
expect(html).toContain('aria-label="Select row"');
|
||||
});
|
||||
|
||||
it("localises the new chrome in German", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Nach Quelle filtern");
|
||||
expect(html).toContain('aria-label="Alle auf dieser Seite auswählen"');
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
@@ -49,19 +48,35 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||||
measurementSourceEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
SortableHead,
|
||||
DeleteButton,
|
||||
SelectionActionBar,
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
} from "@/components/data-list";
|
||||
import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
|
||||
@@ -191,9 +206,40 @@ function formatMeasurementSource(
|
||||
if (source === "IMPORT") return t("measurements.sourceImport");
|
||||
if (source === "MANUAL") return t("measurements.sourceManual");
|
||||
if (source === "APPLE_HEALTH") return t("measurements.sourceAppleHealth");
|
||||
if (source === "COMPUTED") return t("measurements.sourceComputed");
|
||||
if (source === "WHOOP") return t("measurements.sourceWhoop");
|
||||
if (source === "FITBIT") return t("measurements.sourceFitbit");
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.13 — the `MeasurementSource` enum values offered in the
|
||||
* management-list source filter. Derived from the shared Zod enum so a
|
||||
* future source addition surfaces here automatically.
|
||||
*/
|
||||
const MEASUREMENT_SOURCE_OPTIONS = measurementSourceEnum.options;
|
||||
|
||||
/**
|
||||
* v1.15.13 — convert a `<input type="date">` value (YYYY-MM-DD) into the
|
||||
* offset-bearing ISO datetime the measurements list `from`/`to` params
|
||||
* require. `from` snaps to the local start-of-day, `to` to the local
|
||||
* end-of-day so an inclusive single-day range returns that whole day.
|
||||
*/
|
||||
function dayBoundaryIso(
|
||||
day: string,
|
||||
boundary: "start" | "end",
|
||||
): string | undefined {
|
||||
if (!day) return undefined;
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
if (!y || !m || !d) return undefined;
|
||||
const date =
|
||||
boundary === "start"
|
||||
? new Date(y, m - 1, d, 0, 0, 0, 0)
|
||||
: new Date(y, m - 1, d, 23, 59, 59, 999);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-source badge colour. `APPLE_HEALTH` gets the Dracula pink that
|
||||
* matches the iOS app's accent; everything else falls back to the
|
||||
@@ -220,10 +266,24 @@ export function MeasurementList({
|
||||
const [typeFilter, setTypeFilterRaw] = useState<string>(
|
||||
lockedType ?? "ALL",
|
||||
);
|
||||
// v1.15.13 — management-list source filter + optional date range.
|
||||
// `ALL` clears the source filter; empty date strings clear the bound.
|
||||
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
|
||||
const [fromDay, setFromDayRaw] = useState<string>("");
|
||||
const [toDay, setToDayRaw] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("measuredAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// v1.15.13 — page-scoped multi-select. Holds the ids selected on the
|
||||
// CURRENT page; cleared on any page / filter / sort change (per the
|
||||
// v1.15.x audit — no "select across 200k rows"). The bulk-delete
|
||||
// payload is intersected with the painted page ids, so it is always
|
||||
// page-bounded (≤ PAGE_SIZE) and well under the server's 200 cap.
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
// v1.4.37 W7c — set of dayKeys whose drill-down is currently
|
||||
// expanded. Each key maps to a one-shot query that lazy-fetches
|
||||
// the per-sample rows for that calendar day on first open.
|
||||
@@ -256,9 +316,38 @@ export function MeasurementList({
|
||||
null,
|
||||
);
|
||||
|
||||
// v1.15.13 — every filter / page / sort change resets pagination to
|
||||
// page 1 AND clears the page-scoped selection (the rows it referred to
|
||||
// are about to unmount).
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const setTypeFilter = (value: string) => {
|
||||
setTypeFilterRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setSourceFilter = (value: string) => {
|
||||
setSourceFilterRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setFromDay = (value: string) => {
|
||||
setFromDayRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setToDay = (value: string) => {
|
||||
setToDayRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const goToPage = (updater: (p: number) => number) => {
|
||||
setPage(updater);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
function toggleSort(column: string) {
|
||||
@@ -269,6 +358,7 @@ export function MeasurementList({
|
||||
setSortDir(column === "measuredAt" ? "desc" : "asc");
|
||||
}
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// v1.4.37 W7c — when the type filter is a cumulative HK type
|
||||
@@ -287,18 +377,34 @@ export function MeasurementList({
|
||||
const isSleepFilter = typeFilter === "SLEEP_DURATION";
|
||||
const isDayGroupedFilter = isCumulativeFilter || isSleepFilter;
|
||||
|
||||
// v1.15.13 — derive the ISO datetime window the list `from`/`to`
|
||||
// params require from the two date inputs (local start/end of day).
|
||||
const fromIso = dayBoundaryIso(fromDay, "start");
|
||||
const toIso = dayBoundaryIso(toDay, "end");
|
||||
const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter;
|
||||
const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter
|
||||
? "sleep-night"
|
||||
: isCumulativeFilter
|
||||
? "groupBy=day"
|
||||
: "raw";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"measurements",
|
||||
typeFilter === "ALL" ? undefined : typeFilter,
|
||||
queryKey: queryKeys.measurementsList({
|
||||
type: typeFilter === "ALL" ? undefined : typeFilter,
|
||||
sourceEq,
|
||||
from: fromIso,
|
||||
to: toIso,
|
||||
page,
|
||||
sortBy,
|
||||
sortDir,
|
||||
isSleepFilter ? "sleep-night" : isCumulativeFilter ? "groupBy=day" : "raw",
|
||||
],
|
||||
mode: listMode,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (typeFilter !== "ALL") params.set("type", typeFilter);
|
||||
if (sourceEq) params.set("sourceEq", sourceEq);
|
||||
if (fromIso) params.set("from", fromIso);
|
||||
if (toIso) params.set("to", toIso);
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String((page - 1) * PAGE_SIZE));
|
||||
params.set("sortBy", sortBy);
|
||||
@@ -350,6 +456,61 @@ export function MeasurementList({
|
||||
},
|
||||
});
|
||||
|
||||
// v1.15.13 — page-scoped bulk soft-delete. Posts the selected ids
|
||||
// (intersected with the painted page) to the bulk-delete route, then
|
||||
// invalidates the same dependent-key bundle the single delete busts so
|
||||
// the dashboard / insights / chart caches stay in lockstep.
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
const res = await fetch("/api/measurements/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Bulk delete failed");
|
||||
const json = (await res.json()) as { data: { deleted: number } };
|
||||
return json.data.deleted;
|
||||
},
|
||||
onSuccess: async (deleted) => {
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
clearSelection();
|
||||
toast.success(t("measurements.bulkDeleteSuccess", { count: String(deleted) }));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("measurements.bulkDeleteError"));
|
||||
},
|
||||
});
|
||||
|
||||
const pageIds = (data?.measurements ?? [])
|
||||
// Synthetic day-grouped / sleep-night rows aren't individually
|
||||
// deletable (they collapse many raw rows behind a `dayKey`), so they
|
||||
// are excluded from the selectable set.
|
||||
.filter((m) => !(m.dayKey !== undefined && m.sampleCount !== undefined))
|
||||
.map((m) => m.id);
|
||||
|
||||
// Selection is held as a raw id set but every read intersects it with
|
||||
// the painted page (`selectAllState` / `selectedCountOnPage` /
|
||||
// `selectedIdsOnPage`), so an id that falls off the page after a
|
||||
// refetch (e.g. a single-row delete) simply stops counting — no stale
|
||||
// selection survives a page / filter / sort change either, since those
|
||||
// call `clearSelection()`. No prune effect is needed.
|
||||
const selectAll = selectAllState(selectedIds, pageIds);
|
||||
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
|
||||
|
||||
function onToggleRow(id: string) {
|
||||
setSelectedIds((prev) => toggleId(prev, id));
|
||||
}
|
||||
|
||||
function onToggleSelectAll() {
|
||||
setSelectedIds((prev) => toggleSelectAll(prev, pageIds));
|
||||
}
|
||||
|
||||
function onConfirmBulkDelete() {
|
||||
const ids = selectedIdsOnPage(selectedIds, pageIds);
|
||||
if (ids.length === 0) return;
|
||||
bulkDeleteMutation.mutate(ids);
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -494,32 +655,103 @@ export function MeasurementList({
|
||||
Pre-fix the trigger had a fixed `w-48` (192 px) which on
|
||||
Pixel 5 (375 px content width) left only ~120 px for the
|
||||
count, which wrapped to 2 lines. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
{lockedType ? (
|
||||
// v1.8.5 — the insights "all readings" subpage knows the
|
||||
// metric from the route, so the global type selector is
|
||||
// suppressed; the count caption still anchors the column.
|
||||
<span className="sr-only">{t("measurements.filterByType")}</span>
|
||||
) : (
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full sm:w-48"
|
||||
aria-label={t("measurements.filterByType")}
|
||||
>
|
||||
<SelectValue placeholder={t("measurements.allTypes")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">
|
||||
{t("measurements.allTypes")}
|
||||
</SelectItem>
|
||||
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{t(labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
{lockedType ? (
|
||||
// v1.8.5 — the insights "all readings" subpage knows the
|
||||
// metric from the route, so the global type selector is
|
||||
// suppressed; the count caption still anchors the column.
|
||||
<span className="sr-only">{t("measurements.filterByType")}</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("measurements.filterByType")}
|
||||
</Label>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full sm:w-44"
|
||||
aria-label={t("measurements.filterByType")}
|
||||
>
|
||||
<SelectValue placeholder={t("measurements.allTypes")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">
|
||||
{t("measurements.allTypes")}
|
||||
</SelectItem>
|
||||
{Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{t(labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.15.13 — source filter + date range. Suppressed on the
|
||||
locked-type insights subpage, which wants a focused list
|
||||
scoped to the route's single metric (matches the type-
|
||||
selector suppression above). */}
|
||||
{!lockedType && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("measurements.filterBySource")}
|
||||
</Label>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full sm:w-40"
|
||||
aria-label={t("measurements.filterBySource")}
|
||||
>
|
||||
<SelectValue placeholder={t("dataList.allSources")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">
|
||||
{t("dataList.allSources")}
|
||||
</SelectItem>
|
||||
{MEASUREMENT_SOURCE_OPTIONS.map((src) => (
|
||||
<SelectItem key={src} value={src}>
|
||||
{formatMeasurementSource(src, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
htmlFor="measurements-from"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("dataList.dateFrom")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="measurements-from"
|
||||
className="w-full sm:w-40"
|
||||
value={fromDay}
|
||||
max={toDay || undefined}
|
||||
onChange={(e) => setFromDay(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
htmlFor="measurements-to"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("dataList.dateTo")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="measurements-to"
|
||||
className="w-full sm:w-40"
|
||||
value={toDay}
|
||||
min={fromDay || undefined}
|
||||
onChange={(e) => setToDay(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("measurements.measurementCount", {
|
||||
@@ -576,7 +808,22 @@ export function MeasurementList({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-28 pl-4">
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-28">
|
||||
{t("measurements.type")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
@@ -626,10 +873,22 @@ export function MeasurementList({
|
||||
// expanded panel. dayKey is unique per row when
|
||||
// grouped; fall back to m.id otherwise.
|
||||
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
<TableRow>
|
||||
<TableRow data-state={isSelected ? "selected" : undefined}>
|
||||
<TableCell className="pl-4">
|
||||
{/* Grouped/synthetic rows aren't individually
|
||||
deletable, so they have no checkbox. */}
|
||||
{!isGrouped && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(m.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={TYPE_COLORS[m.type] ?? ""}
|
||||
@@ -744,6 +1003,10 @@ export function MeasurementList({
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -752,7 +1015,7 @@ export function MeasurementList({
|
||||
</TableRow>
|
||||
{isGrouped && isExpanded && (
|
||||
<TableRow id={drilldownId}>
|
||||
<TableCell colSpan={6} className="p-0">
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<DayDrillDown
|
||||
type={m.type}
|
||||
dayKey={m.dayKey as string}
|
||||
@@ -781,13 +1044,43 @@ export function MeasurementList({
|
||||
: false;
|
||||
// v1.4.38 W-D P1-1 — see desktop counterpart.
|
||||
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="bg-card border-border rounded-lg border p-3"
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 — multi-select checkbox in a 44px tap
|
||||
target; absent for synthetic grouped rows. */}
|
||||
{!isGrouped && (
|
||||
// v1.15.13 MEDIUM-1 — a Radix Checkbox renders a
|
||||
// 16px `<button role=checkbox>`; a wrapping
|
||||
// `<label>` does NOT forward taps to a button (label
|
||||
// forwarding only works for native form controls),
|
||||
// so the effective tap target was 16px (fails WCAG
|
||||
// 2.5.5). The 44px button below owns the whole hit
|
||||
// area and is the single toggle source; the inner
|
||||
// Checkbox is a `pointer-events-none` controlled
|
||||
// visual, so a tap can fire the handler exactly once.
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
onClick={() => onToggleRow(m.id)}
|
||||
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{Icon && (
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
@@ -898,7 +1191,12 @@ export function MeasurementList({
|
||||
</Button>
|
||||
<DeleteButton
|
||||
className="size-11"
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"measurements.deleteConfirmDescription",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -921,6 +1219,20 @@ export function MeasurementList({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
isDeleting={bulkDeleteMutation.isPending}
|
||||
confirmTitle={t("measurements.bulkDeleteConfirmTitle", {
|
||||
count: String(selectedOnPage),
|
||||
})}
|
||||
confirmBody={t("measurements.bulkDeleteConfirmBody", {
|
||||
count: String(selectedOnPage),
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -936,7 +1248,7 @@ export function MeasurementList({
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
onClick={() => goToPage((p) => p - 1)}
|
||||
aria-label={t("measurements.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -946,7 +1258,7 @@ export function MeasurementList({
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
onClick={() => goToPage((p) => p + 1)}
|
||||
aria-label={t("measurements.nextPage")}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -1279,83 +1591,3 @@ function SleepNightCaption({ m }: { m: Measurement }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
onConfirm,
|
||||
className = "size-11",
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`text-destructive ${className}`}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("measurements.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("measurements.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.15.13 — the new source filter + date range + page-scoped
|
||||
* multi-select chrome on the mood management list. Mirrors the
|
||||
* measurements-list guard.
|
||||
*/
|
||||
|
||||
const baseEntries = [
|
||||
{
|
||||
id: "e-1",
|
||||
date: "2026-05-09",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: ["work"],
|
||||
tagKeys: [],
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: "2026-05-09T20:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "e-2",
|
||||
date: "2026-05-08",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: [],
|
||||
tagKeys: [],
|
||||
note: null,
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: "2026-05-08T20:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/mood",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { entries: baseEntries, meta: { total: 2 } },
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "marc", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MoodList } from "../mood-list";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MoodList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MoodList — filter bar + multi-select chrome", () => {
|
||||
it("renders the source filter control", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain("Filter by source");
|
||||
});
|
||||
|
||||
it("renders the date-range labels wired to from/to", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain('for="mood-from"');
|
||||
expect(html).toContain('for="mood-to"');
|
||||
});
|
||||
|
||||
it("renders labelled selection checkboxes (per-row + select-all)", () => {
|
||||
const html = render("en");
|
||||
const boxes = html.match(/data-slot="checkbox"/g);
|
||||
expect((boxes?.length ?? 0)).toBeGreaterThanOrEqual(3);
|
||||
expect(html).toContain('aria-label="Select all on this page"');
|
||||
expect(html).toContain('aria-label="Select row"');
|
||||
});
|
||||
|
||||
it("renders the localised non-manual source label, not the raw enum", () => {
|
||||
const html = render("en");
|
||||
// MOODLOG row paints its localised badge label.
|
||||
expect(html).toContain("moodLog");
|
||||
expect(html).not.toContain(">MOODLOG<");
|
||||
});
|
||||
});
|
||||
+305
-119
@@ -21,7 +21,6 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
@@ -38,7 +37,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { DateInput, DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
@@ -56,9 +55,6 @@ import {
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
@@ -70,7 +66,23 @@ import {
|
||||
MOOD_LABEL_KEYS,
|
||||
MOOD_SCORE_BY_ENUM,
|
||||
} from "@/lib/mood/labels";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
moodDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { moodSourceEnum } from "@/lib/validations/moodlog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
SortableHead,
|
||||
DeleteButton,
|
||||
SelectionActionBar,
|
||||
toggleId,
|
||||
toggleSelectAll,
|
||||
selectAllState,
|
||||
selectedIdsOnPage,
|
||||
selectedCountOnPage,
|
||||
} from "@/components/data-list";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
|
||||
@@ -125,10 +137,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
|
||||
// v1.15.13 — management-list source filter + optional date range.
|
||||
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
|
||||
const [fromDay, setFromDayRaw] = useState<string>("");
|
||||
const [toDay, setToDayRaw] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("moodLoggedAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// v1.15.13 — page-scoped multi-select selection (current page ids only).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const [editing, setEditing] = useState<MoodEntry | null>(null);
|
||||
const [editMood, setEditMood] = useState("");
|
||||
const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({
|
||||
@@ -156,9 +177,37 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
null,
|
||||
);
|
||||
|
||||
// v1.15.13 — every filter / page / sort change resets pagination AND
|
||||
// clears the page-scoped selection.
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const setMoodFilter = (value: string) => {
|
||||
setMoodFilterRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setSourceFilter = (value: string) => {
|
||||
setSourceFilterRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setFromDay = (value: string) => {
|
||||
setFromDayRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const setToDay = (value: string) => {
|
||||
setToDayRaw(value);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const goToPage = (updater: (p: number) => number) => {
|
||||
setPage(updater);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
function toggleSort(column: string) {
|
||||
@@ -169,19 +218,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setSortDir(column === "moodLoggedAt" ? "desc" : "asc");
|
||||
}
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// v1.15.13 — mood list `from`/`to` are YYYY-MM-DD (the date inputs hand
|
||||
// back exactly that), so they pass through unchanged. `ALL` clears the
|
||||
// source filter.
|
||||
const fromParam = fromDay || undefined;
|
||||
const toParam = toDay || undefined;
|
||||
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"mood-entries",
|
||||
moodFilter === "ALL" ? undefined : moodFilter,
|
||||
queryKey: queryKeys.moodEntriesList({
|
||||
mood: moodFilter === "ALL" ? undefined : moodFilter,
|
||||
source: sourceParam,
|
||||
from: fromParam,
|
||||
to: toParam,
|
||||
page,
|
||||
sortBy,
|
||||
sortDir,
|
||||
],
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (moodFilter !== "ALL") params.set("mood", moodFilter);
|
||||
if (sourceParam) params.set("source", sourceParam);
|
||||
if (fromParam) params.set("from", fromParam);
|
||||
if (toParam) params.set("to", toParam);
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String((page - 1) * PAGE_SIZE));
|
||||
params.set("sortBy", sortBy);
|
||||
@@ -216,6 +278,49 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.15.13 — page-scoped bulk soft-delete, mirroring the measurements list.
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
const res = await fetch("/api/mood-entries/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Bulk delete failed");
|
||||
const json = (await res.json()) as { data: { deleted: number } };
|
||||
return json.data.deleted;
|
||||
},
|
||||
onSuccess: async (deleted) => {
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
clearSelection();
|
||||
toast.success(t("mood.bulkDeleteSuccess", { count: String(deleted) }));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("mood.bulkDeleteError"));
|
||||
},
|
||||
});
|
||||
|
||||
const pageIds = (data?.entries ?? []).map((e) => e.id);
|
||||
|
||||
// Selection reads intersect with the painted page, so a stale id never
|
||||
// counts; page / filter / sort changes clear selection. No prune effect.
|
||||
const selectAll = selectAllState(selectedIds, pageIds);
|
||||
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
|
||||
|
||||
function onToggleRow(id: string) {
|
||||
setSelectedIds((prev) => toggleId(prev, id));
|
||||
}
|
||||
|
||||
function onToggleSelectAll() {
|
||||
setSelectedIds((prev) => toggleSelectAll(prev, pageIds));
|
||||
}
|
||||
|
||||
function onConfirmBulkDelete() {
|
||||
const ids = selectedIdsOnPage(selectedIds, pageIds);
|
||||
if (ids.length === 0) return;
|
||||
bulkDeleteMutation.mutate(ids);
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -321,20 +426,77 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={moodFilter} onValueChange={setMoodFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t("mood.allMoods")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
|
||||
{MOOD_LEVELS_LIST.map((val) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("mood.moodLevel")}
|
||||
</Label>
|
||||
<Select value={moodFilter} onValueChange={setMoodFilter}>
|
||||
<SelectTrigger className="w-full sm:w-44">
|
||||
<SelectValue placeholder={t("mood.allMoods")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">{t("mood.allMoods")}</SelectItem>
|
||||
{MOOD_LEVELS_LIST.map((val) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{MOOD_SCORES[val]} ({t(MOOD_LABEL_KEYS[val])})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* v1.15.13 — source filter. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("mood.filterBySource")}
|
||||
</Label>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger
|
||||
className="w-full sm:w-40"
|
||||
aria-label={t("mood.filterBySource")}
|
||||
>
|
||||
<SelectValue placeholder={t("dataList.allSources")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">{t("dataList.allSources")}</SelectItem>
|
||||
{MOOD_SOURCE_OPTIONS.map((src) => (
|
||||
<SelectItem key={src} value={src}>
|
||||
{formatMoodSource(src, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* v1.15.13 — optional date range, wired to from/to. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="mood-from" className="text-muted-foreground text-xs">
|
||||
{t("dataList.dateFrom")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="mood-from"
|
||||
className="w-full sm:w-40"
|
||||
value={fromDay}
|
||||
max={toDay || undefined}
|
||||
onChange={(e) => setFromDay(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="mood-to" className="text-muted-foreground text-xs">
|
||||
{t("dataList.dateTo")}
|
||||
</Label>
|
||||
<DateInput
|
||||
id="mood-to"
|
||||
className="w-full sm:w-40"
|
||||
value={toDay}
|
||||
min={fromDay || undefined}
|
||||
onChange={(e) => setToDay(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("mood.entryCount", {
|
||||
@@ -388,13 +550,28 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
? true
|
||||
: selectAll === "some"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
disabled={pageIds.length === 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={t("dataList.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="mood"
|
||||
label={t("mood.moodLevel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
className="w-36 pl-4"
|
||||
className="w-36"
|
||||
/>
|
||||
<TableHead>{t("mood.tags")}</TableHead>
|
||||
<SortableHead
|
||||
@@ -418,9 +595,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="pl-4 font-semibold tabular-nums">
|
||||
{data.entries.map((entry) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{entry.score}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(
|
||||
@@ -457,7 +646,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<TableCell>
|
||||
{entry.source !== "MANUAL" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("mood.sourceMoodlog")}
|
||||
{formatMoodSource(entry.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -474,11 +663,14 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -493,13 +685,38 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
guard at `e2e/mood-card-mobile.spec.ts` queries to assert
|
||||
"exactly one occurrence of the score per row". */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{data.entries.map((entry) => (
|
||||
{data.entries.map((entry) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-testid="mood-row"
|
||||
className="bg-card border-border flex items-center justify-between rounded-lg border p-3"
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className="bg-card border-border flex items-center justify-between rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
|
||||
`<button role=checkbox>`; a wrapping `<label>` does
|
||||
NOT forward taps to a button, so the effective tap
|
||||
target was 16px (fails WCAG 2.5.5). The 44px button
|
||||
owns the whole hit area and is the single toggle
|
||||
source; the inner Checkbox is a `pointer-events-none`
|
||||
controlled visual, so a tap fires the handler once. */}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
aria-label={t("dataList.selectRow")}
|
||||
onClick={() => onToggleRow(entry.id)}
|
||||
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<span
|
||||
data-testid="mood-row-score"
|
||||
@@ -546,15 +763,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
mobile
|
||||
iconClassName="h-4 w-4"
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
isDeleting={bulkDeleteMutation.isPending}
|
||||
confirmTitle={t("mood.bulkDeleteConfirmTitle", {
|
||||
count: String(selectedOnPage),
|
||||
})}
|
||||
confirmBody={t("mood.bulkDeleteConfirmBody", {
|
||||
count: String(selectedOnPage),
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -565,19 +799,25 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{/* v1.15.13 L2 — match the measurements list's 44px pagination
|
||||
target (was `size="sm"`, below the WCAG 2.5.5 floor). */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
onClick={() => goToPage((p) => p - 1)}
|
||||
aria-label={t("mood.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
onClick={() => goToPage((p) => p + 1)}
|
||||
aria-label={t("mood.nextPage")}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -797,85 +1037,31 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
/**
|
||||
* v1.15.13 — render a `MoodEntry.source` value with its localized label.
|
||||
* MANUAL is shown via the source filter only (a manual row carries no
|
||||
* badge in the table). MOODLOG keeps its existing `mood.sourceMoodlog`
|
||||
* key; the rest fall back to their own keys.
|
||||
*/
|
||||
function formatMoodSource(
|
||||
source: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
switch (source) {
|
||||
case "MANUAL":
|
||||
return t("mood.sourceManual");
|
||||
case "MOODLOG":
|
||||
return t("mood.sourceMoodlog");
|
||||
case "WEB":
|
||||
return t("mood.sourceWeb");
|
||||
case "TELEGRAM":
|
||||
return t("mood.sourceTelegram");
|
||||
case "DAYLIO":
|
||||
return t("mood.sourceDaylio");
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
onConfirm,
|
||||
mobile = false,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={
|
||||
mobile
|
||||
? "text-destructive min-h-11 min-w-11"
|
||||
: "text-destructive h-8 w-8"
|
||||
}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
<Trash2 className={mobile ? "h-4 w-4" : "h-3.5 w-3.5"} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("mood.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("mood.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
/** v1.15.13 — mood source enum values offered in the source filter. */
|
||||
const MOOD_SOURCE_OPTIONS = moodSourceEnum.options;
|
||||
@@ -579,6 +579,26 @@ const deleteByExternalIdsResponse = z
|
||||
})
|
||||
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
|
||||
|
||||
// v1.15.13 — multi-select bulk soft-delete for the measurements
|
||||
// management list. Ids are scoped to the caller; forged / foreign ids
|
||||
// are silent no-ops (no existence leak). Soft-delete (tombstone), so the
|
||||
// rows surface in the `/api/sync/changes` delta feed.
|
||||
const bulkDeleteMeasurementsRequest = z
|
||||
.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(200),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsBulkDeleteRequest",
|
||||
description:
|
||||
"1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).",
|
||||
});
|
||||
|
||||
const bulkDeleteMeasurementsResponse = z
|
||||
.object({
|
||||
deleted: z.number().int().nonnegative(),
|
||||
})
|
||||
.meta({ id: "MeasurementsBulkDeleteResult" });
|
||||
|
||||
const measurementResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -3444,6 +3464,34 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements/bulk-delete": {
|
||||
post: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Bulk soft-delete measurements",
|
||||
description:
|
||||
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: bulkDeleteMeasurementsRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Bulk delete processed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
bulkDeleteMeasurementsResponse,
|
||||
"MeasurementsBulkDeleteResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sleep/night": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
|
||||
@@ -41,6 +41,66 @@ export const queryKeys = {
|
||||
measurements: () => ["measurements"] as const,
|
||||
moodEntries: () => ["mood-entries"] as const,
|
||||
|
||||
/**
|
||||
* v1.15.13 — the measurements management-list read with its full filter
|
||||
* + sort + pagination state baked into the key so the cache slot is
|
||||
* correct for every filter combination (a `sourceEq` / date-range /
|
||||
* type / page / sort change re-keys instead of poisoning a shared
|
||||
* slot). Rides under the `["measurements"]` prefix so
|
||||
* `measurementDependentKeys` (and a bulk-delete invalidation) reaches
|
||||
* every slot at once. `mode` distinguishes the synthetic day-grouped /
|
||||
* sleep-night branches from the plain `raw` list.
|
||||
*/
|
||||
measurementsList: (params: {
|
||||
type: string | undefined;
|
||||
sourceEq: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
page: number;
|
||||
sortBy: string;
|
||||
sortDir: string;
|
||||
mode: "raw" | "groupBy=day" | "sleep-night";
|
||||
}) =>
|
||||
[
|
||||
"measurements",
|
||||
"list",
|
||||
params.type ?? null,
|
||||
params.sourceEq ?? null,
|
||||
params.from ?? null,
|
||||
params.to ?? null,
|
||||
params.page,
|
||||
params.sortBy,
|
||||
params.sortDir,
|
||||
params.mode,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.15.13 — the mood management-list read with its filter + sort +
|
||||
* pagination state baked into the key, mirroring `measurementsList`.
|
||||
* Rides under the `["mood-entries"]` prefix so `moodDependentKeys`
|
||||
* (and a bulk-delete invalidation) reaches every slot.
|
||||
*/
|
||||
moodEntriesList: (params: {
|
||||
mood: string | undefined;
|
||||
source: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
page: number;
|
||||
sortBy: string;
|
||||
sortDir: string;
|
||||
}) =>
|
||||
[
|
||||
"mood-entries",
|
||||
"list",
|
||||
params.mood ?? null,
|
||||
params.source ?? null,
|
||||
params.from ?? null,
|
||||
params.to ?? null,
|
||||
params.page,
|
||||
params.sortBy,
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date`
|
||||
* discriminator lets the night-picker step back through recent nights
|
||||
|
||||
@@ -236,6 +236,48 @@ describe("measurement validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMeasurementsSchema — v1.15.13 sourceEq filter", () => {
|
||||
it("accepts a valid MeasurementSource on sourceEq", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "WITHINGS" });
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.sourceEq).toBe("WITHINGS");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves sourceEq undefined when omitted", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.sourceEq).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown sourceEq value", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "BOGUS" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the rollup opt-in value on sourceEq (distinct from source)", () => {
|
||||
// `rollup` is the rollup-tier opt-in for `source`, NOT a valid
|
||||
// MeasurementSource filter — sourceEq must not accept it.
|
||||
const parsed = listMeasurementsSchema.safeParse({ sourceEq: "rollup" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts source=rollup and sourceEq together (independent params)", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
source: "rollup",
|
||||
sourceEq: "APPLE_HEALTH",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.source).toBe("rollup");
|
||||
expect(parsed.data.sourceEq).toBe("APPLE_HEALTH");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.38 — the per-day drill-down branch always returns at most
|
||||
// 1000 rows (one pathological phone-only stepCount day). Push the
|
||||
// cap into the validator so a caller asking for more sees a 422
|
||||
|
||||
@@ -586,6 +586,12 @@ export const listMeasurementsSchema = z
|
||||
// when the rollup bucket set is empty for the requested window so
|
||||
// brand-new accounts still see correct data on their first chart.
|
||||
source: z.enum(["rollup"]).optional(),
|
||||
// v1.15.13 — management-list source FILTER. The `source` key above is
|
||||
// the rollup-tier opt-in (`["rollup"]`), NOT a source filter, so the
|
||||
// filter rides a distinct param. Validated against `MeasurementSource`;
|
||||
// threaded into the plain-list `where`. Backed by the `(userId, source,
|
||||
// measuredAt)` index (migration 0136).
|
||||
sourceEq: measurementSourceEnum.optional(),
|
||||
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
|
||||
// types (steps, active energy, distance, flights, daylight). When
|
||||
// `groupBy=day` is set and `type` is a cumulative HK type, the route
|
||||
|
||||
@@ -165,6 +165,11 @@ export const updateMoodEntrySchema = z.object({
|
||||
|
||||
export const listMoodEntriesSchema = z.object({
|
||||
mood: moodLevelEnum.optional(),
|
||||
// v1.15.13 — source filter for the management list. Validated against
|
||||
// the mood source set; threaded into the list `where` (the `userId` +
|
||||
// `deletedAt: null` pins stay). Backed by the `(userId, source,
|
||||
// moodLoggedAt)` index (migration 0136).
|
||||
source: moodSourceEnum.optional(),
|
||||
from: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* v1.15.13 — bulk soft-delete + source filter, real-Postgres integration.
|
||||
*
|
||||
* Covers, against the real testcontainer:
|
||||
* - `POST /api/measurements/bulk-delete` soft-deletes only the caller's
|
||||
* rows; a foreign id is a silent no-op (no existence leak), bumps
|
||||
* `syncVersion`, sets `deletedAt`, and the count reflects owned rows
|
||||
* only.
|
||||
* - Idempotency replay returns the same `deleted` count without
|
||||
* re-mutating (a second distinct call would re-bump syncVersion).
|
||||
* - `POST /api/mood-entries/bulk-delete` mirrors the same contract.
|
||||
* - `sourceEq` (measurements) + `source` (mood) narrow the list reads.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_A = "user-bulkdel-a";
|
||||
const USER_B = "user-bulkdel-b";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seedUser(id: string, username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: { id, username, email: `${username}@example.test`, timezone: "UTC" },
|
||||
});
|
||||
}
|
||||
|
||||
async function loginAs(id: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await seedUser(USER_A, "bulkdel-a");
|
||||
await seedUser(USER_B, "bulkdel-b");
|
||||
});
|
||||
|
||||
function postReq(path: string, body: unknown, idempotencyKey?: string) {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/measurements/bulk-delete (real Postgres)", () => {
|
||||
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
const mine = await prisma.measurement.createManyAndReturn({
|
||||
data: [
|
||||
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) },
|
||||
],
|
||||
});
|
||||
const theirs = await prisma.measurement.create({
|
||||
data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const res = await POST(
|
||||
postReq("/api/measurements/bulk-delete", {
|
||||
ids: [mine[0].id, mine[1].id, theirs.id],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
// Only the two owned rows count; the foreign id is a silent no-op.
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
const minedAfter = await prisma.measurement.findMany({
|
||||
where: { id: { in: [mine[0].id, mine[1].id] } },
|
||||
});
|
||||
for (const row of minedAfter) {
|
||||
expect(row.deletedAt).not.toBeNull();
|
||||
// Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2.
|
||||
expect(row.syncVersion).toBe(2);
|
||||
}
|
||||
// The other user's row is untouched (default syncVersion = 1).
|
||||
const theirsAfter = await prisma.measurement.findUnique({
|
||||
where: { id: theirs.id },
|
||||
});
|
||||
expect(theirsAfter!.deletedAt).toBeNull();
|
||||
expect(theirsAfter!.syncVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("idempotency replay returns the cached count without re-mutating", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const row = await prisma.measurement.create({
|
||||
data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const key = "idem-meas-bulk-1";
|
||||
|
||||
const first = await POST(
|
||||
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
||||
|
||||
// Replay with the SAME key → cached body, same count, no re-mutation.
|
||||
const replay = await POST(
|
||||
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
|
||||
);
|
||||
expect(replay.status).toBe(200);
|
||||
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
|
||||
|
||||
// Measurement.syncVersion defaults to 1; the single real delete bumped
|
||||
// it to 2 and the replay did NOT re-run the mutation (so it stays 2).
|
||||
const after = await prisma.measurement.findUnique({ where: { id: row.id } });
|
||||
expect(after!.syncVersion).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects a >200-id batch with 422", async () => {
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`);
|
||||
const res = await POST(postReq("/api/measurements/bulk-delete", { ids }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => {
|
||||
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const mine = await prisma.moodEntry.createManyAndReturn({
|
||||
data: [
|
||||
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
||||
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
||||
],
|
||||
});
|
||||
const theirs = await prisma.moodEntry.create({
|
||||
data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" },
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk-delete/route");
|
||||
const res = await POST(
|
||||
postReq("/api/mood-entries/bulk-delete", {
|
||||
ids: [mine[0].id, mine[1].id, theirs.id],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2);
|
||||
|
||||
const after = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: [mine[0].id, mine[1].id] } },
|
||||
});
|
||||
for (const row of after) {
|
||||
expect(row.deletedAt).not.toBeNull();
|
||||
expect(row.syncVersion).toBe(1);
|
||||
}
|
||||
const theirsAfter = await prisma.moodEntry.findUnique({
|
||||
where: { id: theirs.id },
|
||||
});
|
||||
expect(theirsAfter!.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("source filter narrows the list reads (real Postgres)", () => {
|
||||
it("measurements sourceEq returns only the matching source", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) },
|
||||
{ userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) },
|
||||
],
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ source: string }>;
|
||||
meta: { total: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.measurements).toHaveLength(2);
|
||||
expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true);
|
||||
expect(body.data.meta.total).toBe(2);
|
||||
});
|
||||
|
||||
it("mood source returns only the matching source", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.moodEntry.createMany({
|
||||
data: [
|
||||
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
|
||||
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
|
||||
],
|
||||
});
|
||||
|
||||
await loginAs(USER_A);
|
||||
const { GET } = await import("@/app/api/mood-entries/route");
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ source: string }>; meta: { total: number } };
|
||||
};
|
||||
expect(body.data.entries).toHaveLength(1);
|
||||
expect(body.data.entries[0].source).toBe("TELEGRAM");
|
||||
expect(body.data.meta.total).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user