mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Keep the measurements and mood filters in the URL
The documents view already round-trips its filters through the query string; the measurements and mood lists kept theirs in memory, so any navigation dropped them. The documents pattern is extracted into a shared hook (use-url-filter-sync) and both lists adopt it: type/source/day-bounds and the measurement value range survive reload, back and sharing, defaults stay out of the URL, invalid facets drop leniently. The insights readings subpage keeps its locked type in memory — that URL belongs to the insights route. /measurements no longer strips ?type=, so the deep links the Vorsorge cards mint now persist as filter state.
This commit is contained in:
1 parent
5619783648
commit
689cacc039
20 files changed
+1048
-91
No files matched your search
@@ -12,7 +12,6 @@ import {
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
@@ -64,18 +63,6 @@ export default function MeasurementsPage() {
|
||||
const addRequestParam = addParam
|
||||
? `${addParam}\u0000${returnToParam ?? ""}`
|
||||
: null;
|
||||
// v1.18.7 (Wave E) — a `?type=<MEASUREMENT_TYPE>` deep link (e.g. the
|
||||
// Vorsorge card's "Show measurements") seeds the list's type filter on
|
||||
// first paint. Captured once via a lazy initializer so the value is
|
||||
// stable even after the effect below strips the query string; an unknown
|
||||
// token is dropped (validated against the canonical type-label map), so a
|
||||
// stale link never traps the user on a broken filter.
|
||||
const typeParam = searchParams.get("type");
|
||||
const [initialType] = useState<string | undefined>(() =>
|
||||
typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS
|
||||
? typeParam
|
||||
: undefined,
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
|
||||
const [defaultType, setDefaultType] = useState<string | undefined>(
|
||||
() => initialAdd ?? undefined,
|
||||
@@ -118,15 +105,21 @@ export default function MeasurementsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the query string so the back-button leaves the user on
|
||||
// `/measurements` rather than re-opening the dialog (or re-seeding the
|
||||
// type filter). The type filter is already seeded into the list's own
|
||||
// state via `initialType`, so stripping the param doesn't lose it.
|
||||
// Drop the one-shot `?add=` / `?returnTo=` params so the back-button
|
||||
// leaves the user on `/measurements` rather than re-opening the dialog.
|
||||
// Every OTHER param is preserved: the list's filter state (`?type=`,
|
||||
// `?source=`, …) now lives in the URL (see `measurement-list-filters.ts`)
|
||||
// and stripping it would lose the filters this page is meant to keep.
|
||||
// Effect-driven (client-only) because `router.replace` is not callable
|
||||
// during SSR.
|
||||
useEffect(() => {
|
||||
if (addParam || typeParam || returnToParam) router.replace("/measurements");
|
||||
}, [addParam, typeParam, returnToParam, router]);
|
||||
if (!addParam && !returnToParam) return;
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
next.delete("add");
|
||||
next.delete("returnTo");
|
||||
const search = next.toString();
|
||||
router.replace(search ? `/measurements?${search}` : "/measurements");
|
||||
}, [addParam, returnToParam, searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
@@ -214,7 +207,6 @@ export default function MeasurementsPage() {
|
||||
setReturnTo(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
initialType={initialType}
|
||||
/>
|
||||
|
||||
<CustomMetricList />
|
||||
|
||||
@@ -40,6 +40,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
useCoachLaunch,
|
||||
} from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
type DocumentBulkAction,
|
||||
@@ -263,23 +263,13 @@ export function DocumentsView() {
|
||||
}, [authLoading, isAuthenticated, moduleEnabled, router]);
|
||||
|
||||
// ── URL-owned filter state ────────────────────────────────────────────
|
||||
const filters = useMemo(
|
||||
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DocumentVaultFilters, mode: "push" | "replace") => {
|
||||
const search = vaultFiltersToSearch(next);
|
||||
const href = search ? `${pathname}?${search}` : pathname;
|
||||
if (mode === "push") {
|
||||
router.push(href, { scroll: false });
|
||||
} else {
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
},
|
||||
[pathname, router],
|
||||
);
|
||||
// The parse/serialise round trip stays in `vault-utils.ts`; the router
|
||||
// plumbing lives in the shared hook (which the measurements and mood
|
||||
// lists reuse — this surface is the pattern's origin).
|
||||
const { filters, applyFilters } = useUrlFilterSync({
|
||||
parse: parseVaultSearchParams,
|
||||
serialise: vaultFiltersToSearch,
|
||||
});
|
||||
|
||||
// Search draft debounces into the URL (200 ms); an external URL change
|
||||
// (back button, deep link) re-seeds the draft — render-phase derived-state
|
||||
|
||||
@@ -27,6 +27,7 @@ const baseMeasurements = [
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ const baseMeasurements = [
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -35,6 +35,7 @@ const baseMeasurements = [
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* URL round trip for the measurements list filters (`?type&source&from&to&
|
||||
* min&max`) — the vault-utils contract applied to this surface: parsing is
|
||||
* lenient (a hand-edited URL drops the invalid facet, never breaks the
|
||||
* page), serialisation omits defaults, and parse(serialise(x)) is identity
|
||||
* so deep links stay stable.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
measurementListFiltersToSearch,
|
||||
numericInputOrUndefined,
|
||||
parseMeasurementListSearchParams,
|
||||
type MeasurementListFilters,
|
||||
} from "../measurement-list-filters";
|
||||
|
||||
describe("parseMeasurementListSearchParams", () => {
|
||||
it.each<[string, MeasurementListFilters]>([
|
||||
["", {}],
|
||||
["type=WEIGHT", { type: "WEIGHT" }],
|
||||
["type=WEIGHT&source=WITHINGS", { type: "WEIGHT", source: "WITHINGS" }],
|
||||
[
|
||||
"from=2026-05-01&to=2026-05-31",
|
||||
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
|
||||
],
|
||||
["min=60&max=90.5", { valueMin: "60", valueMax: "90.5" }],
|
||||
[
|
||||
"type=PULSE&source=APPLE_HEALTH&from=2026-01-01&min=-5",
|
||||
{
|
||||
type: "PULSE",
|
||||
source: "APPLE_HEALTH",
|
||||
fromDay: "2026-01-01",
|
||||
valueMin: "-5",
|
||||
},
|
||||
],
|
||||
])("parses %j", (search, expected) => {
|
||||
expect(
|
||||
parseMeasurementListSearchParams(new URLSearchParams(search)),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it("drops facets a stale or hand-edited URL cannot honour", () => {
|
||||
expect(
|
||||
parseMeasurementListSearchParams(
|
||||
new URLSearchParams(
|
||||
"type=NOT_A_TYPE&source=NOT_A_SOURCE&from=2026-1-1&to=yesterday&min=abc&max=1e999",
|
||||
),
|
||||
),
|
||||
).toEqual({});
|
||||
// 1e999 is Infinity — not finite, so the bound is dropped too.
|
||||
expect(numericInputOrUndefined("1e999")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("measurementListFiltersToSearch", () => {
|
||||
it("serialises the empty filter set to an empty string (bare URL)", () => {
|
||||
expect(measurementListFiltersToSearch({})).toBe("");
|
||||
});
|
||||
|
||||
it.each<MeasurementListFilters>([
|
||||
{},
|
||||
{ type: "WEIGHT" },
|
||||
{ type: "BLOOD_PRESSURE_SYS", source: "MANUAL" },
|
||||
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
|
||||
{ valueMin: "60", valueMax: "90.5" },
|
||||
{
|
||||
type: "PULSE",
|
||||
source: "APPLE_HEALTH",
|
||||
fromDay: "2026-01-01",
|
||||
toDay: "2026-12-31",
|
||||
valueMin: "40",
|
||||
valueMax: "180",
|
||||
},
|
||||
])("round-trips %j", (filters) => {
|
||||
expect(
|
||||
parseMeasurementListSearchParams(
|
||||
new URLSearchParams(measurementListFiltersToSearch(filters)),
|
||||
),
|
||||
).toEqual(filters);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/insights/values/WEIGHT",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* URL-owned filter state on the measurements management list (the
|
||||
* documents-vault pattern via `useUrlFilterSync`). Two directions, both
|
||||
* pinned:
|
||||
*
|
||||
* - a filter change writes the serialised facet set to the page URL
|
||||
* (push for the discrete selects, replace for the committed date
|
||||
* bounds, bare pathname when everything clears);
|
||||
* - mounting with a query string restores every facet — into the filter
|
||||
* rail's controls AND into the list's TanStack query key, so the
|
||||
* restored view refetches exactly what the shared link showed.
|
||||
*
|
||||
* SSR-only per project convention: the rail primitives are stubbed with
|
||||
* prop-capturing markers, and the captured callbacks stand in for the
|
||||
* user's click (post-render setState is a no-op under the server renderer;
|
||||
* the router spies record the navigation the click causes).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const baseMeasurements = [
|
||||
{
|
||||
id: "m-1",
|
||||
type: "WEIGHT",
|
||||
value: 81.5,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: "2026-05-09T08:30:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
];
|
||||
|
||||
let mockSearch = "";
|
||||
const pushMock = vi.fn();
|
||||
const replaceMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock, replace: replaceMock }),
|
||||
usePathname: () => "/measurements",
|
||||
useSearchParams: () => new URLSearchParams(mockSearch),
|
||||
}));
|
||||
|
||||
const recordedQueryKeys: unknown[] = [];
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: { queryKey: unknown }) => {
|
||||
recordedQueryKeys.push(options.queryKey);
|
||||
return {
|
||||
data: { measurements: baseMeasurements, meta: { total: 1 } },
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "testuser", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Prop-capturing stubs for the rail primitives (render order in the list:
|
||||
// date range, type, source, value range).
|
||||
interface SelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
interface RangeProps {
|
||||
from?: string;
|
||||
to?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
onFromChange?: (value: string) => void;
|
||||
onToChange?: (value: string) => void;
|
||||
onMinChange?: (value: string) => void;
|
||||
onMaxChange?: (value: string) => void;
|
||||
}
|
||||
let selects: SelectProps[] = [];
|
||||
let barProps: { isFiltered: boolean; onReset: () => void } | null = null;
|
||||
let dateRange: RangeProps | null = null;
|
||||
let valueRange: RangeProps | null = null;
|
||||
vi.mock("@/components/ui/filter-bar", () => ({
|
||||
FilterBar: (props: {
|
||||
isFiltered: boolean;
|
||||
onReset: () => void;
|
||||
children?: unknown;
|
||||
}) => {
|
||||
barProps = { isFiltered: props.isFiltered, onReset: props.onReset };
|
||||
return <div data-slot="filter-bar">{props.children as never}</div>;
|
||||
},
|
||||
FilterBarSelect: (props: SelectProps) => {
|
||||
selects.push(props);
|
||||
return <span data-slot="filter-bar-pill" />;
|
||||
},
|
||||
FilterBarDateRange: (props: RangeProps) => {
|
||||
dateRange = props;
|
||||
return <span data-slot="filter-bar-pill" />;
|
||||
},
|
||||
FilterBarNumberRange: (props: RangeProps) => {
|
||||
valueRange = props;
|
||||
return <span data-slot="filter-bar-pill" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MeasurementList } from "../measurement-list";
|
||||
|
||||
function render(search: string) {
|
||||
mockSearch = search;
|
||||
selects = [];
|
||||
barProps = null;
|
||||
dateRange = null;
|
||||
valueRange = null;
|
||||
recordedQueryKeys.length = 0;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MeasurementList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const typeSelect = () => selects[0]!;
|
||||
const sourceSelect = () => selects[1]!;
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
replaceMock.mockClear();
|
||||
});
|
||||
|
||||
describe("MeasurementList — filter changes write the URL", () => {
|
||||
it("pushes the type facet", () => {
|
||||
render("");
|
||||
typeSelect().onValueChange("WEIGHT");
|
||||
expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes the source facet on top of the active type", () => {
|
||||
render("type=WEIGHT");
|
||||
sourceSelect().onValueChange("WITHINGS");
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/measurements?type=WEIGHT&source=WITHINGS",
|
||||
{ scroll: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces (no history entry) for a committed date bound", () => {
|
||||
render("");
|
||||
dateRange!.onFromChange!("2026-05-01");
|
||||
expect(replaceMock).toHaveBeenCalledWith("/measurements?from=2026-05-01", {
|
||||
scroll: false,
|
||||
});
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearing the last facet restores the bare pathname", () => {
|
||||
render("type=WEIGHT");
|
||||
typeSelect().onValueChange("ALL");
|
||||
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
|
||||
});
|
||||
|
||||
it("reset clears every facet in one push", () => {
|
||||
render("type=WEIGHT&source=WITHINGS&from=2026-05-01&min=60");
|
||||
expect(barProps!.isFiltered).toBe(true);
|
||||
barProps!.onReset();
|
||||
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MeasurementList — mounting with a query string restores the filters", () => {
|
||||
it("seeds every rail control from the URL", () => {
|
||||
render(
|
||||
"type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90",
|
||||
);
|
||||
expect(typeSelect().value).toBe("WEIGHT");
|
||||
expect(sourceSelect().value).toBe("WITHINGS");
|
||||
expect(dateRange!.from).toBe("2026-05-01");
|
||||
expect(dateRange!.to).toBe("2026-05-31");
|
||||
expect(valueRange!.min).toBe("60");
|
||||
expect(valueRange!.max).toBe("90");
|
||||
expect(barProps!.isFiltered).toBe(true);
|
||||
});
|
||||
|
||||
it("keys the list query off the restored facets (TZ-pinned day bounds)", () => {
|
||||
render(
|
||||
"type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90",
|
||||
);
|
||||
// Suite TZ is pinned to UTC, so the local day bounds are deterministic.
|
||||
expect(recordedQueryKeys).toContainEqual(
|
||||
queryKeys.measurementsList({
|
||||
type: "WEIGHT",
|
||||
sourceEq: "WITHINGS",
|
||||
from: "2026-05-01T00:00:00.000Z",
|
||||
to: "2026-05-31T23:59:59.999Z",
|
||||
valueMin: 60,
|
||||
valueMax: 90,
|
||||
page: 1,
|
||||
sortBy: "measuredAt",
|
||||
sortDir: "desc",
|
||||
mode: "raw",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an invalid facet instead of breaking the page", () => {
|
||||
render("type=NOT_A_TYPE&source=WITHINGS");
|
||||
expect(typeSelect().value).toBe("ALL");
|
||||
expect(sourceSelect().value).toBe("WITHINGS");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Pure URL-facet helpers for the measurements management list, following the
|
||||
* documents-vault pattern (`vault-utils.ts`): the list's filter state lives in
|
||||
* the page URL (`?type&source&from&to&min&max`) so a filtered view is
|
||||
* deep-linkable and survives navigation, reload and back/forward. Parsing is
|
||||
* lenient — a hand-edited or stale URL drops the invalid facet rather than
|
||||
* breaking the page — and serialisation omits defaults so the unfiltered list
|
||||
* keeps a bare URL. Kept out of the client component so the round trip stays
|
||||
* trivially unit-testable without a render harness.
|
||||
*/
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurement-list-meta";
|
||||
|
||||
export interface MeasurementListFilters {
|
||||
/** Canonical `MeasurementType`; absent = all types. */
|
||||
type?: string;
|
||||
/** `MeasurementSource` enum value; absent = all sources. */
|
||||
source?: string;
|
||||
/** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */
|
||||
fromDay?: string;
|
||||
toDay?: string;
|
||||
/** Validated numeric input strings for the value-range pill. */
|
||||
valueMin?: string;
|
||||
valueMax?: string;
|
||||
}
|
||||
|
||||
const SOURCE_SET = new Set<string>(measurementSourceEnum.options);
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function dayOrUndefined(value: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && DAY_PATTERN.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value-range bound survives the URL only as a finite number the list's
|
||||
* `valueMin`/`valueMax` query params would accept; anything else (including
|
||||
* a mid-typing fragment someone pasted) is dropped, not 422'd.
|
||||
*/
|
||||
export function numericInputOrUndefined(
|
||||
value: string | null | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed.length > 20) return undefined;
|
||||
return Number.isFinite(Number(trimmed)) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the list's URL search params (`?type&source&from&to&min&max`) into
|
||||
* the filter object. Unknown types/sources are dropped (a stale deep link
|
||||
* never traps the user on a broken filter — same contract the old
|
||||
* `?type=` seeding had), malformed days and non-numeric bounds likewise.
|
||||
*/
|
||||
export function parseMeasurementListSearchParams(
|
||||
params: URLSearchParams,
|
||||
): MeasurementListFilters {
|
||||
const filters: MeasurementListFilters = {};
|
||||
|
||||
const type = params.get("type")?.trim();
|
||||
if (type && type in MEASUREMENT_TYPE_LABEL_KEYS) filters.type = type;
|
||||
|
||||
const source = params.get("source")?.trim();
|
||||
if (source && SOURCE_SET.has(source)) filters.source = source;
|
||||
|
||||
const fromDay = dayOrUndefined(params.get("from"));
|
||||
if (fromDay) filters.fromDay = fromDay;
|
||||
|
||||
const toDay = dayOrUndefined(params.get("to"));
|
||||
if (toDay) filters.toDay = toDay;
|
||||
|
||||
const valueMin = numericInputOrUndefined(params.get("min"));
|
||||
if (valueMin) filters.valueMin = valueMin;
|
||||
|
||||
const valueMax = numericInputOrUndefined(params.get("max"));
|
||||
if (valueMax) filters.valueMax = valueMax;
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the filter object back into the page URL's search string (no
|
||||
* leading `?`; empty string for the default view). Inverse of
|
||||
* `parseMeasurementListSearchParams` — round-tripping is pinned by test so
|
||||
* the `?type=` deep links from the Vorsorge card and the type-badge
|
||||
* drill-down stay stable.
|
||||
*/
|
||||
export function measurementListFiltersToSearch(
|
||||
filters: MeasurementListFilters,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.type) sp.set("type", filters.type);
|
||||
if (filters.source) sp.set("source", filters.source);
|
||||
if (filters.fromDay) sp.set("from", filters.fromDay);
|
||||
if (filters.toDay) sp.set("to", filters.toDay);
|
||||
if (filters.valueMin) sp.set("min", filters.valueMin);
|
||||
if (filters.valueMax) sp.set("max", filters.valueMax);
|
||||
return sp.toString();
|
||||
}
|
||||
@@ -60,8 +60,9 @@ import {
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useId, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useId, useState } from "react";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
@@ -97,6 +98,11 @@ import {
|
||||
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
|
||||
} from "./measurement-list-meta";
|
||||
import {
|
||||
measurementListFiltersToSearch,
|
||||
numericInputOrUndefined,
|
||||
parseMeasurementListSearchParams,
|
||||
} from "./measurement-list-filters";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
@@ -190,13 +196,6 @@ interface MeasurementListProps {
|
||||
* scoped to this type; the count caption still shows.
|
||||
*/
|
||||
lockedType?: string;
|
||||
/**
|
||||
* v1.18.7 (Wave E) — seed the (still user-editable) type filter from a
|
||||
* `?type=<MEASUREMENT_TYPE>` deep link, e.g. the Vorsorge card's "Show
|
||||
* measurements" action. Unlike `lockedType` the full filter rail stays
|
||||
* visible so the user can widen or change the filter afterwards.
|
||||
*/
|
||||
initialType?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -281,7 +280,6 @@ export function MeasurementList({
|
||||
onEdit,
|
||||
onAddFirst,
|
||||
lockedType,
|
||||
initialType,
|
||||
}: MeasurementListProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -342,22 +340,45 @@ export function MeasurementList({
|
||||
: `/measurements?type=${encodeURIComponent(type)}`,
|
||||
);
|
||||
};
|
||||
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
|
||||
// and the type selector is hidden; the filter state seeds from it.
|
||||
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
|
||||
// same filter but keeps the rail interactive so the user can change it.
|
||||
const [typeFilter, setTypeFilterRaw] = useState<string>(
|
||||
lockedType ?? initialType ?? "ALL",
|
||||
);
|
||||
// URL-owned filter state (the documents-vault pattern): type, source, date
|
||||
// range and value range parse from `?type&source&from&to&min&max` and every
|
||||
// change writes back through the router, so a filtered view survives
|
||||
// navigation, reload and back/forward. The `?type=` deep links (Vorsorge
|
||||
// card, type-badge drill-down) now simply ARE the filter state.
|
||||
// v1.8.5 — when `lockedType` is set the list is pinned to that metric, the
|
||||
// type selector is hidden, and the URL belongs to the insights subpage —
|
||||
// the remaining filters stay in component memory (`syncToUrl: false`).
|
||||
const { filters, applyFilters } = useUrlFilterSync({
|
||||
parse: parseMeasurementListSearchParams,
|
||||
serialise: measurementListFiltersToSearch,
|
||||
syncToUrl: lockedType === undefined,
|
||||
});
|
||||
const typeFilter = lockedType ?? filters.type ?? "ALL";
|
||||
// v1.15.13 — management-list source filter + optional date range.
|
||||
// `ALL` clears the source filter; empty date strings clear the bound.
|
||||
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 sourceFilter = filters.source ?? "ALL";
|
||||
const fromDay = filters.fromDay ?? "";
|
||||
const toDay = filters.toDay ?? "";
|
||||
// v1.18.5 — value-range filter (backlog G). The inputs fire per keystroke,
|
||||
// so they edit a local draft that debounces into the URL (200 ms, replace —
|
||||
// never a history entry per keystroke); an external URL change (back
|
||||
// button, deep link) re-seeds the draft via the render-phase derived-state
|
||||
// adjustment below, exactly like the vault's search field.
|
||||
const urlValueRange = `${filters.valueMin ?? ""} | ||||