From e6ea27baf2ab1ab2d5bcdee05b4b8652aff962ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 14:41:14 +0200 Subject: [PATCH 1/3] feat(medications): collapsible advanced groups + injection titration timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the medication detail advanced surface (Erweitert + API tabs) into expand/collapse groups: SettingsGroup gains an opt-in collapsible mode built on the same Radix Collapsible primitive SheetSection uses, so a long settings wall reads as scannable sections. Lifecycle and external-API groups default open; Data and the destructive zone default collapsed. Add a titration timeline to the injection tab — completed and planned dose-escalation steps rendered as a vertical timeline with a "you are here" marker. Reads the existing MedicationDoseChange stream (future-dated rows are planned steps), so no schema change is needed. Neutral palette, no state-driven colour. --- messages/de.json | 7 + messages/en.json | 7 + messages/es.json | 7 + messages/fr.json | 7 + messages/it.json | 7 + messages/pl.json | 7 + .../__tests__/titration-timeline.test.ts | 73 ++++++ .../detail/MedicationDetailTabs.tsx | 12 + src/components/medications/settings-group.tsx | 57 +++- .../medications/titration-timeline.tsx | 246 ++++++++++++++++++ 10 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 src/components/medications/__tests__/titration-timeline.test.ts create mode 100644 src/components/medications/titration-timeline.tsx diff --git a/messages/de.json b/messages/de.json index 7cac0883a..95af909c0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "Noch keine Dosis erfasst.", "empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen." }, + "titration": { + "title": "Titrationsplan", + "empty": "Noch keine Dosis-Steigerungsschritte erfasst. Datierte Dosisänderungen hinzufügen, um den Plan abzubilden.", + "youAreHere": "Du bist hier", + "doseStep": "{dose} {unit}", + "plannedFor": "Geplant · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Körperumriss", "injectionSitePickerAriaLabel": "Injektionsstelle auswählen", "injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle", diff --git a/messages/en.json b/messages/en.json index 1f42141f5..afaeb1e0b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "No dose recorded yet.", "empty": "No titration history yet. Log a dose change to see the strength curve." }, + "titration": { + "title": "Titration plan", + "empty": "No dose-escalation steps recorded yet. Add dated dose changes to plot the plan.", + "youAreHere": "You are here", + "doseStep": "{dose} {unit}", + "plannedFor": "Planned · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Body outline", "injectionSitePickerAriaLabel": "Injection site picker", "injectionSiteRecommendedAriaLabel": "{site} — recommended next site", diff --git a/messages/es.json b/messages/es.json index abfe65977..f36fc5296 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "Aún no hay dosis registrada.", "empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis." }, + "titration": { + "title": "Plan de titulación", + "empty": "Aún no hay pasos de escalada de dosis registrados. Añade cambios de dosis con fecha para trazar el plan.", + "youAreHere": "Estás aquí", + "doseStep": "{dose} {unit}", + "plannedFor": "Previsto · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo", "injectionSitePickerAriaLabel": "Selector de sitio de inyección", "injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado", diff --git a/messages/fr.json b/messages/fr.json index 358419bb8..47a61720c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "Aucune dose enregistrée pour l'instant.", "empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose." }, + "titration": { + "title": "Plan de titration", + "empty": "Aucune étape d'escalade de dose enregistrée pour le moment. Ajoutez des changements de dose datés pour tracer le plan.", + "youAreHere": "Vous êtes ici", + "doseStep": "{dose} {unit}", + "plannedFor": "Prévu · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Silhouette du corps", "injectionSitePickerAriaLabel": "Sélecteur de site d'injection", "injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé", diff --git a/messages/it.json b/messages/it.json index 773c0a809..c1eac2476 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "Nessuna dose ancora registrata.", "empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose." }, + "titration": { + "title": "Piano di titolazione", + "empty": "Nessuna fase di aumento della dose registrata. Aggiungi variazioni di dose con data per tracciare il piano.", + "youAreHere": "Sei qui", + "doseStep": "{dose} {unit}", + "plannedFor": "Previsto · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo", "injectionSitePickerAriaLabel": "Selettore del sito di iniezione", "injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato", diff --git a/messages/pl.json b/messages/pl.json index e316f5b1f..9df765a80 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1276,6 +1276,13 @@ "noCurrentDose": "Nie zapisano jeszcze żadnej dawki.", "empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki." }, + "titration": { + "title": "Plan titracji", + "empty": "Nie zarejestrowano jeszcze kroków zwiększania dawki. Dodaj datowane zmiany dawki, aby nakreślić plan.", + "youAreHere": "Jesteś tutaj", + "doseStep": "{dose} {unit}", + "plannedFor": "Zaplanowano · {date}" + }, "injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała", "injectionSitePickerAriaLabel": "Wybór miejsca iniekcji", "injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce", diff --git a/src/components/medications/__tests__/titration-timeline.test.ts b/src/components/medications/__tests__/titration-timeline.test.ts new file mode 100644 index 000000000..4ffb3669c --- /dev/null +++ b/src/components/medications/__tests__/titration-timeline.test.ts @@ -0,0 +1,73 @@ +/** + * v1.18.5 — titration-timeline step-builder tests. + * + * Pins `buildTitrationSteps`: chronological sort, past/planned split at + * "now", and the single current-step tag (the latest step already in + * effect). The render is exercised indirectly through these seams; the + * pure builder carries the classification logic the component renders. + */ + +import { describe, it, expect } from "vitest"; + +import { buildTitrationSteps } from "../titration-timeline"; + +const ASOF = new Date("2026-06-18T12:00:00Z"); + +function change(id: string, iso: string, dose: number) { + return { id, effectiveFrom: iso, doseValue: dose, doseUnit: "mg" }; +} + +describe("buildTitrationSteps", () => { + it("returns an empty array for no dose changes", () => { + expect(buildTitrationSteps([], ASOF)).toEqual([]); + }); + + it("sorts by effective date and classifies past vs planned at now", () => { + const steps = buildTitrationSteps( + [ + change("c", "2026-08-01T00:00:00Z", 7.5), // future + change("a", "2026-04-01T00:00:00Z", 2.5), // past + change("b", "2026-06-01T00:00:00Z", 5), // past + ], + ASOF, + ); + expect(steps.map((s) => s.id)).toEqual(["a", "b", "c"]); + expect(steps.map((s) => s.isPast)).toEqual([true, true, false]); + }); + + it("tags only the latest in-effect step as current", () => { + const steps = buildTitrationSteps( + [ + change("a", "2026-04-01T00:00:00Z", 2.5), + change("b", "2026-06-01T00:00:00Z", 5), + change("c", "2026-08-01T00:00:00Z", 7.5), + ], + ASOF, + ); + expect(steps.map((s) => s.isCurrent)).toEqual([false, true, false]); + }); + + it("marks no current step when every step is still planned", () => { + const steps = buildTitrationSteps( + [ + change("a", "2026-07-01T00:00:00Z", 2.5), + change("b", "2026-08-01T00:00:00Z", 5), + ], + ASOF, + ); + expect(steps.some((s) => s.isCurrent)).toBe(false); + expect(steps.every((s) => !s.isPast)).toBe(true); + }); + + it("drops rows with unparseable dates or non-finite doses", () => { + const steps = buildTitrationSteps( + [ + change("a", "2026-04-01T00:00:00Z", 2.5), + change("bad", "not-a-date", 5), + { id: "nan", effectiveFrom: "2026-05-01T00:00:00Z", doseValue: NaN, doseUnit: "mg" }, + ], + ASOF, + ); + expect(steps.map((s) => s.id)).toEqual(["a"]); + }); +}); diff --git a/src/components/medications/detail/MedicationDetailTabs.tsx b/src/components/medications/detail/MedicationDetailTabs.tsx index 42fc719d9..03bf145e9 100644 --- a/src/components/medications/detail/MedicationDetailTabs.tsx +++ b/src/components/medications/detail/MedicationDetailTabs.tsx @@ -70,6 +70,7 @@ import { SettingsGroup } from "@/components/medications/settings-group"; import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet"; import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { TitrationTimeline } from "@/components/medications/titration-timeline"; import { estimateRunwayDays } from "@/components/medications/detail/supply-runway"; import { summariseSupply } from "@/lib/medications/inventory/summary"; import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; @@ -661,6 +662,11 @@ export function MedicationDetailTabs({ wave. */} {isInjectable && ( + {/* Dose-escalation plan: completed + planned titration steps + with a "you are here" marker. Renders for every injectable + route (not GLP-1-only) since titration is a property of the + injection ladder, not the treatment class. */} + {isGlp1 && } {isGlp1 && (
` header for the sheet callers. The settings inside are + * unchanged either way. */ import { type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; @@ -26,6 +36,10 @@ export interface SettingsGroupProps { children: ReactNode; dataSlot?: string; className?: string; + /** v1.18.5 — render the group as an expand/collapse section. */ + collapsible?: boolean; + /** Whether a collapsible group starts expanded (uncontrolled, default true). */ + defaultOpen?: boolean; } export function SettingsGroup({ @@ -33,16 +47,47 @@ export function SettingsGroup({ children, dataSlot, className, + collapsible = false, + defaultOpen = true, }: SettingsGroupProps) { + if (!collapsible) { + return ( +
+

+ {label} +

+
{children}
+
+ ); + } + return ( -
-

- {label} -

-
{children}
-
+ + + {label} + + + +
{children}
+
+ ); } diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx new file mode 100644 index 000000000..32dab66d3 --- /dev/null +++ b/src/components/medications/titration-timeline.tsx @@ -0,0 +1,246 @@ +"use client"; + +/** + * v1.18.5 — dose-escalation (titration) timeline for injectable meds. + * + * Companion to `DoseStrengthCurve`. Where the curve plots the logged + * dose-strength history as a step chart, this surface renders the dose + * STEPS as a vertical timeline — each `MedicationDoseChange` row as one + * step (2.5 mg → 5 mg → 7.5 mg …) with its effective date — and splits + * the list at "now" into completed steps and the planned escalation + * ahead. A "you are here" marker sits at the boundary so the current + * strength reads at a glance against what is still to come. + * + * Data contract: the same `GET /api/medications/[id]/glp1` stream the + * curve consumes (`doseChanges`, ordered `effectiveFrom asc`). A row with + * a future `effectiveFrom` is a PLANNED step; the table already accepts + * future-dated rows, so no schema change is needed to express a plan. + * + * Neutral palette only — completed steps in foreground/muted, the current + * step on the `--primary` accent, planned steps muted with a dashed + * connector. No alarming colour (med-card rule): nothing here turns + * red/green by state. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Check, MapPin } from "lucide-react"; + +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { cn } from "@/lib/utils"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; + +interface DoseChange { + id: string; + effectiveFrom: string; + doseValue: number; + doseUnit: string; + note?: string | null; +} + +interface Glp1DetailsResponse { + doseChanges: DoseChange[]; +} + +export interface TitrationTimelineProps { + medicationId: string; + /** Override "now" for deterministic snapshot tests. */ + asOf?: Date; +} + +export interface TitrationStep { + id: string; + effectiveFrom: number; + doseValue: number; + doseUnit: string; + note?: string | null; + /** Past-or-now effective date. */ + isPast: boolean; + /** The latest step whose effective date is at or before "now". */ + isCurrent: boolean; +} + +/** + * Sort the dose-change stream by effective date, classify each row as + * past / current / planned against `asOf`, and tag the single current + * step (the latest one already in effect). Exported for the unit test. + */ +export function buildTitrationSteps( + doseChanges: readonly DoseChange[], + asOf: Date, +): TitrationStep[] { + const nowMs = asOf.getTime(); + const sorted = doseChanges + .map((dc) => ({ + id: dc.id, + effectiveFrom: Date.parse(dc.effectiveFrom), + doseValue: dc.doseValue, + doseUnit: dc.doseUnit, + note: dc.note ?? null, + })) + .filter( + (p) => Number.isFinite(p.effectiveFrom) && Number.isFinite(p.doseValue), + ) + .sort((a, b) => a.effectiveFrom - b.effectiveFrom); + + // The current step is the last one whose effective date is <= now. + let currentIndex = -1; + for (let i = 0; i < sorted.length; i++) { + if (sorted[i].effectiveFrom <= nowMs) currentIndex = i; + } + + return sorted.map((p, i) => ({ + ...p, + isPast: p.effectiveFrom <= nowMs, + isCurrent: i === currentIndex, + })); +} + +export function TitrationTimeline({ medicationId, asOf }: TitrationTimelineProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data: details } = useQuery({ + queryKey: queryKeys.medicationGlp1Details(medicationId), + queryFn: async () => { + try { + return await apiGet( + `/api/medications/${medicationId}/glp1`, + ); + } catch { + return null; + } + }, + staleTime: 60 * 1000, + }); + + const now = useMemo(() => asOf ?? new Date(), [asOf]); + const steps = useMemo( + () => buildTitrationSteps(details?.doseChanges ?? [], now), + [details, now], + ); + + // Need at least two steps for an escalation to read as a plan; a lone + // step is just the current dose and the curve / status row already + // carry it. + const hasPlan = steps.length >= 2; + const currentIdx = steps.findIndex((s) => s.isCurrent); + // Marker sits after the current step (or at the very top when every + // step is still planned — nothing in effect yet). + const markerAfter = currentIdx; + + return ( + + {!hasPlan ? ( +

+ {t("medications.titration.empty")} +

+ ) : ( +
    + {steps.map((step, i) => ( +
  1. + {/* "You are here" marker between the current step and the + first planned step (or at the top when nothing is yet + in effect). */} + {markerAfter === i - 1 && i > 0 && ( + + )} + {markerAfter === -1 && i === 0 && ( + + )} +
    + {/* Connector node + dashed line for planned segments. */} +
    +
    + +
    +
    +

    + {t("medications.titration.doseStep", { + dose: fmt.number(step.doseValue), + unit: step.doseUnit, + })} +

    +

    + {step.isPast + ? fmt.dateShort(new Date(step.effectiveFrom)) + : t("medications.titration.plannedFor", { + date: fmt.dateShort(new Date(step.effectiveFrom)), + })} +

    +
    + {step.note ? ( +

    {step.note}

    + ) : null} +
    +
    +
  2. + ))} +
+ )} +
+ ); +} + +function TitrationMarker({ label }: { label: string }) { + return ( +
+
+ ); +} From cc89695cbdeb418905f89ae01bb50e7151cc1403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 14:42:29 +0200 Subject: [PATCH 2/3] feat(measurements): value-range filter on the management list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 8 ++ messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + src/app/api/measurements/route.ts | 12 +++ .../measurement-list-filter-select.test.tsx | 7 +- .../measurements/measurement-list.tsx | 59 ++++++++++- src/components/ui/filter-bar.tsx | 99 +++++++++++++++++++ src/lib/query-keys/measurements.ts | 4 + .../validations/__tests__/measurement.test.ts | 62 ++++++++++++ src/lib/validations/measurement.ts | 21 +++- 14 files changed, 286 insertions(+), 4 deletions(-) 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 7cac0883a..add0806f0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6377,6 +6377,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 1f42141f5..c80af94c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6377,6 +6377,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 abfe65977..37a9b6dad 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6377,6 +6377,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 358419bb8..ec9d5c758 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6377,6 +6377,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 773c0a809..8010d801d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6377,6 +6377,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 e316f5b1f..9dda41f2f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", 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), From 474e99e0f858ae9bedcd14caeff2b34ce5deb89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 14:43:47 +0200 Subject: [PATCH 3/3] chore(release): v1.18.5 --- CHANGELOG.md | 13 +++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e89e0740b..c54228ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.18.5] — 2026-06-18 — filter by value, a tidier advanced panel, and a titration timeline for injectables + +A small polish release. You can now filter your readings by value, the advanced medication settings collapse into sections instead of one long page, and injectable medications show their dose-escalation plan as a timeline. No breaking changes; no migration. + +### Added + +- **Filter readings by value.** Search now takes a numeric range — show only readings whose value is between two numbers (or just above / just below one), alongside the existing type, source and date filters. Useful for finding outliers or a specific episode. +- **A titration timeline for injectables.** Injectable medications now show their planned dose-escalation (e.g. 2.5 mg → 5 mg → 7.5 mg over the weeks) as a timeline with a "you are here" marker, on the injection tab. Built from the dose-change history already recorded — nothing new to enter. + +### Changed + +- **The advanced medication settings read more calmly.** The advanced and API panels group into collapsible sections (lifecycle expanded, data and danger areas collapsed by default) instead of one long wall — the settings themselves are unchanged. + ## [1.18.4] — 2026-06-18 — urgent alerts that reach you on whatever you've set up, and a stronger free notification path This release makes notifications work well for self-hosters who don't run Apple push. Genuinely urgent health signals — a sustained low-oxygen or fever pattern flagged by the condition journal — now go out at the strongest level each notification channel you've configured supports, and reach you even with no Apple Developer account: max-priority ntfy, high-urgency Web Push, a webhook, or a time-sensitive iOS alert if you do run push. The installed web app gains real reminder handling: a dose reminder clears itself once you mark it taken, and an app badge counts what's still due. No breaking changes; no migration. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6f6116af2..2490f1275 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.4 + version: 1.18.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 8e0636429..6b434e375 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.4", + "version": "1.18.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev",