import { useSignal } from '@preact/signals'; import { useEffect } from 'preact/hooks'; import { Calendar, CalendarEvent } from '/lib/types.ts'; import { baseUrl, capitalizeWord, getWeeksForMonth } from '/lib/utils.ts'; // import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; // import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; // import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; // import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; interface MainCalendarProps { initialCalendars: Pick[]; initialCalendarEvents: CalendarEvent[]; view: 'day' | 'week' | 'month'; startDate: string; } export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) { const isAdding = useSignal(false); const isDeleting = useSignal(false); const isExporting = useSignal(false); const isImporting = useSignal(false); const isSearching = useSignal(false); const calendars = useSignal[]>(initialCalendars); const isViewOptionsDropdownOpen = useSignal(false); const isImportExportOptionsDropdownOpen = useSignal(false); const calendarEvents = useSignal(initialCalendarEvents); const searchTimeout = useSignal>(0); const openEvent = useSignal(null); const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); const dayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', }); const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }); const today = new Date().toISOString().substring(0, 10); function onClickAddEvent() { if (isAdding.value) { return; } const title = (prompt(`What's the **title** for the new event?`) || '').trim(); if (!title) { alert('A title is required for a new event!'); return; } const startDate = (prompt(`What's the **start date** for the new event (YYYY-MM-DD)?`, new Date().toISOString().substring(0, 10)) || '').trim(); const startHour = (prompt(`What's the **start hour** for the new event (HH:mm)?`, new Date().toISOString().substring(11, 5)) || '') .trim(); if (!startDate || !startHour) { alert('A start date and hour are required for a new event!'); return; } isAdding.value = true; // try { // const requestBody: AddRequestBody = { title, startDate, startHour }; // const response = await fetch(`/api/calendar/add-event`, { // method: 'POST', // body: JSON.stringify(requestBody), // }); // const result = await response.json() as AddResponseBody; // if (!result.success) { // throw new Error('Failed to add contact!'); // } // contacts.value = [...result.contacts]; // } catch (error) { // console.error(error); // } isAdding.value = false; } function toggleImportExportOptionsDropdown() { isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value; } function toggleViewOptionsDropdown() { isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value; } function onClickDeleteEvent(calendarEventId: string) { if (confirm('Are you sure you want to delete this event?')) { if (isDeleting.value) { return; } isDeleting.value = true; // try { // const requestBody: DeleteRequestBody = { calendarEventId, view, startDay }; // const response = await fetch(`/api/calendar/delete-event`, { // method: 'POST', // body: JSON.stringify(requestBody), // }); // const result = await response.json() as DeleteResponseBody; // if (!result.success) { // throw new Error('Failed to delete event!'); // } // contacts.value = [...result.contacts]; // } catch (error) { // console.error(error); // } isDeleting.value = false; openEvent.value = null; } } function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') { const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString() .substring(0, 10); const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString() .substring(0, 10); const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString() .substring(0, 10); const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString() .substring(0, 10); const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString() .substring(0, 10); const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString() .substring(0, 10); if (changeTo === 'today') { if (today === startDate) { return; } window.location.href = `/calendar?view=${view}&startDate=${today}`; return; } if (changeTo === 'previous') { let newStartDate = previousMonth; if (view === 'day') { newStartDate = previousDay; } else if (view === 'week') { newStartDate = previousWeek; } if (newStartDate === startDate) { return; } window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`; return; } let newStartDate = nextMonth; if (view === 'day') { newStartDate = nextDay; } else if (view === 'week') { newStartDate = nextWeek; } if (newStartDate === startDate) { return; } window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`; } function onClickChangeView(newView: MainCalendarProps['view']) { if (view === newView) { isViewOptionsDropdownOpen.value = false; return; } window.location.href = `/calendar?view=${newView}&startDate=${startDate}`; } function onClickImportICS() { isImportExportOptionsDropdownOpen.value = false; if (isImporting.value) { return; } const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.click(); fileInput.onchange = (event) => { const files = (event.target as HTMLInputElement)?.files!; const file = files[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = (fileRead) => { const importFileContents = fileRead.target?.result; if (!importFileContents || isImporting.value) { return; } isImporting.value = true; // try { // const partialContacts = parseVCardFromTextContents(importFileContents!.toString()); // const requestBody: ImportRequestBody = { partialContacts, page }; // const response = await fetch(`/api/calendar/import`, { // method: 'POST', // body: JSON.stringify(requestBody), // }); // const result = await response.json() as ImportResponseBody; // if (!result.success) { // throw new Error('Failed to import contact!'); // } // contacts.value = [...result.contacts]; // } catch (error) { // console.error(error); // } isImporting.value = false; }; reader.readAsText(file, 'UTF-8'); }; } function onClickExportICS() { isImportExportOptionsDropdownOpen.value = false; if (isExporting.value) { return; } isExporting.value = true; // const fileName = ['calendars-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics'] // .join(''); // try { // const requestBody: GetRequestBody = {}; // const response = await fetch(`/api/calendar/get`, { // method: 'POST', // body: JSON.stringify(requestBody), // }); // const result = await response.json() as GetResponseBody; // if (!result.success) { // throw new Error('Failed to get contact!'); // } // const exportContents = formatContactToVCard([...result.contacts]); // // Add content-type // const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join(''); // // Download the file // const data = vCardContent; // const link = document.createElement('a'); // link.setAttribute('href', data); // link.setAttribute('download', fileName); // link.click(); // link.remove(); // } catch (error) { // console.error(error); // } isExporting.value = false; } function searchEvents(searchTerms: string) { if (searchTimeout.value) { clearTimeout(searchTimeout.value); } searchTimeout.value = setTimeout(async () => { isSearching.value = true; // TODO: Remove this await new Promise((resolve) => setTimeout(() => resolve(true), 1000)); // try { // const requestBody: RequestBody = { search: searchTerms }; // const response = await fetch(`/api/calendar/search-events`, { // method: 'POST', // body: JSON.stringify(requestBody), // }); // const result = await response.json() as ResponseBody; // if (!result.success) { // throw new Error('Failed to search events!'); // } // } catch (error) { // console.error(error); // } isSearching.value = false; }, 500); } useEffect(() => { return () => { if (searchTimeout.value) { clearTimeout(searchTimeout.value); } }; }, []); const visibleCalendars = calendars.value.filter((calendar) => calendar.is_visible); const visibleCalendarEvents = calendarEvents.value; // TODO: Send in / consider user timezone const weeks = view === 'month' ? getWeeksForMonth(new Date(startDate)) : []; const hours: { date: Date; isCurrentHour: boolean }[] = view === 'day' ? Array.from({ length: 24 }).map((_, index) => { const hourNumber = index; const date = new Date(startDate); date.setHours(hourNumber); const shortIsoDate = date.toISOString().substring(0, 10); const isCurrentHour = shortIsoDate === today && new Date().getHours() === hourNumber; return { date, isCurrentHour, }; }) : []; // TODO: days with hours return ( <>
Manage calendars searchEvents(event.currentTarget.value)} /> {isSearching.value ? : null}

{view === 'day' ? (
{dayFormat.format(new Date(startDate))}
{hours.map((hour, hourIndex) => { const shortIsoDate = hour.date.toISOString().substring(0, 10); const startHourDate = new Date(shortIsoDate); startHourDate.setHours(hour.date.getHours()); const endHourDate = new Date(shortIsoDate); endHourDate.setHours(hour.date.getHours()); endHourDate.setMinutes(59); endHourDate.setSeconds(59); endHourDate.setMilliseconds(999); const isLastHour = hourIndex === 23; const hourEvents = calendarEvents.value.filter((calendarEvent) => { const eventStartDate = new Date(calendarEvent.start_date); const eventEndDate = new Date(calendarEvent.end_date); eventEndDate.setSeconds(eventEndDate.getSeconds() - 1); // Take one second back so events don't bleed into the next hour // Event starts and ends on this hour if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) { return true; } // Event starts before and ends after this hour if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) { return true; } // Event starts on and ends after this hour if ( eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate ) { return true; } // Event starts before and ends on this hour if ( eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate ) { return true; } return false; }); return (
{hourEvents.length > 0 ? (
    {hourEvents.map((hourEvent) => (
  1. calendar.id === hourEvent.calendar_id) ?.color || 'bg-gray-700' }`} onClick={() => openEvent.value = hourEvent} >

    {hourEvent.title}

  2. ))}
) : null}
); })}
) : null} {view === 'week' ? (
TODO: Build week view
) : null} {view === 'month' ? (
Mon
Tue
Wed
Thu
Fri
Sat
Sun
{weeks.map((week, weekIndex) => week.map((day, dayIndex) => { const shortIsoDate = day.date.toISOString().substring(0, 10); const startDayDate = new Date(shortIsoDate); const endDayDate = new Date(shortIsoDate); endDayDate.setHours(23); endDayDate.setMinutes(59); endDayDate.setSeconds(59); endDayDate.setMilliseconds(999); const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0; const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1; const isToday = today === shortIsoDate; const dayEvents = calendarEvents.value.filter((calendarEvent) => { const eventStartDate = new Date(calendarEvent.start_date); const eventEndDate = new Date(calendarEvent.end_date); // Event starts and ends on this day if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) { return true; } // Event starts before and ends after this day if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) { return true; } // Event starts on and ends after this day if ( eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate ) { return true; } // Event starts before and ends on this day if ( eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate ) { return true; } return false; }); return (
{dayEvents.length > 0 ? (
    {[...dayEvents].slice(0, 2).map((dayEvent) => (
  1. calendar.id === dayEvent.calendar_id) ?.color || 'bg-gray-700' }`} onClick={() => openEvent.value = dayEvent} >

    {dayEvent.title}

  2. ))} {dayEvents.length > 2 ? (
  3. ...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}

  4. ) : null}
) : null}
); }) )}
) : null} {isDeleting.value ? ( <> Deleting... ) : null} {isExporting.value ? ( <> Exporting... ) : null} {isImporting.value ? ( <> Importing... ) : null} {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null}
CalDAV URLs:{' '} {baseUrl}/dav/principals/{' '} {baseUrl}/dav/calendars/

{openEvent.value?.title || ''}

{openEvent.value?.start_date ? allDayEventDateFormat.format(new Date(openEvent.value.start_date)) : ''} {openEvent.value?.is_all_day ? All-day : ( {openEvent.value?.start_date ? hourFormat.format(new Date(openEvent.value.start_date)) : ''} -{' '} {openEvent.value?.end_date ? hourFormat.format(new Date(openEvent.value.end_date)) : ''} )}
{openEvent.value?.extra.description ? (

{openEvent.value.extra.description}

) : null}

TODO: location, calendar, recurrence, reminders

); }