feat(measurements): shared filter bar and multi-select bulk delete

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 19:39:41 +02:00
1 parent ca262d9839
commit f2bd051fa7
17 files changed
+1515 -254

No files matched your search

+30 -2
View File
@@ -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"
}
}
+30 -2
View File
@@ -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"
}
}
+30 -2
View File
@@ -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"
}
}
+30 -2
View File
@@ -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"
}
}
+30 -2
View File
@@ -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"
}
}
+30 -2
View File
@@ -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"
}
}
@@ -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>
);
}
+20
View File
@@ -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,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 (
<div
role="region"
aria-label={t("dataList.selectionBarLabel")}
className="bg-card border-border sticky bottom-2 z-10 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>
);
}
+94
View File
@@ -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"');
});
});
+340 -125
View File
@@ -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,26 @@ 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 && (
<label className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
/>
</label>
)}
{Icon && (
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
@@ -898,7 +1174,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 +1202,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 +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")}
>
<ChevronLeft className="h-4 w-4" />
@@ -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")}
>
<ChevronRight className="h-4 w-4" />
@@ -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 (
<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<");
});
});
+283 -117
View File
@@ -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,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". */}
<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 — multi-select checkbox in a 44px target. */}
<label className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
</label>
<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 +749,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">
@@ -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)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -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)}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -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 (
<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;
+60
View File
@@ -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