Code_Uwe 2 周之前
父节点
当前提交
a37c45cb2e

+ 300 - 50
components/search/form/SearchDateRangePicker.jsx

@@ -1,23 +1,62 @@
 "use client";
 
 import React from "react";
-import { CalendarRange } from "lucide-react";
+import { CalendarRange, X } from "lucide-react";
 
-import { formatIsoDateRangeLabelDe } from "@/lib/frontend/search/dateRange";
+import {
+	formatIsoDateDe,
+	formatIsoDateRangeLabelDe,
+	toDateFromIsoDateYmd,
+	toIsoDateYmdFromDate,
+	compareIsoDatesYmd,
+	normalizeIsoDateYmdOrNull,
+} from "@/lib/frontend/search/dateRange";
+
+import { buildDatePresets } from "@/lib/frontend/search/datePresets";
 
+import { Calendar } from "@/components/ui/calendar";
+import { Badge } from "@/components/ui/badge";
 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 { cn } from "@/lib/utils";
+
+const FIELD = Object.freeze({
+	FROM: "from",
+	TO: "to",
+});
+
+function pickInitialActiveField(from, to) {
+	if (!from) return FIELD.FROM;
+	if (from && !to) return FIELD.TO;
+	return FIELD.FROM;
+}
 
-function toNullableYmd(value) {
-	if (typeof value !== "string") return null;
-	const s = value.trim();
-	return s ? s : null;
+function focusById(id) {
+	if (typeof document === "undefined") return;
+	const el = document.getElementById(id);
+	if (el && typeof el.focus === "function") el.focus();
+}
+
+function buildDisplayedRange(from, to) {
+	const fIso = normalizeIsoDateYmdOrNull(from);
+	const tIso = normalizeIsoDateYmdOrNull(to);
+
+	const f = toDateFromIsoDateYmd(fIso);
+	const t = toDateFromIsoDateYmd(tIso);
+
+	if (f && t && compareIsoDatesYmd(fIso, tIso) <= 0) {
+		return { from: f, to: t };
+	}
+
+	if (f) return { from: f, to: undefined };
+	if (t) return { from: t, to: undefined };
+
+	return undefined;
 }
 
 export default function SearchDateRangePicker({
@@ -27,13 +66,149 @@ export default function SearchDateRangePicker({
 	isSubmitting,
 }) {
 	const [open, setOpen] = React.useState(false);
+	const [activeField, setActiveField] = React.useState(() =>
+		pickInitialActiveField(from, to)
+	);
+
+	const fromBtnId = React.useId();
+	const toBtnId = React.useId();
+
+	const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
+	const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
+
+	const now = React.useMemo(() => new Date(), []);
+	const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
+
+	// Calendar month anchor (controlled) so we can keep UX stable when switching fields.
+	const [month, setMonth] = React.useState(() => fromDate || toDate || now);
+
+	// When popover opens: pick a sensible active field and month anchor.
+	React.useEffect(() => {
+		if (!open) return;
+
+		const nextActive = pickInitialActiveField(from, to);
+		setActiveField(nextActive);
+
+		const nextMonth =
+			nextActive === FIELD.FROM
+				? fromDate || toDate || now
+				: toDate || fromDate || now;
+
+		setMonth(nextMonth);
+
+		queueMicrotask(() => {
+			focusById(nextActive === FIELD.FROM ? fromBtnId : toBtnId);
+		});
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [open]);
 
 	const label = formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum";
+	const fromLabel = formatIsoDateDe(from) || "Startdatum";
+	const toLabel = formatIsoDateDe(to) || "Enddatum";
+
+	const canClearBoth = Boolean((from || to) && !isSubmitting);
+	const canClearFrom = Boolean(from && !isSubmitting);
+	const canClearTo = Boolean(to && !isSubmitting);
+
+	const activeBlue =
+		"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
+
+	const fromYear = 2000;
+	const toYear = now.getFullYear() + 1;
+
+	// Show the selected range visually (works for presets too).
+	const displayedRange = React.useMemo(() => {
+		return buildDisplayedRange(from, to);
+	}, [from, to]);
+
+	function commit(nextFrom, nextTo) {
+		if (typeof onDateRangeChange !== "function") return;
+		onDateRangeChange({ from: nextFrom, to: nextTo });
+	}
+
+	function setActive(field) {
+		setActiveField(field);
+
+		const nextMonth =
+			field === FIELD.FROM ? fromDate || month : toDate || month;
+
+		if (nextMonth) setMonth(nextMonth);
 
-	const fromId = React.useId();
-	const toId = React.useId();
+		queueMicrotask(() => {
+			focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
+		});
+	}
 
-	const canClear = Boolean((from || to) && !isSubmitting);
+	function clearFrom() {
+		if (!canClearFrom) return;
+
+		commit(null, to ?? null);
+
+		setActiveField(FIELD.FROM);
+		setMonth(toDate || now);
+
+		queueMicrotask(() => focusById(fromBtnId));
+	}
+
+	function clearTo() {
+		if (!canClearTo) return;
+
+		commit(from ?? null, null);
+
+		setActiveField(FIELD.TO);
+		setMonth(fromDate || now);
+
+		queueMicrotask(() => focusById(toBtnId));
+	}
+
+	function clearBoth() {
+		if (!canClearBoth) return;
+
+		commit(null, null);
+
+		setActiveField(FIELD.FROM);
+		setMonth(now);
+
+		queueMicrotask(() => focusById(fromBtnId));
+	}
+
+	function applyPreset(preset) {
+		if (!preset?.from || !preset?.to) return;
+
+		commit(preset.from, preset.to);
+
+		// Keep popover open so the user immediately sees the range highlighted.
+		setActiveField(FIELD.TO);
+
+		const nextMonth = toDateFromIsoDateYmd(preset.from) || now;
+		setMonth(nextMonth);
+
+		queueMicrotask(() => focusById(toBtnId));
+	}
+
+	function handleDayClick(day) {
+		if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
+
+		const iso = toIsoDateYmdFromDate(day);
+		if (!iso) return;
+
+		if (activeField === FIELD.FROM) {
+			commit(iso, to ?? null);
+
+			// After selecting FROM, auto-switch to TO (fast range building).
+			setActiveField(FIELD.TO);
+			setMonth(day);
+
+			queueMicrotask(() => focusById(toBtnId));
+			return;
+		}
+
+		// TO
+		commit(from ?? null, iso);
+		setMonth(day);
+
+		// Keep popover open; closing is user-controlled (click outside).
+	}
 
 	return (
 		<div className="grid gap-2">
@@ -53,54 +228,129 @@ export default function SearchDateRangePicker({
 					</Button>
 				</PopoverTrigger>
 
-				<PopoverContent align="start" className="w-80">
-					<div className="space-y-4">
-						<div className="grid gap-2">
-							<Label htmlFor={fromId}>Von</Label>
-							<Input
-								id={fromId}
-								type="date"
-								value={from || ""}
-								disabled={isSubmitting}
-								onChange={(e) => {
-									if (typeof onDateRangeChange !== "function") return;
-									onDateRangeChange({
-										from: toNullableYmd(e.target.value),
-										to: to ?? null,
-									});
-								}}
-							/>
+				{/* w-fit keeps the popover as compact as the 2-month calendar */}
+				<PopoverContent align="start" className="w-fit p-0">
+					<div className="p-3 space-y-3">
+						{/* Presets (wrap + compact) */}
+						<div className="space-y-2">
+							<p className="text-xs text-muted-foreground">Schnellwahl</p>
+
+							<div className="flex flex-wrap gap-2">
+								{presets.map((p) => (
+									<Badge key={p.key} variant="secondary" asChild>
+										<button
+											type="button"
+											className="cursor-pointer select-none hover:opacity-90"
+											disabled={isSubmitting}
+											onClick={() => applyPreset(p)}
+											title={`Schnellwahl: ${p.label}`}
+										>
+											{p.label}
+										</button>
+									</Badge>
+								))}
+							</div>
 						</div>
 
-						<div className="grid gap-2">
-							<Label htmlFor={toId}>Bis</Label>
-							<Input
-								id={toId}
-								type="date"
-								value={to || ""}
-								disabled={isSubmitting}
-								onChange={(e) => {
-									if (typeof onDateRangeChange !== "function") return;
-									onDateRangeChange({
-										from: from ?? null,
-										to: toNullableYmd(e.target.value),
-									});
-								}}
-							/>
+						{/* Von / Bis controls */}
+						<div className="grid grid-cols-2 gap-3">
+							<div className="grid gap-2">
+								<Label>Von</Label>
+								<div className="relative">
+									<Button
+										id={fromBtnId}
+										type="button"
+										variant="outline"
+										size="sm"
+										disabled={isSubmitting}
+										onClick={() => setActive(FIELD.FROM)}
+										className={cn(
+											"w-full justify-between pr-10",
+											activeField === FIELD.FROM ? activeBlue : ""
+										)}
+										title="Startdatum auswählen"
+									>
+										<span className="truncate">{fromLabel}</span>
+									</Button>
+
+									{canClearFrom ? (
+										<button
+											type="button"
+											className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
+											onClick={(e) => {
+												e.preventDefault();
+												e.stopPropagation();
+												clearFrom();
+											}}
+											aria-label="Startdatum löschen"
+											title="Startdatum löschen"
+										>
+											<X className="h-4 w-4" />
+										</button>
+									) : null}
+								</div>
+							</div>
+
+							<div className="grid gap-2">
+								<Label>Bis</Label>
+								<div className="relative">
+									<Button
+										id={toBtnId}
+										type="button"
+										variant="outline"
+										size="sm"
+										disabled={isSubmitting}
+										onClick={() => setActive(FIELD.TO)}
+										className={cn(
+											"w-full justify-between pr-10",
+											activeField === FIELD.TO ? activeBlue : ""
+										)}
+										title="Enddatum auswählen"
+									>
+										<span className="truncate">{toLabel}</span>
+									</Button>
+
+									{canClearTo ? (
+										<button
+											type="button"
+											className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
+											onClick={(e) => {
+												e.preventDefault();
+												e.stopPropagation();
+												clearTo();
+											}}
+											aria-label="Enddatum löschen"
+											title="Enddatum löschen"
+										>
+											<X className="h-4 w-4" />
+										</button>
+									) : null}
+								</div>
+							</div>
 						</div>
 
-						<div className="flex justify-end gap-2">
+						{/* Calendar: 2 months + range highlight */}
+						<Calendar
+							mode="range"
+							captionLayout="dropdown"
+							fromYear={fromYear}
+							toYear={toYear}
+							numberOfMonths={2}
+							pagedNavigation
+							month={month}
+							onMonthChange={setMonth}
+							selected={displayedRange}
+							onDayClick={(day) => handleDayClick(day)}
+							initialFocus
+						/>
+
+						<div className="flex justify-end gap-2 pt-1">
 							<Button
 								type="button"
 								variant="outline"
 								size="sm"
-								disabled={!canClear}
-								onClick={() => {
-									if (!canClear) return;
-									if (typeof onDateRangeChange !== "function") return;
-
-									onDateRangeChange({ from: null, to: null });
-								}}
+								disabled={!canClearBoth}
+								onClick={clearBoth}
 								title="Zeitraum entfernen"
 							>
 								Zurücksetzen

+ 110 - 0
lib/frontend/search/datePresets.js

@@ -0,0 +1,110 @@
+import { toIsoDateYmdFromDate } from "@/lib/frontend/search/dateRange";
+
+function toLocalDay(date) {
+	if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
+		return new Date();
+	}
+
+	return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+}
+
+function addDays(date, deltaDays) {
+	const d = toLocalDay(date);
+	d.setDate(d.getDate() + Number(deltaDays || 0));
+	return d;
+}
+
+function startOfMonth(date) {
+	const d = toLocalDay(date);
+	return new Date(d.getFullYear(), d.getMonth(), 1);
+}
+
+function startOfPrevMonth(date) {
+	const d = toLocalDay(date);
+	return new Date(d.getFullYear(), d.getMonth() - 1, 1);
+}
+
+function endOfPrevMonth(date) {
+	const d = toLocalDay(date);
+	// Day 0 of current month = last day of previous month.
+	return new Date(d.getFullYear(), d.getMonth(), 0);
+}
+
+function startOfYear(date) {
+	const d = toLocalDay(date);
+	return new Date(d.getFullYear(), 0, 1);
+}
+
+export const DATE_PRESET_KEY = Object.freeze({
+	TODAY: "today",
+	YESTERDAY: "yesterday",
+	LAST_7_DAYS: "last_7_days",
+	LAST_30_DAYS: "last_30_days",
+	THIS_MONTH: "this_month",
+	LAST_MONTH: "last_month",
+	THIS_YEAR: "this_year",
+});
+
+/**
+ * Build German-labeled date presets for the Search date filter.
+ *
+ * All outputs are ISO YYYY-MM-DD strings (local calendar values, no timezone drift).
+ *
+ * @param {{ now?: Date }} args
+ * @returns {Array<{ key: string, label: string, from: string, to: string }>}
+ */
+export function buildDatePresets({ now = new Date() } = {}) {
+	const today = toLocalDay(now);
+
+	const presets = [
+		{
+			key: DATE_PRESET_KEY.TODAY,
+			label: "Heute",
+			from: today,
+			to: today,
+		},
+		{
+			key: DATE_PRESET_KEY.YESTERDAY,
+			label: "Gestern",
+			from: addDays(today, -1),
+			to: addDays(today, -1),
+		},
+		{
+			key: DATE_PRESET_KEY.LAST_7_DAYS,
+			label: "Letzte 7 Tage",
+			from: addDays(today, -6),
+			to: today,
+		},
+		{
+			key: DATE_PRESET_KEY.LAST_30_DAYS,
+			label: "Letzte 30 Tage",
+			from: addDays(today, -29),
+			to: today,
+		},
+		{
+			key: DATE_PRESET_KEY.THIS_MONTH,
+			label: "Dieser Monat",
+			from: startOfMonth(today),
+			to: today,
+		},
+		{
+			key: DATE_PRESET_KEY.LAST_MONTH,
+			label: "Letzter Monat",
+			from: startOfPrevMonth(today),
+			to: endOfPrevMonth(today),
+		},
+		{
+			key: DATE_PRESET_KEY.THIS_YEAR,
+			label: "Dieses Jahr",
+			from: startOfYear(today),
+			to: today,
+		},
+	];
+
+	return presets.map((p) => ({
+		key: p.key,
+		label: p.label,
+		from: toIsoDateYmdFromDate(p.from),
+		to: toIsoDateYmdFromDate(p.to),
+	}));
+}

+ 43 - 0
lib/frontend/search/datePresets.test.js

@@ -0,0 +1,43 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { buildDatePresets, DATE_PRESET_KEY } from "./datePresets.js";
+
+describe("lib/frontend/search/datePresets", () => {
+	it("builds stable ISO ranges for a fixed date", () => {
+		const now = new Date(2026, 0, 15); // 2026-01-15 (local)
+		const presets = buildDatePresets({ now });
+
+		const byKey = Object.fromEntries(presets.map((p) => [p.key, p]));
+
+		expect(byKey[DATE_PRESET_KEY.TODAY]).toMatchObject({
+			from: "2026-01-15",
+			to: "2026-01-15",
+		});
+
+		expect(byKey[DATE_PRESET_KEY.YESTERDAY]).toMatchObject({
+			from: "2026-01-14",
+			to: "2026-01-14",
+		});
+
+		expect(byKey[DATE_PRESET_KEY.LAST_7_DAYS]).toMatchObject({
+			from: "2026-01-09",
+			to: "2026-01-15",
+		});
+
+		expect(byKey[DATE_PRESET_KEY.THIS_MONTH]).toMatchObject({
+			from: "2026-01-01",
+			to: "2026-01-15",
+		});
+
+		expect(byKey[DATE_PRESET_KEY.LAST_MONTH]).toMatchObject({
+			from: "2025-12-01",
+			to: "2025-12-31",
+		});
+
+		expect(byKey[DATE_PRESET_KEY.THIS_YEAR]).toMatchObject({
+			from: "2026-01-01",
+			to: "2026-01-15",
+		});
+	});
+});

+ 27 - 26
lib/frontend/search/dateRange.js

@@ -12,11 +12,9 @@ export function isValidIsoDateYmd(value) {
 		return false;
 	}
 
-	// Keep it predictable (same policy as backend):
+	// Predictable policy (same as backend):
 	// - month: 1..12
 	// - day: 1..31
-	// We intentionally do NOT validate month-length precisely (e.g. Feb 30),
-	// because the backend currently behaves the same way.
 	if (m < 1 || m > 12) return false;
 	if (d < 1 || d > 31) return false;
 
@@ -34,12 +32,7 @@ export function normalizeIsoDateYmdOrNull(value) {
 
 /**
  * Compare ISO dates (YYYY-MM-DD).
- *
  * Lexicographic compare is correct for this format.
- *
- * @param {string} a
- * @param {string} b
- * @returns {number} -1 | 0 | 1
  */
 export function compareIsoDatesYmd(a, b) {
 	const aa = String(a || "");
@@ -51,9 +44,7 @@ export function compareIsoDatesYmd(a, b) {
 
 /**
  * Returns true when both dates exist and the range is invalid (from > to).
- *
- * IMPORTANT:
- * - from === to is valid and represents a single day.
+ * IMPORTANT: from === to is valid and represents a single day.
  */
 export function isInvalidIsoDateRange(from, to) {
 	const f = normalizeIsoDateYmdOrNull(from);
@@ -65,9 +56,6 @@ export function isInvalidIsoDateRange(from, to) {
 
 /**
  * Format ISO date (YYYY-MM-DD) as German UI date: DD.MM.YYYY
- *
- * @param {string|null} ymd
- * @returns {string|null}
  */
 export function formatIsoDateDe(ymd) {
 	const s = normalizeIsoDateYmdOrNull(ymd);
@@ -79,24 +67,12 @@ export function formatIsoDateDe(ymd) {
 
 /**
  * Build a compact German label for the active date filter.
- *
- * Rules:
- * - from + to:
- *   - same day => "DD.MM.YYYY"
- *   - range    => "DD.MM.YYYY – DD.MM.YYYY"
- * - only from => "ab DD.MM.YYYY"
- * - only to   => "bis DD.MM.YYYY"
- * - none      => null
- *
- * @param {{ from?: string|null, to?: string|null }} input
- * @returns {string|null}
  */
 export function formatIsoDateRangeLabelDe({ from = null, to = null } = {}) {
 	const f = formatIsoDateDe(from);
 	const t = formatIsoDateDe(to);
 
 	if (f && t) {
-		// Single day
 		if (normalizeIsoDateYmdOrNull(from) === normalizeIsoDateYmdOrNull(to)) {
 			return f;
 		}
@@ -108,3 +84,28 @@ export function formatIsoDateRangeLabelDe({ from = null, to = null } = {}) {
 
 	return null;
 }
+
+/**
+ * Convert a Date to ISO YYYY-MM-DD using LOCAL calendar values
+ * (avoids timezone drift from toISOString()).
+ */
+export function toIsoDateYmdFromDate(date) {
+	if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
+
+	const y = date.getFullYear();
+	const m = String(date.getMonth() + 1).padStart(2, "0");
+	const d = String(date.getDate()).padStart(2, "0");
+
+	return `${y}-${m}-${d}`;
+}
+
+/**
+ * Convert ISO YYYY-MM-DD to a Date (local time).
+ */
+export function toDateFromIsoDateYmd(ymd) {
+	const s = normalizeIsoDateYmdOrNull(ymd);
+	if (!s) return null;
+
+	const [y, m, d] = s.split("-").map((x) => Number(x));
+	return new Date(y, m - 1, d);
+}

+ 15 - 1
lib/frontend/search/dateRange.test.js

@@ -8,6 +8,8 @@ import {
 	isInvalidIsoDateRange,
 	formatIsoDateDe,
 	formatIsoDateRangeLabelDe,
+	toIsoDateYmdFromDate,
+	toDateFromIsoDateYmd,
 } from "./dateRange.js";
 
 describe("lib/frontend/search/dateRange", () => {
@@ -69,7 +71,6 @@ describe("lib/frontend/search/dateRange", () => {
 				formatIsoDateRangeLabelDe({ from: "2025-12-01", to: "2025-12-31" })
 			).toBe("01.12.2025 – 31.12.2025");
 
-			// Same day => single day label (and this must be treated as a valid one-day search)
 			expect(
 				formatIsoDateRangeLabelDe({ from: "2025-12-01", to: "2025-12-01" })
 			).toBe("01.12.2025");
@@ -85,4 +86,17 @@ describe("lib/frontend/search/dateRange", () => {
 			expect(formatIsoDateRangeLabelDe({ from: null, to: null })).toBe(null);
 		});
 	});
+
+	describe("toIsoDateYmdFromDate / toDateFromIsoDateYmd", () => {
+		it("converts dates without timezone drift", () => {
+			const d = new Date(2026, 0, 14); // 14 Jan 2026 (local)
+			expect(toIsoDateYmdFromDate(d)).toBe("2026-01-14");
+
+			const back = toDateFromIsoDateYmd("2026-01-14");
+			expect(back).not.toBe(null);
+			expect(back.getFullYear()).toBe(2026);
+			expect(back.getMonth()).toBe(0);
+			expect(back.getDate()).toBe(14);
+		});
+	});
 });

+ 12 - 74
lib/frontend/search/searchApiInput.js

@@ -1,27 +1,11 @@
 import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
-import { ApiClientError } from "@/lib/frontend/apiClient";
-import {
-	isValidIsoDateYmd,
-	isInvalidIsoDateRange,
-} from "@/lib/frontend/search/dateRange";
+import { buildDateFilterValidationError } from "@/lib/frontend/search/dateFilterValidation";
+import { normalizeIsoDateYmdOrNull } from "@/lib/frontend/search/dateRange";
 
 function isNonEmptyString(value) {
 	return typeof value === "string" && value.trim().length > 0;
 }
 
-function toTrimmedOrNull(value) {
-	return isNonEmptyString(value) ? value.trim() : null;
-}
-
-function buildValidationError(code, message, details) {
-	return new ApiClientError({
-		status: 400,
-		code,
-		message,
-		details,
-	});
-}
-
 /**
  * Build the apiClient.search(...) input from URL state + current user context.
  *
@@ -31,22 +15,6 @@ function buildValidationError(code, message, details) {
  *
  * UX policy for MULTI without branches:
  * - Treat it as "not ready" (input=null, error=null) instead of an error.
- *
- * @param {{
- *   urlState: {
- *     q: string|null,
- *     scope: "single"|"multi"|"all",
- *     branch: string|null,
- *     branches: string[],
- *     from: string|null,
- *     to: string|null
- *   },
- *   routeBranch: string,
- *   user: { role: string, branchId: string|null }|null,
- *   cursor?: string|null,
- *   limit?: number
- * }} args
- * @returns {{ input: any|null, error: any|null }}
  */
 export function buildSearchApiInput({
 	urlState,
@@ -60,49 +28,19 @@ export function buildSearchApiInput({
 	// UI policy (RHL-024): q is required to trigger a search.
 	if (!q) return { input: null, error: null };
 
-	// --- Date range validation (RHL-025) ------------------------------------
-	const from = toTrimmedOrNull(urlState?.from);
-	const to = toTrimmedOrNull(urlState?.to);
-
-	// If provided, dates must be valid YYYY-MM-DD.
-	if (from && !isValidIsoDateYmd(from)) {
-		return {
-			input: null,
-			error: buildValidationError(
-				"VALIDATION_SEARCH_DATE",
-				"Invalid from date",
-				{
-					from,
-				}
-			),
-		};
-	}
-
-	if (to && !isValidIsoDateYmd(to)) {
-		return {
-			input: null,
-			error: buildValidationError("VALIDATION_SEARCH_DATE", "Invalid to date", {
-				to,
-			}),
-		};
-	}
-
-	// Range order: from must not be after to.
-	// IMPORTANT: from === to is valid and represents a single-day search.
-	if (isInvalidIsoDateRange(from, to)) {
-		return {
-			input: null,
-			error: buildValidationError(
-				"VALIDATION_SEARCH_RANGE",
-				"Invalid date range",
-				{ from, to }
-			),
-		};
-	}
+	// DRY: validate date filters via shared pure helper (RHL-025 UX + consistency).
+	const dateErr = buildDateFilterValidationError({
+		from: urlState?.from ?? null,
+		to: urlState?.to ?? null,
+	});
+	if (dateErr) return { input: null, error: dateErr };
 
-	// --- Build input ---------------------------------------------------------
 	const input = { q, limit };
 
+	// Normalize (trim + validity) before passing to apiClient.
+	const from = normalizeIsoDateYmdOrNull(urlState?.from);
+	const to = normalizeIsoDateYmdOrNull(urlState?.to);
+
 	if (from) input.from = from;
 	if (to) input.to = to;