Merge pull request #340 from MBombeck/release/v1.18.5

v1.18.5 — value-range filter, collapsible advanced settings, titration timeline
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-18 14:52:40 +02:00
commit dd44ddeb70
20 files changed
+725 -12

No files matched your search

+13
View File
@@ -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.
+9 -1
View File
@@ -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.
@@ -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:
+10
View File
@@ -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",
@@ -6377,6 +6384,9 @@
"dateFrom": "Von",
"dateTo": "Bis",
"dateRange": "Zeitraum",
"valueRange": "Wert",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Typ",
"sourceLabel": "Quelle",
"resetFilters": "Zurücksetzen",
+10
View File
@@ -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",
@@ -6377,6 +6384,9 @@
"dateFrom": "From",
"dateTo": "To",
"dateRange": "Date range",
"valueRange": "Value",
"valueMin": "Min",
"valueMax": "Max",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Reset",
+10
View File
@@ -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",
@@ -6377,6 +6384,9 @@
"dateFrom": "Desde",
"dateTo": "Hasta",
"dateRange": "Periodo",
"valueRange": "Valor",
"valueMin": "Mín.",
"valueMax": "Máx.",
"typeLabel": "Tipo",
"sourceLabel": "Fuente",
"resetFilters": "Restablecer",
+10
View File
@@ -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é",
@@ -6377,6 +6384,9 @@
"dateFrom": "Du",
"dateTo": "Au",
"dateRange": "Période",
"valueRange": "Valeur",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Type",
"sourceLabel": "Source",
"resetFilters": "Réinitialiser",
+10
View File
@@ -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",
@@ -6377,6 +6384,9 @@
"dateFrom": "Dal",
"dateTo": "Al",
"dateRange": "Periodo",
"valueRange": "Valore",
"valueMin": "Min.",
"valueMax": "Max.",
"typeLabel": "Tipo",
"sourceLabel": "Fonte",
"resetFilters": "Reimposta",
+10
View File
@@ -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",
@@ -6377,6 +6384,9 @@
"dateFrom": "Od",
"dateTo": "Do",
"dateRange": "Zakres dat",
"valueRange": "Wartość",
"valueMin": "Min.",
"valueMax": "Maks.",
"typeLabel": "Typ",
"sourceLabel": "Źródło",
"resetFilters": "Resetuj",
+1 -1
View File
@@ -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",
+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>
)}
@@ -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"]);
});
});
@@ -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 && (
<TabsContent value="injektion" className="space-y-6 pt-2">
{/* 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. */}
<TitrationTimeline medicationId={id} />
{isGlp1 && <SideEffectsSection medicationId={id} />}
{isGlp1 && (
<div
@@ -689,6 +695,7 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="api-group-external-api"
collapsible
>
<div className="py-3">
<ApiTokensRow
@@ -712,6 +719,7 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
collapsible
>
<div className="py-3">
<LifecycleManageBody
@@ -763,6 +771,8 @@ export function MedicationDetailTabs({
<SettingsGroup
label={t("medications.detail.erweitert.group.data")}
dataSlot="erweitert-group-data"
collapsible
defaultOpen={false}
>
<div className="py-3">
<DataPortabilityRow
@@ -899,6 +909,8 @@ function ComplianceGatedDangerZone({
<SettingsGroup
label={t("medications.detail.erweitert.group.danger")}
dataSlot="erweitert-group-danger"
collapsible
defaultOpen={false}
>
<DangerZoneBody
medicationId={medicationId}
+51 -6
View File
@@ -15,9 +15,19 @@
* title, and the inner switch labels carry their own ids. This avoids
* the duplicate-id axe failure the section-card heading split guards
* against.
*
* v1.18.5 — opt-in collapsible mode. When `collapsible` is set the
* uppercase micro-label becomes a Radix Collapsible trigger (the same
* primitive `SheetSection` uses) with a rotating chevron, so a long
* advanced-settings surface reads as scannable expand/collapse groups
* instead of one wall of stacked rows. The non-collapsible default keeps
* the static `<p>` 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 (
<section
className={cn("bg-card rounded-lg border px-4 py-1", className)}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
{label}
</p>
<div className="divide-border/60 divide-y">{children}</div>
</section>
);
}
return (
<section
<CollapsiblePrimitive.Root
defaultOpen={defaultOpen}
className={cn("bg-card rounded-lg border px-4 py-1", className)}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
{label}
</p>
<div className="divide-border/60 divide-y">{children}</div>
</section>
<CollapsiblePrimitive.Trigger
data-slot="settings-group-trigger"
className="group focus-visible:ring-ring/50 border-border/60 flex w-full items-center gap-2 border-b py-2.5 text-left focus-visible:ring-2 focus-visible:outline-none"
>
<span className="text-muted-foreground flex-1 text-xs font-medium tracking-wide uppercase">
{label}
</span>
<ChevronDown
aria-hidden="true"
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content
data-slot="settings-group-content"
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
>
<div className="divide-border/60 divide-y">{children}</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
}
@@ -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<Glp1DetailsResponse | null>({
queryKey: queryKeys.medicationGlp1Details(medicationId),
queryFn: async () => {
try {
return await apiGet<Glp1DetailsResponse>(
`/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 (
<MedicationDetailSection
titleId="titration-timeline-title"
title={t("medications.titration.title")}
dataSlot="titration-timeline"
>
{!hasPlan ? (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-4 text-sm"
data-slot="titration-timeline-empty"
>
{t("medications.titration.empty")}
</p>
) : (
<ol className="relative space-y-0" data-slot="titration-timeline-list">
{steps.map((step, i) => (
<li key={step.id} data-slot="titration-step">
{/* "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 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
)}
{markerAfter === -1 && i === 0 && (
<TitrationMarker label={t("medications.titration.youAreHere")} />
)}
<div
className="flex items-start gap-3 py-2.5"
data-slot={
step.isCurrent
? "titration-step-current"
: step.isPast
? "titration-step-past"
: "titration-step-planned"
}
>
{/* Connector node + dashed line for planned segments. */}
<div className="flex flex-col items-center self-stretch pt-1">
<span
className={cn(
"grid h-5 w-5 shrink-0 place-items-center rounded-full border",
step.isCurrent
? "border-primary bg-primary/15 text-primary"
: step.isPast
? "border-border bg-muted text-muted-foreground"
: "border-border/60 border-dashed text-muted-foreground/70",
)}
aria-hidden="true"
>
{step.isPast ? (
<Check className="h-3 w-3" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
{i < steps.length - 1 && (
<span
className={cn(
"mt-1 w-px flex-1",
steps[i + 1].isPast
? "bg-border"
: "border-border/60 border-l border-dashed",
)}
aria-hidden="true"
/>
)}
</div>
<div className="min-w-0 flex-1 space-y-0.5 pb-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"text-sm font-medium tabular-nums",
step.isCurrent ? "text-primary" : "text-foreground",
)}
>
{t("medications.titration.doseStep", {
dose: fmt.number(step.doseValue),
unit: step.doseUnit,
})}
</p>
<p className="text-muted-foreground shrink-0 text-xs">
{step.isPast
? fmt.dateShort(new Date(step.effectiveFrom))
: t("medications.titration.plannedFor", {
date: fmt.dateShort(new Date(step.effectiveFrom)),
})}
</p>
</div>
{step.note ? (
<p className="text-muted-foreground text-xs">{step.note}</p>
) : null}
</div>
</div>
</li>
))}
</ol>
)}
</MedicationDetailSection>
);
}
function TitrationMarker({ label }: { label: string }) {
return (
<div
className="text-primary flex items-center gap-1.5 py-1.5 text-xs font-medium"
data-slot="titration-you-are-here"
>
<MapPin className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{label}
</div>
);
}
+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),