Pārlūkot izejas kodu

RHL-025 refactor(date-range): streamline SearchDateRangePicker by removing unused functions and improving date handling

Code_Uwe 2 nedēļas atpakaļ
vecāks
revīzija
2c5006acd7

+ 3 - 9
components/search/SearchPage.jsx

@@ -9,10 +9,7 @@ import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { searchPath } from "@/lib/frontend/routes";
 import { isValidBranchParam } from "@/lib/frontend/params";
 
-import {
-	parseSearchUrlState,
-	SEARCH_SCOPE,
-} from "@/lib/frontend/search/urlState";
+import { parseSearchUrlState } from "@/lib/frontend/search/urlState";
 import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
 import { mapSearchError } from "@/lib/frontend/search/errorMapping";
 import { useSearchQuery } from "@/lib/frontend/search/useSearchQuery";
@@ -90,7 +87,6 @@ export default function SearchPage({ branch: routeBranch }) {
 		[query.loadMoreError]
 	);
 
-	// Local date validation: always run (even when q is missing) for instant UX feedback.
 	const localDateValidationError = React.useMemo(() => {
 		return buildDateFilterValidationError({
 			from: urlState.from,
@@ -102,14 +98,12 @@ export default function SearchPage({ branch: routeBranch }) {
 		return mapSearchError(localDateValidationError);
 	}, [localDateValidationError]);
 
-	// Validation errors should be shown near the inputs (SearchForm).
-	// Prefer the query-derived validation when present, otherwise fall back to local date validation.
 	const formValidationError =
 		mappedError?.kind === "validation"
 			? mappedError
 			: mappedLocalDateValidation?.kind === "validation"
-				? mappedLocalDateValidation
-				: null;
+			? mappedLocalDateValidation
+			: null;
 
 	React.useEffect(() => {
 		if (mappedError?.kind !== "unauthenticated") return;

+ 1 - 1
components/search/form/SearchDateFilterChip.jsx

@@ -24,7 +24,7 @@ export default function SearchDateFilterChip({
 
 			<button
 				type="button"
-				className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-muted/60 hover:text-gray-100 disabled:opacity-60 cursor-pointer"
+				className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-muted/60 disabled:opacity-60 cursor-pointer"
 				onClick={() => {
 					if (!canClear) return;
 					onClear();

+ 33 - 221
components/search/form/SearchDateRangePicker.jsx

@@ -16,48 +16,7 @@ import {
 	PopoverTrigger,
 } from "@/components/ui/popover";
 
-import { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
-import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
-import { buildDatePresets } from "@/lib/frontend/search/datePresets";
-
-function pad2(n) {
-	return String(n).padStart(2, "0");
-}
-
-function dateToIsoYmd(date) {
-	if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
-
-	const y = date.getFullYear();
-	const m = pad2(date.getMonth() + 1);
-	const d = pad2(date.getDate());
-	return `${y}-${m}-${d}`;
-}
-
-function isoYmdToDate(iso) {
-	if (typeof iso !== "string") return null;
-	if (!isValidIsoDateYmd(iso)) return null;
-
-	const [y, m, d] = iso.split("-").map((x) => Number(x));
-	return new Date(y, m - 1, d);
-}
-
-const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
-	day: "2-digit",
-	month: "2-digit",
-	year: "numeric",
-});
-
-function formatIsoToDe(iso) {
-	const d = isoYmdToDate(iso);
-	return d ? deDateFormatter.format(d) : "";
-}
-
-function getSummaryLabel(from, to) {
-	if (from && to) return `${formatIsoToDe(from)} – ${formatIsoToDe(to)}`;
-	if (from) return `ab ${formatIsoToDe(from)}`;
-	if (to) return `bis ${formatIsoToDe(to)}`;
-	return "Zeitraum auswählen";
-}
+import { useSearchDateRangePicker } from "@/lib/frontend/search/useSearchDateRangePicker";
 
 export default function SearchDateRangePicker({
 	from,
@@ -66,185 +25,40 @@ export default function SearchDateRangePicker({
 	isSubmitting,
 	className,
 }) {
-	const disabled = Boolean(isSubmitting);
-
-	const [open, setOpen] = React.useState(false);
-	const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
-
-	const fromRef = React.useRef(null);
-	const toRef = React.useRef(null);
-
-	const fromDate = isoYmdToDate(from);
-	const toDate = isoYmdToDate(to);
-
-	const validation = getSearchDateRangeValidation(from ?? null, to ?? null);
-	const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
-
-	const now = React.useMemo(() => new Date(), []);
-	const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
-
-	// Split into two rows (4 + 3):
-	// Row 1: short ranges (today/yesterday/last 7/last 30)
-	// Row 2: larger ranges (this month/last month/this year)
-	const presetsRow1 = presets.slice(0, 4);
-	const presetsRow2 = presets.slice(4);
-
-	const [month, setMonth] = React.useState(() => {
-		return fromDate || toDate || new Date();
+	const {
+		disabled,
+		open,
+		setOpen,
+		activeField,
+		setActiveField,
+		fromRef,
+		toRef,
+		month,
+		setMonth,
+		summary,
+		fromDisplay,
+		toDisplay,
+		presetsRow1,
+		presetsRow2,
+		calendarKey,
+		calendarSelected,
+		calendarModifiers,
+		calendarModifiersClassNames,
+		handlePickDay,
+		handleClearFrom,
+		handleClearTo,
+		handleReset,
+		applyPreset,
+	} = useSearchDateRangePicker({
+		from,
+		to,
+		onDateRangeChange,
+		isSubmitting,
 	});
 
-	React.useEffect(() => {
-		if (!open) return;
-
-		if (from && !to) {
-			setActiveField("to");
-			requestAnimationFrame(() => toRef.current?.focus?.());
-		} else {
-			setActiveField("from");
-			requestAnimationFrame(() => fromRef.current?.focus?.());
-		}
-
-		setMonth(fromDate || toDate || new Date());
-		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [open]);
-
-	function safeOnDateRangeChange(next) {
-		if (typeof onDateRangeChange !== "function") return;
-
-		const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
-		const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
-
-		onDateRangeChange({
-			from: hasFrom ? next.from : from ?? null,
-			to: hasTo ? next.to : to ?? null,
-		});
-	}
-
-	let calendarSelected = undefined;
-	let invalidInterval = null;
-
-	if (fromDate && toDate) {
-		if (isRangeInvalid) {
-			const min = fromDate < toDate ? fromDate : toDate;
-			const max = fromDate < toDate ? toDate : fromDate;
-			calendarSelected = { from: min, to: max };
-			invalidInterval = { from: min, to: max };
-		} else {
-			calendarSelected = { from: fromDate, to: toDate };
-		}
-	} else if (fromDate) {
-		calendarSelected = { from: fromDate, to: undefined };
-	} else if (toDate) {
-		calendarSelected = { from: toDate, to: toDate };
-	}
-
-	const calendarModifiers =
-		isRangeInvalid && invalidInterval
-			? {
-					invalid_range: invalidInterval,
-					invalid_range_edge: [fromDate, toDate].filter(Boolean),
-			  }
-			: undefined;
-
-	const calendarModifiersClassNames =
-		isRangeInvalid && invalidInterval
-			? {
-					invalid_range:
-						"bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive",
-					invalid_range_edge:
-						"!bg-destructive !text-white hover:!bg-destructive/90",
-			  }
-			: undefined;
-
-	function focusActiveField(nextField) {
-		if (nextField === "from") {
-			requestAnimationFrame(() => fromRef.current?.focus?.());
-		} else {
-			requestAnimationFrame(() => toRef.current?.focus?.());
-		}
-	}
-
-	function handlePickDay(...args) {
-		if (disabled) return;
-
-		let day = args[0];
-		let modifiers = args[1];
-
-		if (!(day instanceof Date) && args[1] instanceof Date) {
-			day = args[1];
-			modifiers = args[2];
-		}
-
-		if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
-		if (modifiers?.disabled) return;
-
-		const iso = dateToIsoYmd(day);
-		if (!iso) return;
-
-		if (activeField === "from") {
-			safeOnDateRangeChange({ from: iso });
-			setActiveField("to");
-			focusActiveField("to");
-			return;
-		}
-
-		safeOnDateRangeChange({ to: iso });
-		setActiveField("to");
-		focusActiveField("to");
-	}
-
-	function handleClearFrom(e) {
-		e?.preventDefault?.();
-		e?.stopPropagation?.();
-		if (disabled) return;
-
-		safeOnDateRangeChange({ from: null });
-		setActiveField("from");
-		focusActiveField("from");
-	}
-
-	function handleClearTo(e) {
-		e?.preventDefault?.();
-		e?.stopPropagation?.();
-		if (disabled) return;
-
-		safeOnDateRangeChange({ to: null });
-		setActiveField("to");
-		focusActiveField("to");
-	}
-
-	function handleReset() {
-		if (disabled) return;
-
-		safeOnDateRangeChange({ from: null, to: null });
-		setActiveField("from");
-		setMonth(new Date());
-		focusActiveField("from");
-	}
-
-	function applyPreset(preset) {
-		if (disabled) return;
-		if (!preset?.from || !preset?.to) return;
-
-		safeOnDateRangeChange({ from: preset.from, to: preset.to });
-
-		const nextMonth =
-			isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
-		setMonth(nextMonth);
-
-		setActiveField("to");
-		focusActiveField("to");
-	}
-
-	const summary = getSummaryLabel(from, to);
-
 	const activeInputClass =
 		"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
 
-	const calendarKey = `${from || ""}|${to || ""}|${
-		isRangeInvalid ? "inv" : "ok"
-	}`;
-
 	return (
 		<div className={cn("grid gap-2", className)}>
 			<Label>Zeitraum</Label>
@@ -266,7 +80,6 @@ export default function SearchDateRangePicker({
 					</Button>
 				</PopoverTrigger>
 
-				{/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
 				<PopoverContent align="start" className="w-fit p-0">
 					<div className="w-fit space-y-4 p-4">
 						<div className="grid grid-cols-2 gap-4">
@@ -277,7 +90,7 @@ export default function SearchDateRangePicker({
 										ref={fromRef}
 										readOnly
 										disabled={disabled}
-										value={from ? formatIsoToDe(from) : ""}
+										value={fromDisplay}
 										placeholder="TT.MM.JJJJ"
 										className={cn(
 											"pr-8",
@@ -307,7 +120,7 @@ export default function SearchDateRangePicker({
 										ref={toRef}
 										readOnly
 										disabled={disabled}
-										value={to ? formatIsoToDe(to) : ""}
+										value={toDisplay}
 										placeholder="TT.MM.JJJJ"
 										className={cn(
 											"pr-8",
@@ -344,7 +157,6 @@ export default function SearchDateRangePicker({
 							onDayClick={handlePickDay}
 						/>
 
-						{/* Presets: two flex rows (no grid), always looks balanced */}
 						<div className="space-y-2">
 							<div className="text-sm text-muted-foreground">Schnellwahl</div>
 
@@ -389,7 +201,7 @@ export default function SearchDateRangePicker({
 							</div>
 						</div>
 
-						<div className="flex justify-end pt-1">
+						<div className="flex justify-end">
 							<Button
 								type="button"
 								variant="outline"