diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 3f753ed8e..f03556902 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -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=` 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(() => - typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS - ? typeParam - : undefined, - ); const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null); const [defaultType, setDefaultType] = useState( () => 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} /> diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 7eba90039..04c877b23 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -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 diff --git a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx index 1ab32caa9..7a453203f 100644 --- a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx index c24a1c746..242b4d84a 100644 --- a/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-bp-badge.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-empty.test.tsx b/src/components/measurements/__tests__/measurement-list-empty.test.tsx index 5ee340e74..5a96a8d20 100644 --- a/src/components/measurements/__tests__/measurement-list-empty.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-empty.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx index b7615f19d..f7ec021bf 100644 --- a/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-filter-select.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-filters.test.ts b/src/components/measurements/__tests__/measurement-list-filters.test.ts new file mode 100644 index 000000000..c68fc5bc4 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-filters.test.ts @@ -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([ + {}, + { 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); + }); +}); diff --git a/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx index d325646ed..098ca83f9 100644 --- a/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-locked-type.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx index 3eb426017..13ef32e92 100644 --- a/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-step-grouping.test.tsx @@ -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", () => ({ diff --git a/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx b/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx new file mode 100644 index 000000000..6f7545643 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-url-sync.test.tsx @@ -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
{props.children as never}
; + }, + FilterBarSelect: (props: SelectProps) => { + selects.push(props); + return ; + }, + FilterBarDateRange: (props: RangeProps) => { + dateRange = props; + return ; + }, + FilterBarNumberRange: (props: RangeProps) => { + valueRange = props; + return ; + }, +})); + +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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/measurements/measurement-list-filters.ts b/src/components/measurements/measurement-list-filters.ts new file mode 100644 index 000000000..d8e546779 --- /dev/null +++ b/src/components/measurements/measurement-list-filters.ts @@ -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(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(); +} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 8b7b25a69..455dd9e69 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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=` 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( - 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("ALL"); - const [fromDay, setFromDayRaw] = useState(""); - const [toDay, setToDayRaw] = useState(""); - // v1.18.5 — value-range filter (backlog G). Raw input strings; empty - // means the bound is unset (open-ended on that side). - const [valueMinInput, setValueMinInputRaw] = useState(""); - const [valueMaxInput, setValueMaxInputRaw] = useState(""); + const 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 ?? ""}${filters.valueMax ?? ""}`; + const [valueDraft, setValueDraft] = useState(() => ({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + })); + const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange); + if (urlValueRange !== lastUrlValueRange) { + setLastUrlValueRange(urlValueRange); + setValueDraft({ + min: filters.valueMin ?? "", + max: filters.valueMax ?? "", + }); + } + const valueMinInput = valueDraft.min; + const valueMaxInput = valueDraft.max; const [page, setPage] = useState(1); // Shared column-sort state (measuredAt opens descending). const { @@ -417,42 +438,63 @@ export function MeasurementList({ // are about to unmount). const clearSelection = () => setSelectedIds(new Set()); + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace — a from/to pair set in + // one sitting should not cost two Back presses. const setTypeFilter = (value: string) => { - setTypeFilterRaw(value); + applyFilters({ ...filters, type: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setSourceFilter = (value: string) => { - setSourceFilterRaw(value); + applyFilters({ ...filters, source: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setFromDay = (value: string) => { - setFromDayRaw(value); + applyFilters({ ...filters, fromDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setToDay = (value: string) => { - setToDayRaw(value); + applyFilters({ ...filters, toDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setValueMinInput = (value: string) => { - setValueMinInputRaw(value); + setValueDraft((prev) => ({ ...prev, min: value })); setPage(1); clearSelection(); }; const setValueMaxInput = (value: string) => { - setValueMaxInputRaw(value); + setValueDraft((prev) => ({ ...prev, max: value })); setPage(1); clearSelection(); }; + // The value-range draft debounces into the URL; a bound that does not + // normalise to a finite number never reaches it (matching the previous + // in-memory behaviour, where non-numeric input meant "unset"). + useEffect(() => { + const handle = setTimeout(() => { + const min = numericInputOrUndefined(valueDraft.min); + const max = numericInputOrUndefined(valueDraft.max); + if ( + (min ?? "") === (filters.valueMin ?? "") && + (max ?? "") === (filters.valueMax ?? "") + ) { + return; + } + applyFilters({ ...filters, valueMin: min, valueMax: max }, "replace"); + }, 200); + return () => clearTimeout(handle); + }, [valueDraft, filters, applyFilters]); + const goToPage = (updater: (p: number) => number) => { setPage(updater); clearSelection(); @@ -494,13 +536,9 @@ export function MeasurementList({ // 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; + filters.valueMin !== undefined ? Number(filters.valueMin) : undefined; const valueMax = - valueMaxInput.trim() !== "" && Number.isFinite(Number(valueMaxInput)) - ? Number(valueMaxInput) - : undefined; + filters.valueMax !== undefined ? Number(filters.valueMax) : undefined; const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter ? "sleep-night" : isCumulativeFilter @@ -874,12 +912,14 @@ export function MeasurementList({ (valueMinInput !== "" || valueMaxInput !== "")) } onReset={() => { - setTypeFilter("ALL"); - setSourceFilter("ALL"); - setFromDay(""); - setToDay(""); - setValueMinInput(""); - setValueMaxInput(""); + // One push clears every facet (and so one Back restores them); + // the value-range draft is cleared directly because a bound + // that never committed to the URL has no change to re-seed + // from. + applyFilters({}); + setValueDraft({ min: "", max: "" }); + setPage(1); + clearSelection(); }} count={ data?.meta?.total !== undefined diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx index 745fbf998..51ea0a8f0 100644 --- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -35,6 +35,7 @@ const baseEntries = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/mood/__tests__/mood-list-filters.test.ts b/src/components/mood/__tests__/mood-list-filters.test.ts new file mode 100644 index 000000000..e86d54ad8 --- /dev/null +++ b/src/components/mood/__tests__/mood-list-filters.test.ts @@ -0,0 +1,68 @@ +/** + * URL round trip for the mood list filters (`?mood&source&from&to`) — 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 { + moodListFiltersToSearch, + parseMoodListSearchParams, + type MoodListFilters, +} from "../mood-list-filters"; + +describe("parseMoodListSearchParams", () => { + it.each<[string, MoodListFilters]>([ + ["", {}], + ["mood=GUT", { mood: "GUT" }], + ["mood=LAUSIG&source=TELEGRAM", { mood: "LAUSIG", source: "TELEGRAM" }], + [ + "from=2026-05-01&to=2026-05-31", + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + ], + // MOODLOG is legacy provenance — rows keep their label, so the filter + // must keep resolving it. + ["source=MOODLOG", { source: "MOODLOG" }], + ])("parses %j", (search, expected) => { + expect(parseMoodListSearchParams(new URLSearchParams(search))).toEqual( + expected, + ); + }); + + it("drops facets a stale or hand-edited URL cannot honour", () => { + expect( + parseMoodListSearchParams( + new URLSearchParams( + "mood=ECSTATIC&source=NOT_A_SOURCE&from=05-01-2026&to=yesterday", + ), + ), + ).toEqual({}); + }); +}); + +describe("moodListFiltersToSearch", () => { + it("serialises the empty filter set to an empty string (bare URL)", () => { + expect(moodListFiltersToSearch({})).toBe(""); + }); + + it.each([ + {}, + { mood: "SUPER_GUT" }, + { mood: "OKAY", source: "WEB" }, + { fromDay: "2026-05-01", toDay: "2026-05-31" }, + { + mood: "SCHLECHT", + source: "DAYLIO", + fromDay: "2026-01-01", + toDay: "2026-12-31", + }, + ])("round-trips %j", (filters) => { + expect( + parseMoodListSearchParams( + new URLSearchParams(moodListFiltersToSearch(filters)), + ), + ).toEqual(filters); + }); +}); diff --git a/src/components/mood/__tests__/mood-list-url-sync.test.tsx b/src/components/mood/__tests__/mood-list-url-sync.test.tsx new file mode 100644 index 000000000..5717de85c --- /dev/null +++ b/src/components/mood/__tests__/mood-list-url-sync.test.tsx @@ -0,0 +1,194 @@ +/** + * URL-owned filter state on the mood management list (the documents-vault + * pattern via `useUrlFilterSync`), mirroring the measurements-list guard: + * a filter change writes the serialised facet set to the page URL, and + * mounting with a query string restores every facet into the rail's + * controls and the list's TanStack query key. + * + * 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 baseEntries = [ + { + id: "e-1", + date: "2026-05-09", + mood: "GUT", + score: 4, + tags: [], + tagKeys: [], + note: null, + source: "MANUAL", + moodLoggedAt: "2026-05-09T20:00:00.000Z", + }, +]; + +let mockSearch = ""; +const pushMock = vi.fn(); +const replaceMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), + usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +const recordedQueryKeys: unknown[] = []; +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: unknown }) => { + recordedQueryKeys.push(options.queryKey); + return { + data: { entries: baseEntries, 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, mood, source). +interface SelectProps { + label: string; + value: string; + onValueChange: (value: string) => void; +} +interface RangeProps { + from: string; + to: string; + onFromChange: (value: string) => void; + onToChange: (value: string) => void; +} +let selects: SelectProps[] = []; +let barProps: { isFiltered: boolean; onReset: () => void } | null = null; +let dateRange: 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
{props.children as never}
; + }, + FilterBarSelect: (props: SelectProps) => { + selects.push(props); + return ; + }, + FilterBarDateRange: (props: RangeProps) => { + dateRange = props; + return ; + }, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MoodList } from "../mood-list"; + +function render(search: string) { + mockSearch = search; + selects = []; + barProps = null; + dateRange = null; + recordedQueryKeys.length = 0; + return renderToStaticMarkup( + + + , + ); +} + +const moodSelect = () => selects[0]!; +const sourceSelect = () => selects[1]!; + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); +}); + +describe("MoodList — filter changes write the URL", () => { + it("pushes the mood facet", () => { + render(""); + moodSelect().onValueChange("GUT"); + expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT", { scroll: false }); + }); + + it("pushes the source facet on top of the active mood", () => { + render("mood=GUT"); + sourceSelect().onValueChange("TELEGRAM"); + expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT&source=TELEGRAM", { + scroll: false, + }); + }); + + it("replaces (no history entry) for a committed date bound", () => { + render(""); + dateRange!.onToChange("2026-05-31"); + expect(replaceMock).toHaveBeenCalledWith("/mood?to=2026-05-31", { + scroll: false, + }); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("clearing the last facet restores the bare pathname", () => { + render("mood=GUT"); + moodSelect().onValueChange("ALL"); + expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false }); + }); + + it("reset clears every facet in one push", () => { + render("mood=GUT&source=TELEGRAM&from=2026-05-01"); + expect(barProps!.isFiltered).toBe(true); + barProps!.onReset(); + expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false }); + }); +}); + +describe("MoodList — mounting with a query string restores the filters", () => { + it("seeds every rail control from the URL", () => { + render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31"); + expect(moodSelect().value).toBe("OKAY"); + expect(sourceSelect().value).toBe("MOODLOG"); + expect(dateRange!.from).toBe("2026-05-01"); + expect(dateRange!.to).toBe("2026-05-31"); + expect(barProps!.isFiltered).toBe(true); + }); + + it("keys the list query off the restored facets", () => { + render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31"); + expect(recordedQueryKeys).toContainEqual( + queryKeys.moodEntriesList({ + mood: "OKAY", + source: "MOODLOG", + from: "2026-05-01", + to: "2026-05-31", + page: 1, + sortBy: "moodLoggedAt", + sortDir: "desc", + }), + ); + }); + + it("drops an invalid facet instead of breaking the page", () => { + render("mood=ECSTATIC&source=WEB"); + expect(moodSelect().value).toBe("ALL"); + expect(sourceSelect().value).toBe("WEB"); + }); +}); diff --git a/src/components/mood/__tests__/mood-note-fulltext.test.tsx b/src/components/mood/__tests__/mood-note-fulltext.test.tsx index 34ab6ee8f..bd350ff46 100644 --- a/src/components/mood/__tests__/mood-note-fulltext.test.tsx +++ b/src/components/mood/__tests__/mood-note-fulltext.test.tsx @@ -46,6 +46,7 @@ const baseEntries = [ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/mood", + useSearchParams: () => new URLSearchParams(""), })); vi.mock("@tanstack/react-query", () => ({ diff --git a/src/components/mood/mood-list-filters.ts b/src/components/mood/mood-list-filters.ts new file mode 100644 index 000000000..f0d4509aa --- /dev/null +++ b/src/components/mood/mood-list-filters.ts @@ -0,0 +1,69 @@ +/** + * Pure URL-facet helpers for the mood management list, following the + * documents-vault pattern (`vault-utils.ts`): the list's filter state lives + * in the page URL (`?mood&source&from&to`) 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 { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood"; + +export interface MoodListFilters { + /** `MoodLevel` enum value; absent = all moods. */ + mood?: string; + /** `MoodSource` enum value; absent = all sources. */ + source?: string; + /** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */ + fromDay?: string; + toDay?: string; +} + +const MOOD_SET = new Set(moodLevelEnum.options); +const SOURCE_SET = new Set(moodSourceEnum.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; +} + +/** + * Parse the list's URL search params (`?mood&source&from&to`) into the + * filter object. Unknown moods/sources and malformed days are dropped, so a + * stale deep link never traps the user on a broken filter. + */ +export function parseMoodListSearchParams( + params: URLSearchParams, +): MoodListFilters { + const filters: MoodListFilters = {}; + + const mood = params.get("mood")?.trim(); + if (mood && MOOD_SET.has(mood)) filters.mood = mood; + + 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; + + return filters; +} + +/** + * Serialise the filter object back into the page URL's search string (no + * leading `?`; empty string for the default view). Inverse of + * `parseMoodListSearchParams`; round-tripping is pinned by test. + */ +export function moodListFiltersToSearch(filters: MoodListFilters): string { + const sp = new URLSearchParams(); + if (filters.mood) sp.set("mood", filters.mood); + if (filters.source) sp.set("source", filters.source); + if (filters.fromDay) sp.set("from", filters.fromDay); + if (filters.toDay) sp.set("to", filters.toDay); + return sp.toString(); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index a950b2fe8..b9bbefd4a 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -59,6 +59,11 @@ import { import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTableSort } from "@/hooks/use-table-sort"; +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; +import { + moodListFiltersToSearch, + parseMoodListSearchParams, +} from "./mood-list-filters"; // Columns that open descending when first selected. The logged-at column // reads newest-first by default; every other column opens ascending. @@ -174,11 +179,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // list stays readable and every control that would change it is absent. const { canManage } = useRecordCapabilities(); const queryClient = useQueryClient(); - const [moodFilter, setMoodFilterRaw] = useState("ALL"); + // URL-owned filter state (the documents-vault pattern): mood, source and + // date range parse from `?mood&source&from&to` and every change writes back + // through the router, so a filtered view survives navigation, reload and + // back/forward. // v1.15.13 — management-list source filter + optional date range. - const [sourceFilter, setSourceFilterRaw] = useState("ALL"); - const [fromDay, setFromDayRaw] = useState(""); - const [toDay, setToDayRaw] = useState(""); + const { filters, applyFilters } = useUrlFilterSync({ + parse: parseMoodListSearchParams, + serialise: moodListFiltersToSearch, + }); + const moodFilter = filters.mood ?? "ALL"; + const sourceFilter = filters.source ?? "ALL"; + const fromDay = filters.fromDay ?? ""; + const toDay = filters.toDay ?? ""; const [page, setPage] = useState(1); // Shared column-sort state (moodLoggedAt opens descending). const { @@ -268,26 +281,29 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { // clears the page-scoped selection. const clearSelection = () => setSelectedIds(new Set()); + // Discrete facet changes push (Back steps through filter states, like the + // vault's chips); the committed date bounds replace — a from/to pair set in + // one sitting should not cost two Back presses. const setMoodFilter = (value: string) => { - setMoodFilterRaw(value); + applyFilters({ ...filters, mood: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setSourceFilter = (value: string) => { - setSourceFilterRaw(value); + applyFilters({ ...filters, source: value === "ALL" ? undefined : value }); setPage(1); clearSelection(); }; const setFromDay = (value: string) => { - setFromDayRaw(value); + applyFilters({ ...filters, fromDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; const setToDay = (value: string) => { - setToDayRaw(value); + applyFilters({ ...filters, toDay: value || undefined }, "replace"); setPage(1); clearSelection(); }; @@ -624,10 +640,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { toDay !== "" } onReset={() => { - setMoodFilter("ALL"); - setSourceFilter("ALL"); - setFromDay(""); - setToDay(""); + // One push clears every facet, so one Back restores them. + applyFilters({}); + setPage(1); + clearSelection(); }} count={ data?.meta?.total !== undefined diff --git a/src/hooks/__tests__/use-url-filter-sync.test.tsx b/src/hooks/__tests__/use-url-filter-sync.test.tsx new file mode 100644 index 000000000..36aac48c7 --- /dev/null +++ b/src/hooks/__tests__/use-url-filter-sync.test.tsx @@ -0,0 +1,109 @@ +/** + * The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the + * documents-vault pattern extracted for the measurements and mood lists. + * Pinned here: mount parses the live query string, a filter change writes + * the serialised form back through the router (push by default, replace on + * request), an empty filter set restores the bare pathname, and + * `syncToUrl: false` detaches the state from the URL entirely. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +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), +})); + +import { useUrlFilterSync } from "../use-url-filter-sync"; + +interface ProbeFilters { + type?: string; + source?: string; +} + +function parse(params: URLSearchParams): ProbeFilters { + const filters: ProbeFilters = {}; + const type = params.get("type"); + if (type) filters.type = type; + const source = params.get("source"); + if (source) filters.source = source; + return filters; +} + +function serialise(filters: ProbeFilters): string { + const sp = new URLSearchParams(); + if (filters.type) sp.set("type", filters.type); + if (filters.source) sp.set("source", filters.source); + return sp.toString(); +} + +interface ProbeResult { + filters: ProbeFilters; + applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void; +} + +function mount(search: string, syncToUrl?: boolean): ProbeResult { + mockSearch = search; + // Ref-shaped capture cell: the render-scoped assignment happens inside a + // closure the control-flow analysis cannot see through. + const captured: { current: ProbeResult | null } = { current: null }; + function Probe() { + captured.current = useUrlFilterSync({ + parse, + serialise, + syncToUrl, + }); + return null; + } + renderToStaticMarkup(); + if (!captured.current) throw new Error("probe did not render"); + return captured.current; +} + +beforeEach(() => { + pushMock.mockClear(); + replaceMock.mockClear(); +}); + +describe("useUrlFilterSync", () => { + it("parses the mounted query string into the filter object", () => { + const { filters } = mount("type=WEIGHT&source=WITHINGS"); + expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" }); + }); + + it("writes a filter change to the URL via push by default", () => { + const { applyFilters } = mount(""); + applyFilters({ type: "WEIGHT" }); + expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", { + scroll: false, + }); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("writes via replace when asked (high-frequency input)", () => { + const { applyFilters } = mount(""); + applyFilters({ source: "MANUAL" }, "replace"); + expect(replaceMock).toHaveBeenCalledWith("/measurements?source=MANUAL", { + scroll: false, + }); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("restores the bare pathname when every facet clears", () => { + const { applyFilters } = mount("type=WEIGHT"); + applyFilters({}); + expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false }); + }); + + it("syncToUrl: false ignores the URL and never navigates", () => { + const { filters, applyFilters } = mount("type=WEIGHT", false); + expect(filters).toEqual({}); + applyFilters({ type: "PULSE" }); + expect(pushMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-url-filter-sync.ts b/src/hooks/use-url-filter-sync.ts new file mode 100644 index 000000000..0050182ca --- /dev/null +++ b/src/hooks/use-url-filter-sync.ts @@ -0,0 +1,72 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +/** + * URL-owned filter state — the documents-vault pattern + * (`src/components/documents/documents-view.tsx`), extracted so the + * measurements and mood management lists carry the same contract: the page's + * filter object is parsed from the live query string and every change writes + * the serialised form back through the router, so a filtered view survives + * navigation, reload, back/forward and sharing. + * + * `parse` and `serialise` must be inverse, module-level functions (stable + * identities): round-tripping is what keeps a deep link and the UI agreeing, + * and `serialise` omits defaults so the unfiltered view keeps a bare URL. + * + * Mode grammar (matching the vault): a discrete facet change (`push`, the + * default) creates a history entry so Back steps through filter states, like + * the vault's kind/year chips; high-frequency typed input passes `replace` so + * the history never fills with keystrokes, like the vault's debounced search. + * + * `syncToUrl: false` keeps the identical contract in component memory only — + * for a list embedded under a URL that belongs to another surface (the + * type-locked insights readings subpage). + */ +export function useUrlFilterSync({ + parse, + serialise, + syncToUrl = true, +}: { + parse: (params: URLSearchParams) => T; + serialise: (filters: T) => string; + syncToUrl?: boolean; +}): { + filters: T; + applyFilters: (next: T, mode?: "push" | "replace") => void; +} { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const urlFilters = useMemo( + () => parse(new URLSearchParams(searchParams.toString())), + [parse, searchParams], + ); + + // Memory-only fallback for `syncToUrl: false`. The hook always mounts both + // sources (hooks are unconditional); only one is read. + const [localFilters, setLocalFilters] = useState(() => + parse(new URLSearchParams()), + ); + + const applyFilters = useCallback( + (next: T, mode: "push" | "replace" = "push") => { + if (!syncToUrl) { + setLocalFilters(next); + return; + } + const search = serialise(next); + const href = search ? `${pathname}?${search}` : pathname; + if (mode === "push") { + router.push(href, { scroll: false }); + } else { + router.replace(href, { scroll: false }); + } + }, + [syncToUrl, serialise, pathname, router], + ); + + return { filters: syncToUrl ? urlFilters : localFilters, applyFilters }; +}