Keep the measurements and mood filters in the URL

The documents view already round-trips its filters through the query
string; the measurements and mood lists kept theirs in memory, so any
navigation dropped them. The documents pattern is extracted into a shared
hook (use-url-filter-sync) and both lists adopt it: type/source/day-bounds
and the measurement value range survive reload, back and sharing, defaults
stay out of the URL, invalid facets drop leniently. The insights readings
subpage keeps its locked type in memory — that URL belongs to the insights
route. /measurements no longer strips ?type=, so the deep links the
Vorsorge cards mint now persist as filter state.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:24:08 +02:00
1 parent 5619783648
commit 689cacc039
20 files changed
+1048 -91

No files matched your search

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