feat(search): numeric value-range filter (valueMin/valueMax)

This commit is contained in:
Marc-André Bombeck committed 2026-06-18 14:42:52 +02:00
commit e0796c5e44
14 files changed
+286 -4

No files matched your search

+8
View File
@@ -1648,6 +1648,14 @@ paths:
- NIGHTSCOUT
- POLAR
- OURA
- in: query
name: valueMin
schema:
type: number
- in: query
name: valueMax
schema:
type: number
- in: query
name: groupBy
schema:
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "Von",
"dateTo": "Bis",
"dateRange": "Zeitraum",
"valueRange": "Wert",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Typ",
"sourceLabel": "Quelle",
"resetFilters": "Zurücksetzen",
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "From",
"dateTo": "To",
"dateRange": "Date range",
"valueRange": "Value",
"valueMin": "Min",
"valueMax": "Max",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Reset",
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "Desde",
"dateTo": "Hasta",
"dateRange": "Periodo",
"valueRange": "Valor",
"valueMin": "Mín.",
"valueMax": "Máx.",
"typeLabel": "Tipo",
"sourceLabel": "Fuente",
"resetFilters": "Restablecer",
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "Du",
"dateTo": "Au",
"dateRange": "Période",
"valueRange": "Valeur",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Réinitialiser",
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "Dal",
"dateTo": "Al",
"dateRange": "Periodo",
"valueRange": "Valore",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Tipo",
"sourceLabel": "Fonte",
"resetFilters": "Reimposta",
+3
View File
@@ -6384,6 +6384,9 @@
"dateFrom": "Od",
"dateTo": "Do",
"dateRange": "Zakres dat",
"valueRange": "Wartość",
"valueMin": "Min.",
"valueMax": "Maks.",
"typeLabel": "Typ",
"sourceLabel": "Źródło",
"resetFilters": "Resetuj",
+12
View File
@@ -99,6 +99,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
aggregate,
source,
sourceEq,
valueMin,
valueMax,
groupBy,
dayKey,
} = parsed.data;
@@ -116,6 +118,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
// `source=rollup` opt-in above (which selects the rollup read tier);
// `sourceEq` narrows the plain list to one ingest source.
...(sourceEq && { source: sourceEq }),
// v1.18.5 — value-range filter (backlog G). Open-ended on either side;
// the validator has already rejected an inverted `valueMin > valueMax`.
...(valueMin != null || valueMax != null
? {
value: {
...(valueMin != null && { gte: valueMin }),
...(valueMax != null && { lte: valueMax }),
},
}
: {}),
...(from || to
? {
measuredAt: {
@@ -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>
);
}
+4
View File
@@ -20,6 +20,8 @@ export const measurementKeys = {
sourceEq: string | undefined;
from: string | undefined;
to: string | undefined;
valueMin: number | undefined;
valueMax: number | undefined;
page: number;
sortBy: string;
sortDir: string;
@@ -32,6 +34,8 @@ export const measurementKeys = {
params.sourceEq ?? null,
params.from ?? null,
params.to ?? null,
params.valueMin ?? null,
params.valueMax ?? null,
params.page,
params.sortBy,
params.sortDir,
@@ -331,6 +331,68 @@ describe("measurement validation", () => {
});
});
describe("listMeasurementsSchema — v1.18.5 value-range filter", () => {
it("coerces valueMin / valueMax from query strings", () => {
const parsed = listMeasurementsSchema.safeParse({
valueMin: "80",
valueMax: "120",
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.valueMin).toBe(80);
expect(parsed.data.valueMax).toBe(120);
}
});
it("accepts an open-ended min-only range", () => {
const parsed = listMeasurementsSchema.safeParse({ valueMin: "100" });
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.valueMin).toBe(100);
expect(parsed.data.valueMax).toBeUndefined();
}
});
it("accepts an open-ended max-only range", () => {
const parsed = listMeasurementsSchema.safeParse({ valueMax: "90" });
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.valueMax).toBe(90);
expect(parsed.data.valueMin).toBeUndefined();
}
});
it("accepts valueMin == valueMax (exact-value band)", () => {
const parsed = listMeasurementsSchema.safeParse({
valueMin: "72",
valueMax: "72",
});
expect(parsed.success).toBe(true);
});
it("rejects an inverted range (valueMin > valueMax) with a 422-shaped issue", () => {
const parsed = listMeasurementsSchema.safeParse({
valueMin: "120",
valueMax: "80",
});
expect(parsed.success).toBe(false);
if (!parsed.success) {
expect(parsed.error.issues.some((i) => i.path[0] === "valueMin")).toBe(
true,
);
}
});
it("leaves both bounds undefined when omitted", () => {
const parsed = listMeasurementsSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.valueMin).toBeUndefined();
expect(parsed.data.valueMax).toBeUndefined();
}
});
});
// v1.4.38 — the per-day drill-down branch always returns at most
// 1000 rows (one pathological phone-only stepCount day). Push the
// cap into the validator so a caller asking for more sees a 422
+20 -1
View File
@@ -651,6 +651,13 @@ export const listMeasurementsSchema = z
// threaded into the plain-list `where`. Backed by the `(userId, source,
// measuredAt)` index (migration 0136).
sourceEq: measurementSourceEnum.optional(),
// v1.18.5 — value-range FILTER (backlog G). Narrows the plain list to
// readings whose `value` falls within an optional `[valueMin, valueMax]`
// band. Either bound may be omitted for an open-ended range. Coerced
// from the query string; threaded into the plain-list `where` as a
// `value: { gte, lte }` range. Server-authoritative; validated min<=max.
valueMin: z.coerce.number().finite().optional(),
valueMax: z.coerce.number().finite().optional(),
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types (steps, active energy, distance, flights, daylight). When
// `groupBy=day` is set and `type` is a cumulative HK type, the route
@@ -715,7 +722,19 @@ export const listMeasurementsSchema = z
message:
"limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows",
path: ["limit"],
});
})
// v1.18.5 — an inverted value range (`valueMin > valueMax`) is a client
// bug, not an empty result set; reject it at the validator with a 422 so
// the filter UI can surface the mismatch instead of silently returning
// zero rows. Open-ended ranges (one bound omitted) pass through.
.refine(
({ valueMin, valueMax }) =>
valueMin == null || valueMax == null || valueMin <= valueMax,
{
message: "valueMin must be <= valueMax",
path: ["valueMin"],
},
);
export const createBatchMeasurementSchema = z.object({
measurements: z.array(createMeasurementSchema).min(1).max(5),