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

@@ -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),