(front) add Scheduler utilities and hooks

Add date formatting utilities and custom hooks for
Scheduler initialization and event handlers including
drag-drop, resize and click operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:34:21 +01:00
parent da8ab3140c
commit 63e91b5eb5
3 changed files with 913 additions and 0 deletions

View File

@@ -0,0 +1,499 @@
/**
* useSchedulerHandlers hook.
* Provides all event handlers for the Scheduler component.
*/
import { useCallback, MutableRefObject } from "react";
import { IcsEvent } from "ts-ics";
import { useAuth } from "@/features/auth/Auth";
import type {
EventCalendarEvent,
EventCalendarSelectInfo,
EventCalendarEventClickInfo,
EventCalendarEventDropInfo,
EventCalendarEventResizeInfo,
EventCalendarDateClickInfo,
} from "../../../services/dav/types/event-calendar";
import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../services/dav/EventCalendarAdapter";
import type { CalDavService } from "../../../services/dav/CalDavService";
import type { CalDavCalendar } from "../../../services/dav/types/caldav-service";
import type { EventModalState, RecurringDeleteOption } from "../types";
// Get browser timezone
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
type ECEvent = EventCalendarEvent;
// Calendar API interface (subset of what we need from the calendar instance)
interface CalendarApi {
updateEvent: (event: ECEvent) => void;
addEvent: (event: ECEvent) => void;
unselect: () => void;
refetchEvents: () => void;
}
interface UseSchedulerHandlersProps {
adapter: EventCalendarAdapter;
caldavService: CalDavService;
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
calendarRef: MutableRefObject<CalendarApi | null>;
calendarUrl: string;
modalState: EventModalState;
setModalState: React.Dispatch<React.SetStateAction<EventModalState>>;
}
export const useSchedulerHandlers = ({
adapter,
caldavService,
davCalendarsRef,
calendarRef,
calendarUrl,
modalState,
setModalState,
}: UseSchedulerHandlersProps) => {
const { user } = useAuth();
/**
* Handle event drop (drag & drop to new time/date).
* Uses adapter to correctly convert dates with timezone.
*/
const handleEventDrop = useCallback(
async (info: EventCalendarEventDropInfo) => {
const extProps = info.event.extendedProps as CalDavExtendedProps;
if (!extProps?.eventUrl) {
console.error("No eventUrl in extendedProps, cannot update");
info.revert();
return;
}
console.log('[EventDrop] Event:', info.event);
console.log('[EventDrop] allDay:', info.event.allDay);
console.log('[EventDrop] start:', info.event.start);
console.log('[EventDrop] end:', info.event.end);
try {
const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, {
defaultTimezone: extProps.timezone || BROWSER_TIMEZONE,
});
console.log('[EventDrop] IcsEvent start:', icsEvent.start);
console.log('[EventDrop] IcsEvent end:', icsEvent.end);
const result = await caldavService.updateEvent({
eventUrl: extProps.eventUrl,
event: icsEvent,
etag: extProps.etag,
});
if (!result.success) {
console.error("Failed to update event:", result.error);
info.revert();
return;
}
// Update etag for next update
if (result.data?.etag && calendarRef.current) {
const updatedEvent = {
...info.event,
extendedProps: { ...extProps, etag: result.data.etag },
};
calendarRef.current.updateEvent(updatedEvent as ECEvent);
}
} catch (error) {
console.error("Error updating event:", error);
info.revert();
}
},
[adapter, caldavService, calendarRef]
);
/**
* Handle event resize (change duration).
*/
const handleEventResize = useCallback(
async (info: EventCalendarEventResizeInfo) => {
const extProps = info.event.extendedProps as CalDavExtendedProps;
if (!extProps?.eventUrl) {
console.error("No eventUrl in extendedProps, cannot update");
info.revert();
return;
}
try {
const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, {
defaultTimezone: extProps.timezone || BROWSER_TIMEZONE,
});
const result = await caldavService.updateEvent({
eventUrl: extProps.eventUrl,
event: icsEvent,
etag: extProps.etag,
});
if (!result.success) {
console.error("Failed to resize event:", result.error);
info.revert();
return;
}
// Update etag
if (result.data?.etag && calendarRef.current) {
const updatedEvent = {
...info.event,
extendedProps: {
...extProps,
etag: result.data.etag,
},
};
calendarRef.current.updateEvent(updatedEvent as ECEvent);
}
} catch (error) {
console.error("Error resizing event:", error);
info.revert();
}
},
[adapter, caldavService, calendarRef]
);
/**
* Handle event click - open edit modal.
*/
const handleEventClick = useCallback(
(info: EventCalendarEventClickInfo) => {
const extProps = info.event.extendedProps as CalDavExtendedProps;
// Convert EventCalendar event back to IcsEvent for editing
const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, {
defaultTimezone: extProps?.timezone || BROWSER_TIMEZONE,
});
setModalState({
isOpen: true,
mode: "edit",
event: icsEvent,
calendarUrl: extProps?.calendarUrl || calendarUrl,
eventUrl: extProps?.eventUrl,
etag: extProps?.etag,
});
},
[adapter, calendarUrl, setModalState]
);
/**
* Handle date click - open create modal for single time slot.
*/
const handleDateClick = useCallback(
(info: EventCalendarDateClickInfo) => {
const start = info.date;
const end = new Date(start.getTime() + 60 * 60 * 1000); // 1 hour default
const newEvent: Partial<IcsEvent> = {
uid: crypto.randomUUID(),
stamp: { date: new Date() },
start: {
date: start,
type: info.allDay ? "DATE" : "DATE-TIME",
// Don't set 'local' here - the date is already in browser local time
// Setting 'local' would make EventModal think it's "fake UTC"
},
end: {
date: end,
type: info.allDay ? "DATE" : "DATE-TIME",
// Don't set 'local' here - the date is already in browser local time
},
};
setModalState({
isOpen: true,
mode: "create",
event: newEvent,
calendarUrl: calendarUrl,
});
},
[calendarUrl, setModalState]
);
/**
* Handle select - open create modal for selected time range.
*/
const handleSelect = useCallback(
(info: EventCalendarSelectInfo) => {
const newEvent: Partial<IcsEvent> = {
uid: crypto.randomUUID(),
stamp: { date: new Date() },
start: {
date: info.start,
type: info.allDay ? "DATE" : "DATE-TIME",
// Don't set 'local' here - the date is already in browser local time
},
end: {
date: info.end,
type: info.allDay ? "DATE" : "DATE-TIME",
// Don't set 'local' here - the date is already in browser local time
},
};
setModalState({
isOpen: true,
mode: "create",
event: newEvent,
calendarUrl: calendarUrl,
});
// Clear the selection
calendarRef.current?.unselect();
},
[calendarUrl, calendarRef, setModalState]
);
/**
* Handle modal save (create or update event).
*/
const handleModalSave = useCallback(
async (event: IcsEvent, targetCalendarUrl: string) => {
if (modalState.mode === "create") {
// Create new event
const result = await caldavService.createEvent({
calendarUrl: targetCalendarUrl,
event,
});
if (!result.success) {
throw new Error(result.error || "Failed to create event");
}
// Add to calendar UI
if (calendarRef.current && result.data) {
// For recurring events, refetch all events to ensure proper timezone conversion
if (event.recurrenceRule) {
calendarRef.current.refetchEvents();
} else {
// Non-recurring event, add normally
const calendarColors = adapter.createCalendarColorMap(
davCalendarsRef.current
);
const ecEvents = adapter.toEventCalendarEvents([result.data], {
calendarColors,
});
if (ecEvents.length > 0) {
calendarRef.current.addEvent(ecEvents[0] as ECEvent);
}
}
}
} else {
// Update existing event
if (!modalState.eventUrl) {
throw new Error("No event URL for update");
}
const result = await caldavService.updateEvent({
eventUrl: modalState.eventUrl,
event,
etag: modalState.etag,
});
if (!result.success) {
throw new Error(result.error || "Failed to update event");
}
// Update in calendar UI
if (calendarRef.current && result.data) {
// If this is a recurring event, refetch all events to update all instances
if (event.recurrenceRule) {
calendarRef.current.refetchEvents();
} else {
// Non-recurring event, update normally
const calendarColors = adapter.createCalendarColorMap(
davCalendarsRef.current
);
const ecEvents = adapter.toEventCalendarEvents([result.data], {
calendarColors,
});
if (ecEvents.length > 0) {
calendarRef.current.updateEvent(ecEvents[0] as ECEvent);
}
}
}
}
},
[adapter, caldavService, calendarRef, davCalendarsRef, modalState]
);
/**
* Handle modal delete.
*/
const handleModalDelete = useCallback(
async (
event: IcsEvent,
_targetCalendarUrl: string,
option?: RecurringDeleteOption
) => {
if (!modalState.eventUrl) {
throw new Error("No event URL for delete");
}
// If this is a recurring event and we have an option
if (event.recurrenceRule && option && option !== 'all') {
// Get the occurrence date
// Prefer recurrenceId if available (it identifies this specific occurrence)
// Otherwise fall back to start date
let occurrenceDate: Date;
if (event.recurrenceId?.value?.date) {
occurrenceDate = event.recurrenceId.value.date;
} else if (event.start.date instanceof Date) {
occurrenceDate = event.start.date;
} else {
occurrenceDate = new Date(event.start.date);
}
if (option === 'this') {
// Option 1: Delete only this occurrence - Add EXDATE
const addExdateResult = await caldavService.addExdateToEvent(
modalState.eventUrl,
occurrenceDate,
modalState.etag
);
if (!addExdateResult.success) {
throw new Error(addExdateResult.error || "Failed to add EXDATE");
}
// Refetch events to update UI
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
} else if (option === 'future') {
// Option 2: Delete this and future occurrences - Modify UNTIL
const fetchResult = await caldavService.fetchEvent(modalState.eventUrl);
if (!fetchResult.success || !fetchResult.data) {
throw new Error("Failed to fetch source event");
}
const sourceIcsEvents = fetchResult.data.data.events ?? [];
const sourceEvent = sourceIcsEvents.find(
(e) => e.uid === event.uid && !e.recurrenceId
);
if (!sourceEvent || !sourceEvent.recurrenceRule) {
throw new Error("Source event or recurrence rule not found");
}
// Set UNTIL to the day before this occurrence
const untilDate = new Date(occurrenceDate);
untilDate.setDate(untilDate.getDate() - 1);
untilDate.setHours(23, 59, 59, 999);
const updatedRecurrenceRule = {
...sourceEvent.recurrenceRule,
until: {
type: 'DATE-TIME' as const,
date: untilDate,
},
count: undefined, // Remove count if present
};
const updatedEvent: IcsEvent = {
...sourceEvent,
recurrenceRule: updatedRecurrenceRule,
};
const updateResult = await caldavService.updateEvent({
eventUrl: modalState.eventUrl,
event: updatedEvent,
etag: modalState.etag,
});
if (!updateResult.success) {
throw new Error(updateResult.error || "Failed to update event");
}
// Refetch events to update UI
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
}
} else {
// Option 3: Delete all occurrences OR non-recurring event
const result = await caldavService.deleteEvent(modalState.eventUrl);
if (!result.success) {
throw new Error(result.error || "Failed to delete event");
}
// Refetch events to update UI
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
}
},
[caldavService, modalState, calendarRef]
);
/**
* Handle modal close.
*/
const handleModalClose = useCallback(() => {
setModalState((prev) => ({ ...prev, isOpen: false }));
}, [setModalState]);
/**
* Handle respond to invitation.
*/
const handleRespondToInvitation = useCallback(
async (event: IcsEvent, status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED') => {
if (!user?.email) {
console.error('No user email available');
return;
}
if (!modalState.eventUrl) {
console.error('No event URL available');
return;
}
try {
const result = await caldavService.respondToMeeting(
modalState.eventUrl,
event,
user.email,
status,
modalState.etag
);
if (!result.success) {
throw new Error(result.error || 'Failed to respond to invitation');
}
console.log('✉️ Response sent successfully:', status);
// Refetch events to update the UI with the new status
if (calendarRef.current) {
calendarRef.current.refetchEvents();
}
// Close the modal
setModalState((prev) => ({ ...prev, isOpen: false }));
} catch (error) {
console.error('Error responding to invitation:', error);
throw error;
}
},
[caldavService, user, calendarRef, modalState.eventUrl, modalState.etag, setModalState]
);
return {
handleEventDrop,
handleEventResize,
handleEventClick,
handleDateClick,
handleSelect,
handleModalSave,
handleModalDelete,
handleModalClose,
handleRespondToInvitation,
};
};

View File

@@ -0,0 +1,358 @@
/**
* useSchedulerInit hook.
* Handles calendar initialization and configuration.
*/
import { useEffect, useRef, MutableRefObject } from "react";
import { useTranslation } from "react-i18next";
import {
createCalendar,
TimeGrid,
DayGrid,
List,
Interaction,
} from "@event-calendar/core";
import { useCalendarLocale } from "../../../hooks/useCalendarLocale";
import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../services/dav/EventCalendarAdapter";
import type { CalDavService } from "../../../services/dav/CalDavService";
import type { CalDavCalendar } from "../../../services/dav/types/caldav-service";
import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar";
type ECEvent = EventCalendarEvent;
// Calendar API interface
interface CalendarApi {
updateEvent: (event: ECEvent) => void;
addEvent: (event: ECEvent) => void;
unselect: () => void;
refetchEvents: () => void;
$destroy?: () => void;
}
interface UseSchedulerInitProps {
containerRef: MutableRefObject<HTMLDivElement | null>;
calendarRef: MutableRefObject<CalendarApi | null>;
isConnected: boolean;
calendarUrl: string;
caldavService: CalDavService;
adapter: EventCalendarAdapter;
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
setCurrentDate: (date: Date) => void;
handleEventClick: (info: unknown) => void;
handleEventDrop: (info: unknown) => void;
handleEventResize: (info: unknown) => void;
handleDateClick: (info: unknown) => void;
handleSelect: (info: unknown) => void;
}
export const useSchedulerInit = ({
containerRef,
calendarRef,
isConnected,
calendarUrl,
caldavService,
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
setCurrentDate,
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
}: UseSchedulerInitProps) => {
const { t, i18n } = useTranslation();
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
useEffect(() => {
if (!containerRef.current || calendarRef.current || !isConnected) return;
const ec = createCalendar(
containerRef.current,
[TimeGrid, DayGrid, List, Interaction],
{
// View configuration
view: "timeGridWeek",
headerToolbar: {
start: "prev,next today",
center: "title",
end: "dayGridMonth,timeGridWeek,timeGridDay,listWeek",
},
// Button text translations
buttonText: {
today: t('calendar.views.today'),
dayGridMonth: t('calendar.views.month'),
timeGridWeek: t('calendar.views.week'),
timeGridDay: t('calendar.views.day'),
listWeek: t('calendar.views.listWeek'),
},
// Locale & time settings
locale: calendarLocale,
firstDay: firstDayOfWeek,
slotDuration: "00:30",
scrollTime: "08:00",
displayEventEnd: true,
// Interactive features
editable: true,
selectable: true,
dragScroll: true,
eventStartEditable: true,
eventDurationEditable: true,
selectMinDistance: 5,
eventDragMinDistance: 5,
selectBackgroundColor: '#ffcdd2', // Light red color for selection
// Event handlers - ALL INTERACTIONS
// Cast handlers to bypass library type differences (DomEvent vs MouseEvent)
eventClick: handleEventClick as (info: unknown) => void,
eventDrop: handleEventDrop as (info: unknown) => void,
eventResize: handleEventResize as (info: unknown) => void,
dateClick: handleDateClick as (info: unknown) => void,
select: handleSelect as (info: unknown) => void,
// Sync current date with MiniCalendar when navigating
datesSet: (info: { start: Date; end: Date }) => {
// Use the middle of the visible range as the "current" date
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
},
// Event display
dayMaxEvents: true,
nowIndicator: true,
// Date formatting (locale-aware)
dayHeaderFormat: formatDayHeader,
eventFilter: (info: { event: ECEvent; view: unknown }) => {
// Filter events based on visible calendars using the ref
const extProps = info.event.extendedProps as CalDavExtendedProps | undefined;
const eventCalendarUrl = extProps?.calendarUrl;
if (!eventCalendarUrl) return true;
return visibleCalendarUrlsRef.current.has(eventCalendarUrl);
},
// Event sources - fetch from ALL CalDAV calendars (filtering done by eventFilter)
eventSources: [
{
events: async (fetchInfo: EventCalendarFetchInfo) => {
const calendars = davCalendarsRef.current;
if (calendars.length === 0) return [];
try {
// Fetch events from ALL calendars in parallel
const allEventsPromises = calendars.map(async (calendar) => {
// Fetch source events (with recurrence rules) without expansion
const sourceEventsResult = await caldavService.fetchEvents(
calendar.url,
{
timeRange: {
start: fetchInfo.start,
end: fetchInfo.end,
},
expand: false,
}
);
// Fetch expanded instances
const expandedEventsResult = await caldavService.fetchEvents(
calendar.url,
{
timeRange: {
start: fetchInfo.start,
end: fetchInfo.end,
},
expand: true,
}
);
if (!expandedEventsResult.success || !expandedEventsResult.data) {
console.error(
`Failed to fetch events from ${calendar.url}:`,
expandedEventsResult.error
);
return [];
}
// Build a map of source recurrence rules by UID
const sourceRulesByUid = new Map<string, unknown>();
if (sourceEventsResult.success && sourceEventsResult.data) {
for (const sourceEvent of sourceEventsResult.data) {
const icsEvents = sourceEvent.data.events ?? [];
for (const icsEvent of icsEvents) {
if (icsEvent.recurrenceRule && !icsEvent.recurrenceId) {
sourceRulesByUid.set(icsEvent.uid, icsEvent.recurrenceRule);
}
}
}
}
// Enrich expanded events with recurrence rules from sources
const enrichedExpandedData = expandedEventsResult.data.map(
(event) => {
const enrichedEvents = event.data.events?.map((icsEvent) => {
// If this is an instance without recurrenceRule, add it from source
if (icsEvent.recurrenceId && !icsEvent.recurrenceRule) {
const sourceRule = sourceRulesByUid.get(icsEvent.uid);
if (sourceRule) {
return { ...icsEvent, recurrenceRule: sourceRule };
}
}
return icsEvent;
});
return {
...event,
data: {
...event.data,
events: enrichedEvents,
},
};
}
);
const calendarColors = adapter.createCalendarColorMap(calendars);
// Type assertion needed due to the enrichment process
return adapter.toEventCalendarEvents(
enrichedExpandedData as typeof expandedEventsResult.data,
{ calendarColors }
);
});
const allEventsArrays = await Promise.all(allEventsPromises);
return allEventsArrays.flat() as ECEvent[];
} catch (error) {
console.error("Error fetching events:", error);
return [];
}
},
},
],
// Loading state is handled internally by the calendar
}
);
calendarRef.current = ec as unknown as CalendarApi;
return () => {
if (calendarRef.current) {
// @event-calendar/core is Svelte-based and uses $destroy
const calendar = calendarRef.current as CalendarApi;
if (typeof calendar.$destroy === 'function') {
calendar.$destroy();
}
calendarRef.current = null;
}
// Also clear the container
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
};
}, [
isConnected,
calendarUrl,
calendarLocale,
firstDayOfWeek,
formatDayHeader,
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
caldavService,
adapter,
setCurrentDate,
t,
i18n.language,
containerRef,
calendarRef,
visibleCalendarUrlsRef,
davCalendarsRef,
]);
};
/**
* Hook to check scheduling capabilities on mount.
*/
export const useSchedulingCapabilitiesCheck = (
isConnected: boolean,
caldavService: CalDavService
) => {
const hasCheckedRef = useRef(false);
useEffect(() => {
if (!isConnected || hasCheckedRef.current) return;
hasCheckedRef.current = true;
const checkSchedulingCapabilities = async () => {
const result = await caldavService.getSchedulingCapabilities();
if (result.success && result.data) {
console.group('📅 CalDAV Scheduling Capabilities');
console.log(
'Scheduling Support:',
result.data.hasSchedulingSupport ? '✅ Enabled' : '❌ Disabled'
);
console.log(
'Schedule Outbox URL:',
result.data.scheduleOutboxUrl || '❌ Not found'
);
console.log(
'Schedule Inbox URL:',
result.data.scheduleInboxUrl || '❌ Not found'
);
console.log(
'Calendar User Addresses:',
result.data.calendarUserAddressSet.length > 0
? result.data.calendarUserAddressSet
: '❌ None'
);
console.log('');
console.log('Raw server response:', result.data.rawResponse);
if (result.data.hasSchedulingSupport) {
console.log('');
console.log('✉️ Email Notifications Status:');
console.log(' The server supports CalDAV scheduling (RFC 6638).');
console.log(
' However, this does NOT guarantee email notifications will be sent.'
);
console.log(
' Email sending requires the IMip plugin to be configured on the server.'
);
console.log(
' Contact your server administrator to verify IMip plugin configuration.'
);
} else {
console.warn('');
console.warn(
'⚠️ CalDAV scheduling properties not found on this server.'
);
console.warn(' This could mean:');
console.warn(
' 1. The scheduling plugin is not enabled in Sabre/DAV configuration'
);
console.warn(
' 2. The properties are located elsewhere (check raw response above)'
);
console.warn(
' 3. The server does not support CalDAV scheduling (RFC 6638)'
);
}
console.groupEnd();
} else {
console.error('Failed to check scheduling capabilities:', result.error);
}
};
checkSchedulingCapabilities();
}, [isConnected, caldavService]);
};

View File

@@ -0,0 +1,56 @@
/**
* Date formatting utilities for the Scheduler components.
* Handles conversion between Date objects and HTML input formats.
*/
/**
* Pad a number to 2 digits.
*/
const pad = (n: number): string => n.toString().padStart(2, "0");
/**
* Format Date to input datetime-local format (YYYY-MM-DDTHH:mm).
*
* @param date - The date to format
* @param isFakeUtc - If true, use getUTC* methods (for dates from adapter
* that store local time as UTC values)
*/
export const formatDateTimeLocal = (date: Date, isFakeUtc = false): string => {
if (isFakeUtc) {
// For "fake UTC" dates, getUTC* methods return the intended local time
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
/**
* Parse datetime-local input value to Date.
*
* @param value - String in YYYY-MM-DDTHH:mm format
*/
export const parseDateTimeLocal = (value: string): Date => {
return new Date(value);
};
/**
* Format Date to input date format (YYYY-MM-DD).
*
* @param date - The date to format
* @param isFakeUtc - If true, use getUTC* methods (for dates from adapter)
*/
export const formatDateLocal = (date: Date, isFakeUtc = false): string => {
if (isFakeUtc) {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
/**
* Parse date input value to Date (at midnight local time).
*
* @param value - String in YYYY-MM-DD format
*/
export const parseDateLocal = (value: string): Date => {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
};