feat(measurements): value-range filter on the management list

Add an optional numeric value-range filter to the measurement list:
readings whose `value` falls within `[valueMin, valueMax]`, either bound
omittable for an open-ended range. Server-authoritative — both bounds are
parsed on `listMeasurementsSchema` (coerced from the query string), an
inverted `valueMin > valueMax` returns 422, and the bounds thread into the
Prisma `where` as a `value: { gte, lte }` range on the raw per-sample list.

The filter rides a new `FilterBarNumberRange` pill that mirrors the existing
date-range control, hidden for the day-grouped and sleep-night types where
the painted value is a synthesised total rather than a stored reading.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 14:42:29 +02:00
1 parent 8920a35d38
commit cc89695cbd
14 files changed
+286 -4

No files matched your search

@@ -76,14 +76,17 @@ describe("MeasurementList — filter bar + multi-select chrome", () => {
// `<FilterBar>` pill rail (date range · type · source pills + count).
// The date inputs now live inside the pill's popover (closed in SSR),
// so the guard pins the labelled pill triggers instead.
it("renders the filter rail with type, source and date-range pills", () => {
it("renders the filter rail with type, source, date-range and value pills", () => {
const html = render("en");
expect(html).toContain('data-slot="filter-bar"');
expect(html).toContain('aria-label="Type"');
expect(html).toContain('aria-label="Source"');
expect(html).toContain('aria-label="Date range"');
// v1.18.5 — value-range pill rides the rail for the raw per-sample
// list (the default `ALL` type is not a day-grouped/sleep type).
expect(html).toContain('aria-label="Value"');
const pills = html.match(/data-slot="filter-bar-pill"/g);
expect(pills?.length).toBe(3);
expect(pills?.length).toBe(4);
});
it("renders labelled selection checkboxes (per-row + select-all)", () => {
@@ -8,6 +8,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import {
FilterBar,
FilterBarDateRange,
FilterBarNumberRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import {
@@ -275,6 +276,10 @@ export function MeasurementList({
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
// v1.18.5 — value-range filter (backlog G). Raw input strings; empty
// means the bound is unset (open-ended on that side).
const [valueMinInput, setValueMinInputRaw] = useState<string>("");
const [valueMaxInput, setValueMaxInputRaw] = useState<string>("");
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("measuredAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -345,6 +350,18 @@ export function MeasurementList({
clearSelection();
};
const setValueMinInput = (value: string) => {
setValueMinInputRaw(value);
setPage(1);
clearSelection();
};
const setValueMaxInput = (value: string) => {
setValueMaxInputRaw(value);
setPage(1);
clearSelection();
};
const goToPage = (updater: (p: number) => number) => {
setPage(updater);
clearSelection();
@@ -382,6 +399,19 @@ export function MeasurementList({
const fromIso = dayBoundaryIso(fromDay, "start");
const toIso = dayBoundaryIso(toDay, "end");
const sourceEq = sourceFilter === "ALL" ? undefined : sourceFilter;
// v1.18.5 — value-range bounds. Empty / non-numeric input clears the
// bound. Only threaded on the raw per-sample list; the day-grouped and
// sleep-night modes synthesise rows server-side (a SUM / per-night
// TIME-ASLEEP), where filtering on a raw `value` column carries no
// meaning, so the range pill is hidden for those types.
const valueMin =
valueMinInput.trim() !== "" && Number.isFinite(Number(valueMinInput))
? Number(valueMinInput)
: undefined;
const valueMax =
valueMaxInput.trim() !== "" && Number.isFinite(Number(valueMaxInput))
? Number(valueMaxInput)
: undefined;
const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter
? "sleep-night"
: isCumulativeFilter
@@ -394,6 +424,9 @@ export function MeasurementList({
sourceEq,
from: fromIso,
to: toIso,
// Value range applies only to the raw per-sample list.
valueMin: isDayGroupedFilter ? undefined : valueMin,
valueMax: isDayGroupedFilter ? undefined : valueMax,
page,
sortBy,
sortDir,
@@ -405,6 +438,11 @@ export function MeasurementList({
if (sourceEq) params.set("sourceEq", sourceEq);
if (fromIso) params.set("from", fromIso);
if (toIso) params.set("to", toIso);
// v1.18.5 — value range only on the raw per-sample list.
if (!isDayGroupedFilter && valueMin != null)
params.set("valueMin", String(valueMin));
if (!isDayGroupedFilter && valueMax != null)
params.set("valueMax", String(valueMax));
params.set("limit", String(PAGE_SIZE));
params.set("offset", String((page - 1) * PAGE_SIZE));
params.set("sortBy", sortBy);
@@ -678,13 +716,17 @@ export function MeasurementList({
typeFilter !== "ALL" ||
sourceFilter !== "ALL" ||
fromDay !== "" ||
toDay !== ""
toDay !== "" ||
(!isDayGroupedFilter &&
(valueMinInput !== "" || valueMaxInput !== ""))
}
onReset={() => {
setTypeFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
setValueMinInput("");
setValueMaxInput("");
}}
count={
data?.meta?.total !== undefined
@@ -721,6 +763,21 @@ export function MeasurementList({
label: formatMeasurementSource(src, t),
}))}
/>
{/* v1.18.5 — value-range pill. Hidden for the day-grouped /
sleep-night types where the painted `value` is a synthesised
SUM / per-night total rather than a raw reading. */}
{!isDayGroupedFilter && (
<FilterBarNumberRange
label={t("dataList.valueRange")}
min={valueMinInput}
max={valueMaxInput}
onMinChange={setValueMinInput}
onMaxChange={setValueMaxInput}
idPrefix="measurements-value"
fromLabel={t("dataList.valueMin")}
toLabel={t("dataList.valueMax")}
/>
)}
</FilterBar>
)}
+99
View File
@@ -5,6 +5,7 @@ import { ListFilter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
@@ -294,3 +295,101 @@ export function FilterBarDateRange({
</span>
);
}
/**
* v1.18.5 — value-range filter pill (backlog G). Mirrors
* `FilterBarDateRange` but over a numeric `[min, max]` band; either bound
* may be left empty for an open-ended range. State stays as strings (the
* raw input value) so the owning component controls coercion; empty string
* means "unset". The pill shows `min – max` with an ellipsis for an open
* bound, exactly as the date range does.
*/
export function FilterBarNumberRange({
label,
min,
max,
onMinChange,
onMaxChange,
idPrefix,
fromLabel,
toLabel,
}: {
label: string;
/** Raw input string (a number) or empty string when unset. */
min: string;
max: string;
onMinChange: (value: string) => void;
onMaxChange: (value: string) => void;
/** Unique prefix for the two input ids (label association). */
idPrefix: string;
fromLabel: string;
toLabel: string;
}) {
const active = min !== "" || max !== "";
const display = active ? `${min || "…"} – ${max || "…"}` : null;
const clear = () => {
onMinChange("");
onMaxChange("");
};
return (
<span className="relative inline-flex shrink-0">
<Popover>
<PopoverTrigger
aria-label={label}
data-slot="filter-bar-pill"
data-active={active ? "true" : "false"}
className={cn(
PILL_CLASSES,
"outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
active && cn(PILL_ACTIVE_CLASSES, "pr-8"),
)}
>
{active ? (
<span className="truncate">
<span className="text-muted-foreground">{label}: </span>
{display}
</span>
) : (
<span className="text-muted-foreground">{label}</span>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-auto space-y-3 p-4">
<div className="flex flex-col gap-1">
<Label
htmlFor={`${idPrefix}-min`}
className="text-muted-foreground text-xs"
>
{fromLabel}
</Label>
<Input
id={`${idPrefix}-min`}
type="number"
inputMode="decimal"
className="w-44"
value={min}
onChange={(e) => onMinChange(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor={`${idPrefix}-max`}
className="text-muted-foreground text-xs"
>
{toLabel}
</Label>
<Input
id={`${idPrefix}-max`}
type="number"
inputMode="decimal"
className="w-44"
value={max}
onChange={(e) => onMaxChange(e.target.value)}
/>
</div>
</PopoverContent>
</Popover>
{active && <ClearPillButton label={label} onClear={clear} />}
</span>
);
}