Просмотр исходного кода

RHL-043 feat(search): add recent search history dropdown

Code_Uwe 1 месяц назад
Родитель
Сommit
b39755e567

+ 6 - 0
components/search/SearchForm.jsx

@@ -37,6 +37,9 @@ export default function SearchForm({
 	to,
 	onDateRangeChange,
 	validationError,
+	recentSearches,
+	onSelectRecentSearch,
+	onClearRecentSearches,
 }) {
 	const canSearch = typeof qDraft === "string" && qDraft.trim().length > 0;
 
@@ -66,6 +69,9 @@ export default function SearchForm({
 						currentQuery={currentQuery}
 						isSubmitting={isSubmitting}
 						canSearch={canSearch}
+						recentSearches={recentSearches}
+						onSelectRecentSearch={onSelectRecentSearch}
+						onClearRecentSearches={onClearRecentSearches}
 					/>
 				</div>
 

+ 86 - 0
components/search/SearchPage.jsx

@@ -29,6 +29,13 @@ import {
 	buildNextStateForClearAllBranches,
 	buildHrefForSingleBranchSwitch,
 } from "@/lib/frontend/search/pageHelpers";
+import {
+	loadSearchHistory,
+	addSearchHistoryEntry,
+	clearSearchHistory,
+	buildSearchHrefFromEntry,
+	DEFAULT_SEARCH_HISTORY_MAX_ITEMS,
+} from "@/lib/frontend/search/history";
 
 import { buildDateFilterValidationError } from "@/lib/frontend/search/dateFilterValidation";
 
@@ -44,6 +51,10 @@ export default function SearchPage({ branch: routeBranch }) {
 	const router = useRouter();
 	const searchParams = useSearchParams();
 	const { status: authStatus, user } = useAuth();
+	const userId =
+		typeof user?.userId === "string" && user.userId.trim()
+			? user.userId.trim()
+			: null;
 
 	const isAuthenticated = authStatus === "authenticated" && user;
 	const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
@@ -68,6 +79,21 @@ export default function SearchPage({ branch: routeBranch }) {
 		setQDraft(urlState.q || "");
 	}, [urlState.q]);
 
+	const [recentSearches, setRecentSearches] = React.useState([]);
+	React.useEffect(() => {
+		if (!userId) {
+			setRecentSearches([]);
+			return;
+		}
+
+		setRecentSearches(loadSearchHistory(userId));
+	}, [userId]);
+
+	const historyWriteGuardRef = React.useRef("");
+	React.useEffect(() => {
+		historyWriteGuardRef.current = "";
+	}, [userId]);
+
 	const branchesQuery = useSearchBranches({ enabled: isAdminLike });
 
 	const query = useSearchQuery({
@@ -105,6 +131,45 @@ export default function SearchPage({ branch: routeBranch }) {
 				? mappedLocalDateValidation
 				: null;
 
+	React.useEffect(() => {
+		// Trigger policy: store only executed searches that completed first-page loading successfully.
+		if (!userId) return;
+		if (query.status !== "success") return;
+		if (typeof urlState.q !== "string" || !urlState.q.trim()) return;
+
+		const writeKey = `${userId}|${searchKey}`;
+		if (historyWriteGuardRef.current === writeKey) return;
+
+		const nextEntries = addSearchHistoryEntry(
+			userId,
+			{
+				routeBranch,
+				q: urlState.q,
+				scope: urlState.scope,
+				branches: urlState.branches,
+				limit: urlState.limit,
+				from: urlState.from,
+				to: urlState.to,
+				createdAt: Date.now(),
+			},
+			{ maxItems: DEFAULT_SEARCH_HISTORY_MAX_ITEMS },
+		);
+
+		setRecentSearches(nextEntries);
+		historyWriteGuardRef.current = writeKey;
+	}, [
+		userId,
+		query.status,
+		searchKey,
+		routeBranch,
+		urlState.q,
+		urlState.scope,
+		urlState.branches,
+		urlState.limit,
+		urlState.from,
+		urlState.to,
+	]);
+
 	React.useEffect(() => {
 		if (mappedError?.kind !== "unauthenticated") return;
 
@@ -188,6 +253,24 @@ export default function SearchPage({ branch: routeBranch }) {
 		[urlState, replaceStateToUrl],
 	);
 
+	const handleSelectRecentSearch = React.useCallback(
+		(entry) => {
+			const href = buildSearchHrefFromEntry(entry);
+			if (!href) return;
+
+			router.push(href);
+		},
+		[router],
+	);
+
+	const handleClearRecentSearches = React.useCallback(() => {
+		if (!userId) return;
+
+		clearSearchHistory(userId);
+		setRecentSearches([]);
+		historyWriteGuardRef.current = "";
+	}, [userId]);
+
 	if (mappedError?.kind === "forbidden") {
 		return <ForbiddenView attemptedBranch={routeBranch} />;
 	}
@@ -259,6 +342,9 @@ export default function SearchPage({ branch: routeBranch }) {
 					to={urlState.to}
 					onDateRangeChange={handleDateRangeChange}
 					validationError={formValidationError}
+					recentSearches={recentSearches}
+					onSelectRecentSearch={handleSelectRecentSearch}
+					onClearRecentSearches={handleClearRecentSearches}
 				/>
 			</ExplorerSectionCard>
 

+ 157 - 22
components/search/form/SearchQueryBox.jsx

@@ -1,11 +1,52 @@
 "use client";
 
 import React from "react";
-import { Search } from "lucide-react";
+import { Clock3, Search } from "lucide-react";
 
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
+import {
+	Popover,
+	PopoverContent,
+	PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+	Command,
+	CommandGroup,
+	CommandItem,
+	CommandList,
+} from "@/components/ui/command";
+
+function getScopeSummary(entry) {
+	if (entry?.scope === "all") return "Alle Niederlassungen";
+
+	if (entry?.scope === "multi") {
+		const count = Array.isArray(entry?.branches) ? entry.branches.length : 0;
+		return count > 0
+			? `Mehrere Niederlassungen (${count})`
+			: "Mehrere Niederlassungen";
+	}
+
+	return `Niederlassung ${entry?.routeBranch || ""}`.trim();
+}
+
+function getDateSummary(entry) {
+	const from = typeof entry?.from === "string" ? entry.from.trim() : "";
+	const to = typeof entry?.to === "string" ? entry.to.trim() : "";
+
+	if (from && to) return `${from} bis ${to}`;
+	if (from) return `ab ${from}`;
+	if (to) return `bis ${to}`;
+	return "";
+}
+
+function getEntryMetaSummary(entry) {
+	const parts = [getScopeSummary(entry)];
+	const dateSummary = getDateSummary(entry);
+	if (dateSummary) parts.push(dateSummary);
+	return parts.join(" • ");
+}
 
 export default function SearchQueryBox({
 	qDraft,
@@ -14,7 +55,30 @@ export default function SearchQueryBox({
 	currentQuery,
 	isSubmitting,
 	canSearch,
+	recentSearches,
+	onSelectRecentSearch,
+	onClearRecentSearches,
 }) {
+	const [open, setOpen] = React.useState(false);
+
+	const historyItems = Array.isArray(recentSearches) ? recentSearches : [];
+	const hasHistory = historyItems.length > 0;
+	const canClearHistory =
+		hasHistory && typeof onClearRecentSearches === "function";
+
+	React.useEffect(() => {
+		if (hasHistory) return;
+		setOpen(false);
+	}, [hasHistory]);
+
+	const handleInputFocus = React.useCallback(() => {
+		const trimmedDraft = typeof qDraft === "string" ? qDraft.trim() : "";
+		if (trimmedDraft) return;
+		if (!hasHistory) return;
+
+		setOpen(true);
+	}, [qDraft, hasHistory]);
+
 	return (
 		// Important for flex layouts:
 		// - w-full ensures the box fills its container width.
@@ -22,27 +86,98 @@ export default function SearchQueryBox({
 		<div className="grid w-full min-w-0 gap-2">
 			<Label htmlFor="q">Suchbegriff</Label>
 
-			<div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
-				<Input
-					id="q"
-					name="q"
-					value={qDraft}
-					onChange={(e) => onQDraftChange(e.target.value)}
-					placeholder="z. B. Bridgestone, Rechnung, Kundennummer…"
-					disabled={isSubmitting}
-					// Make the input take the remaining space next to the button.
-					className="flex-1 min-w-0"
-				/>
-
-				<Button
-					type="submit"
-					disabled={!canSearch || isSubmitting}
-					className="shrink-0"
-				>
-					<Search className="h-4 w-4" />
-					Suchen
-				</Button>
-			</div>
+			<Popover open={open} onOpenChange={setOpen}>
+				<div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
+					<Input
+						id="q"
+						name="q"
+						value={qDraft}
+						onChange={(e) => onQDraftChange(e.target.value)}
+						onFocus={handleInputFocus}
+						placeholder="z. B. Bridgestone, Rechnung, Kundennummer…"
+						disabled={isSubmitting}
+						// Make the input take the remaining space next to the buttons.
+						className="flex-1 min-w-0"
+					/>
+
+					<PopoverTrigger asChild>
+						<Button
+							type="button"
+							variant="outline"
+							size="icon"
+							disabled={isSubmitting}
+							aria-label="Letzte Suchen öffnen"
+							title="Letzte Suchen öffnen"
+							className="shrink-0"
+						>
+							<Clock3 className="h-4 w-4" />
+						</Button>
+					</PopoverTrigger>
+
+					<Button
+						type="submit"
+						disabled={!canSearch || isSubmitting}
+						className="shrink-0"
+					>
+						<Search className="h-4 w-4" />
+						Suchen
+					</Button>
+				</div>
+
+				<PopoverContent align="start" className="w-[22rem] p-0">
+					{hasHistory ? (
+						<div className="overflow-hidden">
+							<Command>
+								<CommandList className="max-h-72">
+									<CommandGroup heading="Letzte Suchen">
+										{historyItems.map((entry, index) => (
+											<CommandItem
+												key={`${entry.routeBranch}|${entry.q}|${entry.createdAt}|${index}`}
+												value={`${entry.q} ${entry.routeBranch} ${entry.scope} ${(entry.branches || []).join(" ")}`}
+												onSelect={() => {
+													if (typeof onSelectRecentSearch !== "function") return;
+													onSelectRecentSearch(entry);
+													setOpen(false);
+												}}
+												className="items-start gap-3 py-2"
+											>
+												<Clock3 className="mt-0.5 h-4 w-4 text-muted-foreground" />
+												<div className="min-w-0 flex-1">
+													<p className="truncate text-sm font-medium">{entry.q}</p>
+													<p className="truncate text-xs text-muted-foreground">
+														{getEntryMetaSummary(entry)}
+													</p>
+												</div>
+											</CommandItem>
+										))}
+									</CommandGroup>
+								</CommandList>
+							</Command>
+
+							<div className="border-t p-2">
+								<Button
+									type="button"
+									variant="ghost"
+									size="sm"
+									className="w-full justify-start"
+									disabled={!canClearHistory}
+									onClick={() => {
+										if (!canClearHistory) return;
+										onClearRecentSearches();
+										setOpen(false);
+									}}
+								>
+									Verlauf löschen
+								</Button>
+							</div>
+						</div>
+					) : (
+						<div className="p-3 text-sm text-muted-foreground">
+							Keine letzten Suchen vorhanden.
+						</div>
+					)}
+				</PopoverContent>
+			</Popover>
 
 			{currentQuery ? (
 				<div className="text-xs text-muted-foreground">

+ 276 - 0
lib/frontend/search/history.js

@@ -0,0 +1,276 @@
+import { isValidBranchParam } from "@/lib/frontend/params";
+import { buildSearchHref } from "@/lib/frontend/search/pageHelpers";
+import {
+	SEARCH_SCOPE,
+	SEARCH_LIMITS,
+	DEFAULT_SEARCH_LIMIT,
+	serializeSearchUrlState,
+} from "@/lib/frontend/search/urlState";
+
+export const SEARCH_HISTORY_STORAGE_PREFIX = "rhl.searchHistory.v1";
+export const DEFAULT_SEARCH_HISTORY_MAX_ITEMS = 10;
+
+function normalizeUserId(userId) {
+	if (typeof userId !== "string") return null;
+
+	const trimmed = userId.trim();
+	return trimmed ? trimmed : null;
+}
+
+function normalizeQuery(value) {
+	if (typeof value !== "string") return null;
+
+	const trimmed = value.trim();
+	return trimmed ? trimmed : null;
+}
+
+function normalizeRouteBranch(value) {
+	if (typeof value !== "string") return null;
+
+	const normalized = value.trim().toUpperCase();
+	if (!normalized) return null;
+	if (!isValidBranchParam(normalized)) return null;
+
+	return normalized;
+}
+
+function toBranchNumber(branchId) {
+	const match = /^NL(\d+)$/i.exec(String(branchId || "").trim());
+	if (!match) return null;
+
+	const n = Number(match[1]);
+	return Number.isInteger(n) ? n : null;
+}
+
+function compareBranchIds(a, b) {
+	const aa = String(a || "");
+	const bb = String(b || "");
+
+	const na = toBranchNumber(aa);
+	const nb = toBranchNumber(bb);
+
+	if (na !== null && nb !== null) return na - nb;
+	return aa.localeCompare(bb, "en");
+}
+
+function normalizeBranches(value) {
+	const source = Array.isArray(value)
+		? value
+		: typeof value === "string"
+			? value.split(",")
+			: [];
+
+	const normalized = source
+		.map((item) => String(item || "").trim().toUpperCase())
+		.filter((item) => item && isValidBranchParam(item));
+
+	const unique = Array.from(new Set(normalized));
+	unique.sort(compareBranchIds);
+	return unique;
+}
+
+function normalizeScope(value, branches) {
+	if (value === SEARCH_SCOPE.ALL) return SEARCH_SCOPE.ALL;
+	if (value === SEARCH_SCOPE.MULTI) return SEARCH_SCOPE.MULTI;
+	if (value === SEARCH_SCOPE.SINGLE) return SEARCH_SCOPE.SINGLE;
+
+	return branches.length > 0 ? SEARCH_SCOPE.MULTI : SEARCH_SCOPE.SINGLE;
+}
+
+function normalizeLimit(value) {
+	const n = Number(value);
+	if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT;
+	if (!SEARCH_LIMITS.includes(n)) return DEFAULT_SEARCH_LIMIT;
+
+	return n;
+}
+
+function normalizeDate(value) {
+	if (typeof value !== "string") return null;
+
+	const trimmed = value.trim();
+	return trimmed ? trimmed : null;
+}
+
+function normalizeCreatedAt(value) {
+	const n = Number(value);
+	if (!Number.isFinite(n) || n <= 0) return Date.now();
+	return Math.trunc(n);
+}
+
+function buildEntryState(entry) {
+	return {
+		q: entry.q,
+		scope: entry.scope,
+		branches: entry.branches,
+		limit: entry.limit,
+		from: entry.from,
+		to: entry.to,
+	};
+}
+
+function buildHistoryIdentity(entry) {
+	const qs = serializeSearchUrlState(buildEntryState(entry));
+	return `${entry.routeBranch}|${qs}`;
+}
+
+function normalizeMaxItems(value) {
+	const n = Number(value);
+	if (!Number.isInteger(n) || n < 1) return DEFAULT_SEARCH_HISTORY_MAX_ITEMS;
+	return n;
+}
+
+function normalizeEntries(entries, { maxItems = Infinity } = {}) {
+	const safeMax = Number.isFinite(maxItems)
+		? Math.max(1, Math.trunc(maxItems))
+		: Infinity;
+
+	const out = [];
+	const seen = new Set();
+
+	for (const raw of Array.isArray(entries) ? entries : []) {
+		const normalized = normalizeSearchHistoryEntry(raw);
+		if (!normalized) continue;
+
+		const id = buildHistoryIdentity(normalized);
+		if (seen.has(id)) continue;
+
+		seen.add(id);
+		out.push(normalized);
+
+		if (out.length >= safeMax) break;
+	}
+
+	return out;
+}
+
+export function buildSearchHistoryStorageKey(userId) {
+	const normalizedUserId = normalizeUserId(userId);
+	if (!normalizedUserId) return null;
+
+	return `${SEARCH_HISTORY_STORAGE_PREFIX}.${normalizedUserId}`;
+}
+
+/**
+ * Normalize one search history entry into canonical form.
+ *
+ * Cursor is intentionally excluded from the schema because it is not shareable.
+ *
+ * @param {any} raw
+ * @returns {{
+ *   routeBranch: string,
+ *   q: string,
+ *   scope: "single"|"multi"|"all",
+ *   branches: string[],
+ *   limit: number,
+ *   from: string|null,
+ *   to: string|null,
+ *   createdAt: number
+ * }|null}
+ */
+export function normalizeSearchHistoryEntry(raw) {
+	if (!raw || typeof raw !== "object") return null;
+
+	const routeBranch = normalizeRouteBranch(raw.routeBranch);
+	const q = normalizeQuery(raw.q);
+	if (!routeBranch || !q) return null;
+
+	const allBranches = normalizeBranches(raw.branches);
+	const scope = normalizeScope(raw.scope, allBranches);
+	const branches = scope === SEARCH_SCOPE.MULTI ? allBranches : [];
+
+	return {
+		routeBranch,
+		q,
+		scope,
+		branches,
+		limit: normalizeLimit(raw.limit),
+		from: normalizeDate(raw.from),
+		to: normalizeDate(raw.to),
+		createdAt: normalizeCreatedAt(raw.createdAt),
+	};
+}
+
+export function loadSearchHistory(userId) {
+	const storageKey = buildSearchHistoryStorageKey(userId);
+	if (!storageKey) return [];
+	if (typeof window === "undefined") return [];
+
+	try {
+		const raw = window.localStorage.getItem(storageKey);
+		if (!raw) return [];
+
+		const parsed = JSON.parse(raw);
+		return normalizeEntries(parsed);
+	} catch {
+		return [];
+	}
+}
+
+export function saveSearchHistory(userId, entries) {
+	const normalized = normalizeEntries(entries);
+
+	const storageKey = buildSearchHistoryStorageKey(userId);
+	if (!storageKey) return normalized;
+	if (typeof window === "undefined") return normalized;
+
+	try {
+		if (normalized.length === 0) {
+			window.localStorage.removeItem(storageKey);
+		} else {
+			window.localStorage.setItem(storageKey, JSON.stringify(normalized));
+		}
+	} catch {
+		// ignore storage quota and privacy mode errors
+	}
+
+	return normalized;
+}
+
+export function addSearchHistoryEntry(
+	userId,
+	entry,
+	{ maxItems = DEFAULT_SEARCH_HISTORY_MAX_ITEMS } = {},
+) {
+	const normalizedEntry = normalizeSearchHistoryEntry(entry);
+	if (!normalizedEntry) return loadSearchHistory(userId);
+
+	const current = loadSearchHistory(userId);
+	const currentId = buildHistoryIdentity(normalizedEntry);
+
+	const deduped = current.filter((it) => buildHistoryIdentity(it) !== currentId);
+	const capped = [normalizedEntry, ...deduped].slice(0, normalizeMaxItems(maxItems));
+
+	return saveSearchHistory(userId, capped);
+}
+
+export function clearSearchHistory(userId) {
+	const storageKey = buildSearchHistoryStorageKey(userId);
+	if (!storageKey) return false;
+	if (typeof window === "undefined") return false;
+
+	try {
+		window.localStorage.removeItem(storageKey);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+export function buildSearchHrefFromEntry(entry) {
+	const normalized = normalizeSearchHistoryEntry(entry);
+	if (!normalized) return null;
+
+	const href = buildSearchHref({
+		routeBranch: normalized.routeBranch,
+		state: buildEntryState(normalized),
+	});
+
+	// Defensive safety gate: only allow internal paths.
+	if (typeof href !== "string") return null;
+	if (!href.startsWith("/")) return null;
+	if (href.startsWith("//")) return null;
+
+	return href;
+}
+

+ 241 - 0
lib/frontend/search/history.test.js

@@ -0,0 +1,241 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+	SEARCH_SCOPE,
+	DEFAULT_SEARCH_LIMIT,
+} from "@/lib/frontend/search/urlState";
+import {
+	buildSearchHistoryStorageKey,
+	loadSearchHistory,
+	saveSearchHistory,
+	addSearchHistoryEntry,
+	clearSearchHistory,
+	normalizeSearchHistoryEntry,
+	buildSearchHrefFromEntry,
+} from "./history.js";
+
+function createLocalStorageMock() {
+	const store = new Map();
+
+	return {
+		getItem: vi.fn((key) => (store.has(key) ? store.get(key) : null)),
+		setItem: vi.fn((key, value) => {
+			store.set(String(key), String(value));
+		}),
+		removeItem: vi.fn((key) => {
+			store.delete(String(key));
+		}),
+		clear: vi.fn(() => {
+			store.clear();
+		}),
+		dump: () => store,
+	};
+}
+
+describe("lib/frontend/search/history", () => {
+	let localStorageMock;
+
+	beforeEach(() => {
+		localStorageMock = createLocalStorageMock();
+		vi.stubGlobal("window", { localStorage: localStorageMock });
+	});
+
+	afterEach(() => {
+		vi.unstubAllGlobals();
+		vi.restoreAllMocks();
+	});
+
+	it("buildSearchHistoryStorageKey is user-scoped and versioned", () => {
+		expect(buildSearchHistoryStorageKey("u-1")).toBe("rhl.searchHistory.v1.u-1");
+		expect(buildSearchHistoryStorageKey("  ")).toBe(null);
+		expect(buildSearchHistoryStorageKey(null)).toBe(null);
+	});
+
+	it("normalizeSearchHistoryEntry canonicalizes values", () => {
+		const normalized = normalizeSearchHistoryEntry({
+			routeBranch: " nl1 ",
+			q: "  bridgestone  ",
+			scope: SEARCH_SCOPE.MULTI,
+			branches: ["nl20", "NL06", "NL20", "bad"],
+			limit: 999,
+			from: " 2025-01-01 ",
+			to: " ",
+			createdAt: "1700000000000",
+		});
+
+		expect(normalized).toEqual({
+			routeBranch: "NL1",
+			q: "bridgestone",
+			scope: SEARCH_SCOPE.MULTI,
+			branches: ["NL06", "NL20"],
+			limit: DEFAULT_SEARCH_LIMIT,
+			from: "2025-01-01",
+			to: null,
+			createdAt: 1700000000000,
+		});
+	});
+
+	it("save/load keeps deterministic normalized entries and dedupes by canonical identity", () => {
+		const userId = "admin-1";
+
+		const saved = saveSearchHistory(userId, [
+			{
+				routeBranch: "NL17",
+				q: "abc",
+				scope: SEARCH_SCOPE.MULTI,
+				branches: ["NL20", "NL06"],
+				limit: 200,
+				from: "2025-01-01",
+				to: "2025-01-31",
+				createdAt: 100,
+			},
+			{
+				routeBranch: "NL17",
+				q: " abc ",
+				scope: SEARCH_SCOPE.MULTI,
+				branches: ["NL06", "NL20", "NL20"],
+				limit: 200,
+				from: "2025-01-01",
+				to: "2025-01-31",
+				createdAt: 200,
+			},
+		]);
+
+		expect(saved).toHaveLength(1);
+		expect(saved[0]).toMatchObject({
+			routeBranch: "NL17",
+			q: "abc",
+			scope: SEARCH_SCOPE.MULTI,
+			branches: ["NL06", "NL20"],
+			limit: 200,
+			from: "2025-01-01",
+			to: "2025-01-31",
+		});
+
+		expect(loadSearchHistory(userId)).toEqual(saved);
+	});
+
+	it("addSearchHistoryEntry moves reused queries to the top (LRU)", () => {
+		const userId = "dev-1";
+
+		addSearchHistoryEntry(userId, {
+			routeBranch: "NL01",
+			q: "first",
+			scope: SEARCH_SCOPE.SINGLE,
+			branches: [],
+			limit: 100,
+			from: null,
+			to: null,
+			createdAt: 1,
+		});
+
+		addSearchHistoryEntry(userId, {
+			routeBranch: "NL02",
+			q: "second",
+			scope: SEARCH_SCOPE.SINGLE,
+			branches: [],
+			limit: 100,
+			from: null,
+			to: null,
+			createdAt: 2,
+		});
+
+		const next = addSearchHistoryEntry(userId, {
+			routeBranch: "NL01",
+			q: "first",
+			scope: SEARCH_SCOPE.SINGLE,
+			branches: [],
+			limit: 100,
+			from: null,
+			to: null,
+			createdAt: 3,
+		});
+
+		expect(next).toHaveLength(2);
+		expect(next[0].routeBranch).toBe("NL01");
+		expect(next[0].q).toBe("first");
+		expect(next[1].routeBranch).toBe("NL02");
+		expect(next[1].q).toBe("second");
+	});
+
+	it("addSearchHistoryEntry enforces cap", () => {
+		const userId = "cap-user";
+
+		for (let i = 1; i <= 12; i += 1) {
+			addSearchHistoryEntry(
+				userId,
+				{
+					routeBranch: "NL17",
+					q: `q-${i}`,
+					scope: SEARCH_SCOPE.SINGLE,
+					branches: [],
+					limit: 100,
+					from: null,
+					to: null,
+					createdAt: i,
+				},
+				{ maxItems: 10 },
+			);
+		}
+
+		const entries = loadSearchHistory(userId);
+		expect(entries).toHaveLength(10);
+		expect(entries[0].q).toBe("q-12");
+		expect(entries[9].q).toBe("q-3");
+	});
+
+	it("clearSearchHistory removes persisted entries", () => {
+		const userId = "clear-user";
+
+		saveSearchHistory(userId, [
+			{
+				routeBranch: "NL17",
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branches: [],
+				limit: 100,
+				from: null,
+				to: null,
+				createdAt: 1,
+			},
+		]);
+
+		expect(loadSearchHistory(userId)).toHaveLength(1);
+		expect(clearSearchHistory(userId)).toBe(true);
+		expect(loadSearchHistory(userId)).toEqual([]);
+	});
+
+	it("buildSearchHrefFromEntry returns only safe internal hrefs", () => {
+		const href = buildSearchHrefFromEntry({
+			routeBranch: "NL17",
+			q: "bridgestone",
+			scope: SEARCH_SCOPE.MULTI,
+			branches: ["NL20", "NL06"],
+			limit: 200,
+			from: "2025-01-01",
+			to: "2025-01-31",
+			createdAt: 1,
+		});
+
+		expect(href).toBe(
+			"/NL17/search?q=bridgestone&scope=multi&branches=NL06%2CNL20&limit=200&from=2025-01-01&to=2025-01-31",
+		);
+		expect(href.startsWith("/")).toBe(true);
+		expect(href.startsWith("//")).toBe(false);
+
+		expect(
+			buildSearchHrefFromEntry({
+				routeBranch: "http://evil.com",
+				q: "x",
+				scope: SEARCH_SCOPE.SINGLE,
+				branches: [],
+				limit: 100,
+				from: null,
+				to: null,
+				createdAt: 1,
+			}),
+		).toBe(null);
+	});
+});
+