Jelajahi Sumber

refactor(date-range): integrate utility functions for date normalization and calendar state management

Code_Uwe 1 Minggu lalu
induk
melakukan
a5579ef83d
1 mengubah file dengan 33 tambahan dan 73 penghapusan
  1. 33 73
      lib/frontend/search/useSearchDateRangePicker.js

+ 33 - 73
lib/frontend/search/useSearchDateRangePicker.js

@@ -12,6 +12,11 @@ import {
 	toIsoDateYmdFromDate,
 	toIsoDateYmdFromDate,
 } from "@/lib/frontend/search/dateRange";
 } from "@/lib/frontend/search/dateRange";
 
 
+import {
+	normalizeDayClickArgs,
+	buildCalendarState,
+} from "@/lib/frontend/search/dateRangePickerUtils";
+
 const ACTIVE_FIELD = Object.freeze({
 const ACTIVE_FIELD = Object.freeze({
 	FROM: "from",
 	FROM: "from",
 	TO: "to",
 	TO: "to",
@@ -25,66 +30,6 @@ function focusRef(ref) {
 	requestAnimationFrame(() => ref?.current?.focus?.());
 	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({
 export function useSearchDateRangePicker({
 	from,
 	from,
 	to,
 	to,
@@ -108,8 +53,13 @@ export function useSearchDateRangePicker({
 
 
 	const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
 	const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
 
 
-	const now = React.useMemo(() => new Date(), []);
-	const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
+	// Presets should reflect the current day even if the app is open across midnight.
+	// We refresh presets on popover open, and also compute a fresh preset on click.
+	const [presetsNow, setPresetsNow] = React.useState(() => new Date());
+
+	const presets = React.useMemo(() => {
+		return buildDatePresets({ now: presetsNow });
+	}, [presetsNow]);
 
 
 	const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
 	const presetsRow1 = React.useMemo(() => presets.slice(0, 4), [presets]);
 	const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
 	const presetsRow2 = React.useMemo(() => presets.slice(4), [presets]);
@@ -121,6 +71,9 @@ export function useSearchDateRangePicker({
 	React.useEffect(() => {
 	React.useEffect(() => {
 		if (!open) return;
 		if (!open) return;
 
 
+		// Refresh preset reference "now" whenever the popover opens.
+		setPresetsNow(new Date());
+
 		// Only run on open transition.
 		// Only run on open transition.
 		// We intentionally do not depend on from/to to avoid focus jumps while clicking.
 		// We intentionally do not depend on from/to to avoid focus jumps while clicking.
 		if (from && !to) {
 		if (from && !to) {
@@ -143,11 +96,11 @@ export function useSearchDateRangePicker({
 			const hasTo = hasOwn(patch, "to");
 			const hasTo = hasOwn(patch, "to");
 
 
 			onDateRangeChange({
 			onDateRangeChange({
-				from: hasFrom ? patch.from : from ?? null,
-				to: hasTo ? patch.to : to ?? null,
+				from: hasFrom ? patch.from : (from ?? null),
+				to: hasTo ? patch.to : (to ?? null),
 			});
 			});
 		},
 		},
-		[onDateRangeChange, from, to]
+		[onDateRangeChange, from, to],
 	);
 	);
 
 
 	const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
 	const { calendarSelected, calendarModifiers, calendarModifiersClassNames } =
@@ -188,7 +141,7 @@ export function useSearchDateRangePicker({
 			setActiveField(ACTIVE_FIELD.TO);
 			setActiveField(ACTIVE_FIELD.TO);
 			focusRef(toRef);
 			focusRef(toRef);
 		},
 		},
-		[disabled, activeField, safeOnDateRangeChange]
+		[disabled, activeField, safeOnDateRangeChange],
 	);
 	);
 
 
 	const handleClearFrom = React.useCallback(
 	const handleClearFrom = React.useCallback(
@@ -201,7 +154,7 @@ export function useSearchDateRangePicker({
 			setActiveField(ACTIVE_FIELD.FROM);
 			setActiveField(ACTIVE_FIELD.FROM);
 			focusRef(fromRef);
 			focusRef(fromRef);
 		},
 		},
-		[disabled, safeOnDateRangeChange]
+		[disabled, safeOnDateRangeChange],
 	);
 	);
 
 
 	const handleClearTo = React.useCallback(
 	const handleClearTo = React.useCallback(
@@ -214,7 +167,7 @@ export function useSearchDateRangePicker({
 			setActiveField(ACTIVE_FIELD.TO);
 			setActiveField(ACTIVE_FIELD.TO);
 			focusRef(toRef);
 			focusRef(toRef);
 		},
 		},
-		[disabled, safeOnDateRangeChange]
+		[disabled, safeOnDateRangeChange],
 	);
 	);
 
 
 	const handleReset = React.useCallback(() => {
 	const handleReset = React.useCallback(() => {
@@ -229,20 +182,26 @@ export function useSearchDateRangePicker({
 	const applyPreset = React.useCallback(
 	const applyPreset = React.useCallback(
 		(preset) => {
 		(preset) => {
 			if (disabled) return;
 			if (disabled) return;
-			if (!preset?.from || !preset?.to) return;
+			if (!preset?.key) return;
+
+			// Defensive: compute a fresh preset at click-time (midnight edge-case).
+			const freshList = buildDatePresets({ now: new Date() });
+			const fresh = freshList.find((p) => p.key === preset.key) || preset;
+
+			if (!fresh?.from || !fresh?.to) return;
 
 
-			safeOnDateRangeChange({ from: preset.from, to: preset.to });
+			safeOnDateRangeChange({ from: fresh.from, to: fresh.to });
 
 
 			const nextMonth =
 			const nextMonth =
-				toDateFromIsoDateYmd(preset.from) ||
-				toDateFromIsoDateYmd(preset.to) ||
+				toDateFromIsoDateYmd(fresh.from) ||
+				toDateFromIsoDateYmd(fresh.to) ||
 				new Date();
 				new Date();
 
 
 			setMonth(nextMonth);
 			setMonth(nextMonth);
 			setActiveField(ACTIVE_FIELD.TO);
 			setActiveField(ACTIVE_FIELD.TO);
 			focusRef(toRef);
 			focusRef(toRef);
 		},
 		},
-		[disabled, safeOnDateRangeChange]
+		[disabled, safeOnDateRangeChange],
 	);
 	);
 
 
 	return {
 	return {
@@ -264,6 +223,7 @@ export function useSearchDateRangePicker({
 		calendarSelected,
 		calendarSelected,
 		calendarModifiers,
 		calendarModifiers,
 		calendarModifiersClassNames,
 		calendarModifiersClassNames,
+		isRangeInvalid,
 		handlePickDay,
 		handlePickDay,
 		handleClearFrom,
 		handleClearFrom,
 		handleClearTo,
 		handleClearTo,