mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
20 files changed
+725
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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