diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b3578916..6f6116af2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 95af909c0..945324bc9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6384,6 +6384,9 @@ "dateFrom": "Von", "dateTo": "Bis", "dateRange": "Zeitraum", + "valueRange": "Wert", + "valueMin": "Min.", + "valueMax": "Max.", "typeLabel": "Typ", "sourceLabel": "Quelle", "resetFilters": "Zurücksetzen", diff --git a/messages/en.json b/messages/en.json index afaeb1e0b..9285cc541 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6384,6 +6384,9 @@ "dateFrom": "From", "dateTo": "To", "dateRange": "Date range", + "valueRange": "Value", + "valueMin": "Min", + "valueMax": "Max", "typeLabel": "Type", "sourceLabel": "Source", "resetFilters": "Reset", diff --git a/messages/es.json b/messages/es.json index f36fc5296..4eb44003b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6384,6 +6384,9 @@ "dateFrom": "Desde", "dateTo": "Hasta", "dateRange": "Periodo", + "valueRange": "Valor", + "valueMin": "Mín.", + "valueMax": "Máx.", "typeLabel": "Tipo", "sourceLabel": "Fuente", "resetFilters": "Restablecer", diff --git a/messages/fr.json b/messages/fr.json index 47a61720c..4a61eb47d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6384,6 +6384,9 @@ "dateFrom": "Du", "dateTo": "Au", "dateRange": "Période", + "valueRange": "Valeur", + "valueMin": "Min.", + "valueMax": "Max.", "typeLabel": "Type", "sourceLabel": "Source", "resetFilters": "Réinitialiser", diff --git a/messages/it.json b/messages/it.json index c1eac2476..386325fff 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6384,6 +6384,9 @@ "dateFrom": "Dal", "dateTo": "Al", "dateRange": "Periodo", + "valueRange": "Valore", + "valueMin": "Min.", + "valueMax": "Max.", "typeLabel": "Tipo", "sourceLabel": "Fonte", "resetFilters": "Reimposta", diff --git a/messages/pl.json b/messages/pl.json index 9df765a80..853a30a39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 57f78876c..2345089d2 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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: { diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx index d2562b342..b836fb2d8 100644 --- a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx @@ -76,14 +76,17 @@ describe("MeasurementList — filter bar + multi-select chrome", () => { // `` 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)", () => { diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 2d9c2d34e..f8d40ddb2 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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("ALL"); const [fromDay, setFromDayRaw] = useState(""); const [toDay, setToDayRaw] = useState(""); + // 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(""); + const [valueMaxInput, setValueMaxInputRaw] = useState(""); const [page, setPage] = useState(1); const [sortBy, setSortBy] = useState("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 && ( + + )} )} diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index 4df8852b8..b27de321e 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -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({ ); } + +/** + * 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 ( + + + + {active ? ( + + {label}: + {display} + + ) : ( + {label} + )} + + +
+ + onMinChange(e.target.value)} + /> +
+
+ + onMaxChange(e.target.value)} + /> +
+
+
+ {active && } +
+ ); +} diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index a8e9265ad..42c326302 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -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, diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 5110c1e97..51e867ef0 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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 diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index e279c14d2..a2b48acbf 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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),