소스 검색

RHL-025 feat(date-range): add centralized validation and hooks for date range selection

Code_Uwe 2 주 전
부모
커밋
9230bf1c20
3개의 변경된 파일328개의 추가작업 그리고 2개의 파일을 삭제
  1. 7 2
      lib/frontend/search/searchDateValidation.js
  2. 48 0
      lib/frontend/search/searchDateValidation.test.js
  3. 273 0
      lib/frontend/search/useSearchDateRangePicker.js

+ 7 - 2
lib/frontend/search/searchDateValidation.js

@@ -4,8 +4,13 @@ import {
 } from "@/lib/frontend/search/dateRange";
 
 /**
- * Zentralisierte Validation fuer Search-Datefilter (ISO YYYY-MM-DD).
- * Gibt ein kleines Fehlerobjekt zurueck (oder null), ohne UI-Abhaengigkeiten.
+ * Centralized validation for search date filters (ISO YYYY-MM-DD).
+ *
+ * Returns a small, UI-agnostic error descriptor (or null).
+ *
+ * Notes:
+ * - from === to is valid (single-day search)
+ * - from > to is invalid
  *
  * @param {string|null} from
  * @param {string|null} to

+ 48 - 0
lib/frontend/search/searchDateValidation.test.js

@@ -0,0 +1,48 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import { getSearchDateRangeValidation } from "./searchDateValidation.js";
+
+describe("lib/frontend/search/searchDateValidation", () => {
+	it("returns null when both dates are missing", () => {
+		expect(getSearchDateRangeValidation(null, null)).toBe(null);
+		expect(getSearchDateRangeValidation(undefined, undefined)).toBe(null);
+	});
+
+	it("returns VALIDATION_SEARCH_DATE for invalid from", () => {
+		expect(getSearchDateRangeValidation("2026/01/01", null)).toEqual({
+			code: "VALIDATION_SEARCH_DATE",
+			message: "Invalid from date",
+			details: { from: "2026/01/01" },
+		});
+	});
+
+	it("returns VALIDATION_SEARCH_DATE for invalid to", () => {
+		expect(getSearchDateRangeValidation(null, "2026-99-01")).toEqual({
+			code: "VALIDATION_SEARCH_DATE",
+			message: "Invalid to date",
+			details: { to: "2026-99-01" },
+		});
+	});
+
+	it("returns VALIDATION_SEARCH_RANGE when from > to", () => {
+		expect(getSearchDateRangeValidation("2026-01-10", "2026-01-09")).toEqual({
+			code: "VALIDATION_SEARCH_RANGE",
+			message: "Invalid date range",
+			details: { from: "2026-01-10", to: "2026-01-09" },
+		});
+	});
+
+	it("accepts from === to as a valid single-day range", () => {
+		expect(getSearchDateRangeValidation("2026-01-10", "2026-01-10")).toBe(null);
+	});
+
+	it("accepts open ranges", () => {
+		expect(getSearchDateRangeValidation("2026-01-10", null)).toBe(null);
+		expect(getSearchDateRangeValidation(null, "2026-01-10")).toBe(null);
+	});
+
+	it("accepts a valid range", () => {
+		expect(getSearchDateRangeValidation("2026-01-01", "2026-01-31")).toBe(null);
+	});
+});

+ 273 - 0
lib/frontend/search/useSearchDateRangePicker.js

@@ -0,0 +1,273 @@
+"use client";
+
+import React from "react";
+
+import { buildDatePresets } from "@/lib/frontend/search/datePresets";
+import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
+
+import {
+	formatIsoDateDe,
+	formatIsoDateRangeLabelDe,
+	toDateFromIsoDateYmd,
+	toIsoDateYmdFromDate,
+} from "@/lib/frontend/search/dateRange";
+
+const ACTIVE_FIELD = Object.freeze({
+	FROM: "from",
+	TO: "to",
+});
+
+function hasOwn(obj, key) {
+	return Object.prototype.hasOwnProperty.call(obj || {}, key);
+}
+
+function focusRef(ref) {
+	requestAnimationFrame(() => ref?.current?.focus?.());
+}
+
+function normalizeDayClickArgs(args) {
+	// react-day-picker handler signatures have differed across versions.
+	// We normalize both of these variants:
+	// - onDayClick(day, modifiers, event)
+	// - onDayClick(event, day, modifiers)
+	const a0 = args?.[0];
+	const a1 = args?.[1];
+	const a2 = args?.[2];
+
+	// Common: (day, modifiers)
+	if (a0 instanceof Date) return { day: a0, modifiers: a1 || null };
+
+	// Event-first: (event, day, modifiers)
+	if (a1 instanceof Date) return { day: a1, modifiers: a2 || null };
+
+	return { day: null, modifiers: null };
+}
+
+function buildCalendarState({ fromDate, toDate, isRangeInvalid }) {
+	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) {
+		// "to only" -> visually represent as a single-day range
+		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;
+
+	return { calendarSelected, calendarModifiers, calendarModifiersClassNames };
+}
+
+export function useSearchDateRangePicker({
+	from,
+	to,
+	onDateRangeChange,
+	isSubmitting,
+}) {
+	const disabled = Boolean(isSubmitting);
+
+	const [open, setOpen] = React.useState(false);
+	const [activeField, setActiveField] = React.useState(ACTIVE_FIELD.FROM);
+
+	const fromRef = React.useRef(null);
+	const toRef = React.useRef(null);
+
+	const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
+	const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
+
+	const validation = React.useMemo(() => {
+		return getSearchDateRangeValidation(from ?? null, to ?? null);
+	}, [from, to]);
+
+	const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
+
+	const now = React.useMemo(() => new Date(), []);
+	const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
+
+	const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
+	const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
+
+	const [month, setMonth] = React.useState(() => {
+		return fromDate || toDate || new Date();
+	});
+
+	React.useEffect(() => {
+		if (!open) return;
+
+		// Only run on open transition.
+		// We intentionally do not depend on from/to to avoid focus jumps while clicking.
+		if (from && !to) {
+			setActiveField(ACTIVE_FIELD.TO);
+			focusRef(toRef);
+		} else {
+			setActiveField(ACTIVE_FIELD.FROM);
+			focusRef(fromRef);
+		}
+
+		setMonth(fromDate || toDate || new Date());
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [open]);
+
+	const safeOnDateRangeChange = React.useCallback(
+		(patch) => {
+			if (typeof onDateRangeChange !== "function") return;
+
+			const hasFrom = hasOwn(patch, "from");
+			const hasTo = hasOwn(patch, "to");
+
+			onDateRangeChange({
+				from: hasFrom ? patch.from : from ?? null,
+				to: hasTo ? patch.to : to ?? null,
+			});
+		},
+		[onDateRangeChange, from, to]
+	);
+
+	const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
+		React.useMemo(() => {
+			return buildCalendarState({ fromDate, toDate, isRangeInvalid });
+		}, [fromDate, toDate, isRangeInvalid]);
+
+	const calendarKey = React.useMemo(() => {
+		return `${from || ""}|${to || ""}|${isRangeInvalid ? "inv" : "ok"}`;
+	}, [from, to, isRangeInvalid]);
+
+	const summary = React.useMemo(() => {
+		return formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum auswählen";
+	}, [from, to]);
+
+	const fromDisplay = React.useMemo(() => formatIsoDateDe(from) || "", [from]);
+	const toDisplay = React.useMemo(() => formatIsoDateDe(to) || "", [to]);
+
+	const handlePickDay = React.useCallback(
+		(...args) => {
+			if (disabled) return;
+
+			const { day, modifiers } = normalizeDayClickArgs(args);
+			if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
+			if (modifiers?.disabled) return;
+
+			const iso = toIsoDateYmdFromDate(day);
+			if (!iso) return;
+
+			if (activeField === ACTIVE_FIELD.FROM) {
+				safeOnDateRangeChange({ from: iso });
+				setActiveField(ACTIVE_FIELD.TO);
+				focusRef(toRef);
+				return;
+			}
+
+			safeOnDateRangeChange({ to: iso });
+			setActiveField(ACTIVE_FIELD.TO);
+			focusRef(toRef);
+		},
+		[disabled, activeField, safeOnDateRangeChange]
+	);
+
+	const handleClearFrom = React.useCallback(
+		(e) => {
+			e?.preventDefault?.();
+			e?.stopPropagation?.();
+			if (disabled) return;
+
+			safeOnDateRangeChange({ from: null });
+			setActiveField(ACTIVE_FIELD.FROM);
+			focusRef(fromRef);
+		},
+		[disabled, safeOnDateRangeChange]
+	);
+
+	const handleClearTo = React.useCallback(
+		(e) => {
+			e?.preventDefault?.();
+			e?.stopPropagation?.();
+			if (disabled) return;
+
+			safeOnDateRangeChange({ to: null });
+			setActiveField(ACTIVE_FIELD.TO);
+			focusRef(toRef);
+		},
+		[disabled, safeOnDateRangeChange]
+	);
+
+	const handleReset = React.useCallback(() => {
+		if (disabled) return;
+
+		safeOnDateRangeChange({ from: null, to: null });
+		setActiveField(ACTIVE_FIELD.FROM);
+		setMonth(new Date());
+		focusRef(fromRef);
+	}, [disabled, safeOnDateRangeChange]);
+
+	const applyPreset = React.useCallback(
+		(preset) => {
+			if (disabled) return;
+			if (!preset?.from || !preset?.to) return;
+
+			safeOnDateRangeChange({ from: preset.from, to: preset.to });
+
+			const nextMonth =
+				toDateFromIsoDateYmd(preset.from) ||
+				toDateFromIsoDateYmd(preset.to) ||
+				new Date();
+
+			setMonth(nextMonth);
+			setActiveField(ACTIVE_FIELD.TO);
+			focusRef(toRef);
+		},
+		[disabled, safeOnDateRangeChange]
+	);
+
+	return {
+		disabled,
+		open,
+		setOpen,
+		activeField,
+		setActiveField,
+		fromRef,
+		toRef,
+		month,
+		setMonth,
+		summary,
+		fromDisplay,
+		toDisplay,
+		presetsRow1,
+		presetsRow2,
+		calendarKey,
+		calendarSelected,
+		calendarModifiers,
+		calendarModifiersClassNames,
+		handlePickDay,
+		handleClearFrom,
+		handleClearTo,
+		handleReset,
+		applyPreset,
+	};
+}