+ );
+}
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 && (
+ <>
+
@@ -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". */}