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

@@ -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>
);
}