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

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