mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
8920a35d38
commit
cc89695cbd
14 files changed
+286
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "Von",
|
||||
"dateTo": "Bis",
|
||||
"dateRange": "Zeitraum",
|
||||
"valueRange": "Wert",
|
||||
"valueMin": "Min.",
|
||||
"valueMax": "Max.",
|
||||
"typeLabel": "Typ",
|
||||
"sourceLabel": "Quelle",
|
||||
"resetFilters": "Zurücksetzen",
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "From",
|
||||
"dateTo": "To",
|
||||
"dateRange": "Date range",
|
||||
"valueRange": "Value",
|
||||
"valueMin": "Min",
|
||||
"valueMax": "Max",
|
||||
"typeLabel": "Type",
|
||||
"sourceLabel": "Source",
|
||||
"resetFilters": "Reset",
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "Desde",
|
||||
"dateTo": "Hasta",
|
||||
"dateRange": "Periodo",
|
||||
"valueRange": "Valor",
|
||||
"valueMin": "Mín.",
|
||||
"valueMax": "Máx.",
|
||||
"typeLabel": "Tipo",
|
||||
"sourceLabel": "Fuente",
|
||||
"resetFilters": "Restablecer",
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "Du",
|
||||
"dateTo": "Au",
|
||||
"dateRange": "Période",
|
||||
"valueRange": "Valeur",
|
||||
"valueMin": "Min.",
|
||||
"valueMax": "Max.",
|
||||
"typeLabel": "Type",
|
||||
"sourceLabel": "Source",
|
||||
"resetFilters": "Réinitialiser",
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "Dal",
|
||||
"dateTo": "Al",
|
||||
"dateRange": "Periodo",
|
||||
"valueRange": "Valore",
|
||||
"valueMin": "Min.",
|
||||
"valueMax": "Max.",
|
||||
"typeLabel": "Tipo",
|
||||
"sourceLabel": "Fonte",
|
||||
"resetFilters": "Reimposta",
|
||||
|
||||
@@ -6377,6 +6377,9 @@
|
||||
"dateFrom": "Od",
|
||||
"dateTo": "Do",
|
||||
"dateRange": "Zakres dat",
|
||||
"valueRange": "Wartość",
|
||||
"valueMin": "Min.",
|
||||
"valueMax": "Maks.",
|
||||
"typeLabel": "Typ",
|
||||
"sourceLabel": "Źródło",
|
||||
"resetFilters": "Resetuj",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user