From f2bd051fa7c804d9739a086867396bced838d3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 19:39:41 +0200 Subject: [PATCH] feat(measurements): shared filter bar and multi-select bulk delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the duplicated list chrome — SortableHead and the single-row DeleteButton — into a shared src/components/data-list/ module and add a page-scoped multi-select with a selection action bar, used by both the measurements and mood management lists. Filter bar: each list keeps its primary Select (type / mood level) and gains a source Select (measurements sends sourceEq, mood sends source) plus an optional date range wired to the existing from/to params. Every filter resets pagination to page 1 and is baked into the query key via the centralised factory (queryKeys.measurementsList / moodEntriesList) so the cache stays correct per filter combination. Multi-select is scoped to the current page: a leading checkbox column on desktop, a checkbox on the mobile cards, and a select-all-on-page header checkbox. When at least one row is selected an action bar shows the count, a clear affordance, and a destructive Delete (N) behind an AlertDialog confirm that POSTs to the bulk-delete route, then invalidates the same dependent-key bundle the single delete busts and clears the selection. Selection clears on any page / filter / sort change; the selection math lives in a pure, unit-tested helper. The locked-type insights subpage keeps its focused single-metric view (source filter + date range suppressed). New strings land in a dataList namespace plus per-list source-filter and bulk-delete keys, translated across all six locales. --- messages/de.json | 32 +- messages/en.json | 32 +- messages/es.json | 32 +- messages/fr.json | 32 +- messages/it.json | 32 +- messages/pl.json | 32 +- .../data-list/__tests__/selection.test.ts | 97 ++++ src/components/data-list/delete-button.tsx | 76 +++ src/components/data-list/index.ts | 20 + .../data-list/selection-action-bar.tsx | 114 +++++ src/components/data-list/selection.ts | 94 ++++ src/components/data-list/sortable-head.tsx | 50 ++ .../measurement-list-filter-select.test.tsx | 101 ++++ .../measurements/measurement-list.tsx | 465 +++++++++++++----- .../mood-list-filter-select.test.tsx | 100 ++++ src/components/mood/mood-list.tsx | 400 ++++++++++----- src/lib/query-keys.ts | 60 +++ 17 files changed, 1515 insertions(+), 254 deletions(-) create mode 100644 src/components/data-list/__tests__/selection.test.ts create mode 100644 src/components/data-list/delete-button.tsx create mode 100644 src/components/data-list/index.ts create mode 100644 src/components/data-list/selection-action-bar.tsx create mode 100644 src/components/data-list/selection.ts create mode 100644 src/components/data-list/sortable-head.tsx create mode 100644 src/components/measurements/__tests__/measurement-list-filter-select.test.tsx create mode 100644 src/components/mood/__tests__/mood-list-filter-select.test.tsx diff --git a/messages/de.json b/messages/de.json index 976ea7cc8..fa3052963 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/messages/en.json b/messages/en.json index 1831e188b..1ff732096 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/messages/es.json b/messages/es.json index ec2fd334e..4cca7a5ac 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/messages/fr.json b/messages/fr.json index bfa9dd524..9ad5342ad 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/messages/it.json b/messages/it.json index 3200c2b2e..063e8b425 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/messages/pl.json b/messages/pl.json index fe399b31e..38e9b03d9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", @@ -598,7 +606,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 +5320,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" } } diff --git a/src/components/data-list/__tests__/selection.test.ts b/src/components/data-list/__tests__/selection.test.ts new file mode 100644 index 000000000..f12ef3fdd --- /dev/null +++ b/src/components/data-list/__tests__/selection.test.ts @@ -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); + }); + }); +}); diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx new file mode 100644 index 000000000..dd53016e3 --- /dev/null +++ b/src/components/data-list/delete-button.tsx @@ -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 ( + + + + + + + {title} + {description} + + + {cancelLabel ?? t("common.cancel")} + + {confirmLabel ?? t("common.delete")} + + + + + ); +} diff --git a/src/components/data-list/index.ts b/src/components/data-list/index.ts new file mode 100644 index 000000000..e57f9faa4 --- /dev/null +++ b/src/components/data-list/index.ts @@ -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"; diff --git a/src/components/data-list/selection-action-bar.tsx b/src/components/data-list/selection-action-bar.tsx new file mode 100644 index 000000000..76409da08 --- /dev/null +++ b/src/components/data-list/selection-action-bar.tsx @@ -0,0 +1,114 @@ +"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 ( +
+
+ + + {t("dataList.selected", { count: String(count) })} + +
+ + + + + + + + {confirmTitle} + {confirmBody} + + + + {t("common.cancel")} + + + {isDeleting ? ( + + ) : null} + {t("common.delete")} + + + + +
+ ); +} diff --git a/src/components/data-list/selection.ts b/src/components/data-list/selection.ts new file mode 100644 index 000000000..d482aebf3 --- /dev/null +++ b/src/components/data-list/selection.ts @@ -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` 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, id: string): Set { + 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, + 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, + pageIds: readonly string[], +): Set { + 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, + 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, + pageIds: readonly string[], +): number { + let n = 0; + for (const id of pageIds) { + if (selected.has(id)) n += 1; + } + return n; +} diff --git a/src/components/data-list/sortable-head.tsx b/src/components/data-list/sortable-head.tsx new file mode 100644 index 000000000..c02c0ac12 --- /dev/null +++ b/src/components/data-list/sortable-head.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx new file mode 100644 index 000000000..53accc4c0 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx @@ -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( + + + , + ); +} + +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"'); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index f1f5bc938..0c64b42c1 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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 `` 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( 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("ALL"); + const [fromDay, setFromDayRaw] = useState(""); + const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("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>( + () => 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. */} -
- {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. - {t("measurements.filterByType")} - ) : ( - - )} +
+
+ {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. + {t("measurements.filterByType")} + ) : ( +
+ + +
+ )} + + {/* 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 && ( + <> +
+ + +
+ +
+ + setFromDay(e.target.value)} + /> +
+
+ + setToDay(e.target.value)} + /> +
+ + )} +
+ {data?.meta?.total !== undefined && ( {t("measurements.measurementCount", { @@ -576,7 +808,22 @@ export function MeasurementList({ - + + {/* v1.15.13 — select-all-on-page header checkbox. */} + + + {t("measurements.type")} - + + {/* Grouped/synthetic rows aren't individually + deletable, so they have no checkbox. */} + {!isGrouped && ( + onToggleRow(m.id)} + aria-label={t("dataList.selectRow")} + /> + )} + + deleteMutation.mutate(m.id) } + title={t("measurements.deleteConfirmTitle")} + description={t( + "measurements.deleteConfirmDescription", + )} /> )} @@ -752,7 +1015,7 @@ export function MeasurementList({ {isGrouped && isExpanded && ( - +
-
+
+ {/* v1.15.13 — multi-select checkbox in a 44px tap + target; absent for synthetic grouped rows. */} + {!isGrouped && ( + + )} {Icon && (
deleteMutation.mutate(m.id)} + title={t("measurements.deleteConfirmTitle")} + description={t( + "measurements.deleteConfirmDescription", + )} /> )} @@ -921,6 +1202,20 @@ export function MeasurementList({ )} + {/* v1.15.13 — page-scoped multi-select action bar. */} + + {/* Pagination */} {totalPages > 1 && (
@@ -936,7 +1231,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")} > @@ -946,7 +1241,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")} > @@ -1279,83 +1574,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 ( - - - - ); -} - -function DeleteButton({ - onConfirm, - className = "size-11", -}: { - onConfirm: () => void; - className?: string; -}) { - const { t } = useTranslations(); - return ( - - - - - - - - {t("measurements.deleteConfirmTitle")} - - - {t("measurements.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("common.delete")} - - - - - ); -} diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx new file mode 100644 index 000000000..774e8e154 --- /dev/null +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -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( + + + , + ); +} + +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<"); + }); +}); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index aeff2d266..f7d37cce5 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -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("ALL"); + // v1.15.13 — management-list source filter + optional date range. + const [sourceFilter, setSourceFilterRaw] = useState("ALL"); + const [fromDay, setFromDayRaw] = useState(""); + const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("moodLoggedAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // v1.15.13 — page-scoped multi-select selection (current page ids only). + const [selectedIds, setSelectedIds] = useState>( + () => new Set(), + ); + const [editing, setEditing] = useState(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 ( <>
-
- +
+
+
+ + +
+ + {/* v1.15.13 — source filter. */} +
+ + +
+ + {/* v1.15.13 — optional date range, wired to from/to. */} +
+ + setFromDay(e.target.value)} + /> +
+
+ + setToDay(e.target.value)} + /> +
+
+ {data?.meta?.total !== undefined && ( {t("mood.entryCount", { @@ -388,13 +550,28 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ + {/* v1.15.13 — select-all-on-page header checkbox. */} + + {t("mood.tags")} - {data.entries.map((entry) => ( - - + {data.entries.map((entry) => { + const isSelected = selectedIds.has(entry.id); + return ( + + + onToggleRow(entry.id)} + aria-label={t("dataList.selectRow")} + /> + + {entry.score}{" "} ( @@ -457,7 +646,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { {entry.source !== "MANUAL" && ( - {t("mood.sourceMoodlog")} + {formatMoodSource(entry.source, t)} )} @@ -474,11 +663,14 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { deleteMutation.mutate(entry.id)} + title={t("mood.deleteConfirmTitle")} + description={t("mood.deleteConfirmDescription")} /> - ))} + ); + })}
@@ -493,13 +685,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { guard at `e2e/mood-card-mobile.spec.ts` queries to assert "exactly one occurrence of the score per row". */}
- {data.entries.map((entry) => ( + {data.entries.map((entry) => { + const isSelected = selectedIds.has(entry.id); + return (
-
+
+ {/* v1.15.13 — multi-select checkbox in a 44px target. */} +
deleteMutation.mutate(entry.id)} - mobile + iconClassName="h-4 w-4" + title={t("mood.deleteConfirmTitle")} + description={t("mood.deleteConfirmDescription")} />
- ))} + ); + })}
)} + {/* v1.15.13 — page-scoped multi-select action bar. */} + + {/* Pagination */} {totalPages > 1 && (
@@ -569,7 +789,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { variant="ghost" size="sm" disabled={page <= 1} - onClick={() => setPage((p) => p - 1)} + onClick={() => goToPage((p) => p - 1)} > @@ -577,7 +797,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { variant="ghost" size="sm" disabled={page >= totalPages} - onClick={() => setPage((p) => p + 1)} + onClick={() => goToPage((p) => p + 1)} > @@ -797,85 +1017,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 ( - - - - ); +/** + * 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["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 ( - - - - - - - {t("mood.deleteConfirmTitle")} - - {t("mood.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("common.delete")} - - - - - ); -} +/** v1.15.13 — mood source enum values offered in the source filter. */ +const MOOD_SOURCE_OPTIONS = moodSourceEnum.options; diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 18d414d8e..13614b656 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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