From 689cacc0395d75838b3c256caec9a000313262d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:24:08 +0200 Subject: [PATCH] Keep the measurements and mood filters in the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/measurements/page.tsx | 32 +-- src/components/documents/documents-view.tsx | 26 +-- ...asurement-list-apple-health-badge.test.tsx | 1 + .../measurement-list-bp-badge.test.tsx | 1 + .../__tests__/measurement-list-empty.test.tsx | 1 + .../measurement-list-filter-select.test.tsx | 1 + .../measurement-list-filters.test.ts | 81 +++++++ .../measurement-list-locked-type.test.tsx | 1 + .../measurement-list-step-grouping.test.tsx | 1 + .../measurement-list-url-sync.test.tsx | 220 ++++++++++++++++++ .../measurements/measurement-list-filters.ts | 98 ++++++++ .../measurements/measurement-list.tsx | 122 ++++++---- .../mood-list-filter-select.test.tsx | 1 + .../mood/__tests__/mood-list-filters.test.ts | 68 ++++++ .../__tests__/mood-list-url-sync.test.tsx | 194 +++++++++++++++ .../__tests__/mood-note-fulltext.test.tsx | 1 + src/components/mood/mood-list-filters.ts | 69 ++++++ src/components/mood/mood-list.tsx | 40 +++- .../__tests__/use-url-filter-sync.test.tsx | 109 +++++++++ src/hooks/use-url-filter-sync.ts | 72 ++++++ 20 files changed, 1048 insertions(+), 91 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-list-filters.test.ts create mode 100644 src/components/measurements/__tests__/measurement-list-url-sync.test.tsx create mode 100644 src/components/measurements/measurement-list-filters.ts create mode 100644 src/components/mood/__tests__/mood-list-filters.test.ts create mode 100644 src/components/mood/__tests__/mood-list-url-sync.test.tsx create mode 100644 src/components/mood/mood-list-filters.ts create mode 100644 src/hooks/__tests__/use-url-filter-sync.test.tsx create mode 100644 src/hooks/use-url-filter-sync.ts 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 }; +}