From e6f67c24cbd1e808ff89525a7faf2176dc777e92 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Mon, 16 Feb 2026 20:34:29 +0100 Subject: [PATCH] feat: add JMAP Calendar integration with month/week/day/agenda views Full calendar support via JMAP Calendars (RFC 8984): - Event create/edit/delete with recurrence rules and reminders - Multi-day event spanning, column-based overlap layout - Locale-aware date formatting, first day of week and time format settings - Real-time updates via push notifications - ARIA accessibility, input validation, color sanitization - Keyboard shortcuts, mobile touch targets, focus trap - ICU pluralization for all 8 supported languages --- README.md | 11 + ROADMAP.md | 28 +- app/[locale]/calendar/page.tsx | 335 +++++++++++++ app/[locale]/settings/page.tsx | 6 +- components/calendar/calendar-agenda-view.tsx | 196 ++++++++ components/calendar/calendar-day-view.tsx | 235 +++++++++ components/calendar/calendar-month-view.tsx | 170 +++++++ .../calendar/calendar-sidebar-panel.tsx | 57 +++ components/calendar/calendar-toolbar.tsx | 109 +++++ components/calendar/calendar-week-view.tsx | 313 ++++++++++++ components/calendar/event-card.tsx | 94 ++++ components/calendar/event-modal.tsx | 444 ++++++++++++++++++ components/calendar/mini-calendar.tsx | 217 +++++++++ components/layout/sidebar.tsx | 17 + components/settings/calendar-settings.tsx | 55 +++ lib/jmap/client.ts | 367 +++++++++++++-- lib/jmap/types.ts | 220 +++++++++ locales/de/common.json | 113 ++++- locales/en/common.json | 113 ++++- locales/es/common.json | 113 ++++- locales/fr/common.json | 113 ++++- locales/it/common.json | 113 ++++- locales/ja/common.json | 113 ++++- locales/nl/common.json | 113 ++++- locales/pt/common.json | 113 ++++- stores/auth-store.ts | 11 + stores/calendar-store.ts | 159 +++++++ stores/email-store.ts | 13 +- 28 files changed, 3922 insertions(+), 39 deletions(-) create mode 100644 app/[locale]/calendar/page.tsx create mode 100644 components/calendar/calendar-agenda-view.tsx create mode 100644 components/calendar/calendar-day-view.tsx create mode 100644 components/calendar/calendar-month-view.tsx create mode 100644 components/calendar/calendar-sidebar-panel.tsx create mode 100644 components/calendar/calendar-toolbar.tsx create mode 100644 components/calendar/calendar-week-view.tsx create mode 100644 components/calendar/event-card.tsx create mode 100644 components/calendar/event-modal.tsx create mode 100644 components/calendar/mini-calendar.tsx create mode 100644 components/settings/calendar-settings.tsx create mode 100644 stores/calendar-store.ts diff --git a/README.md b/README.md index 913d005a..21987bd3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,17 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - vCard import/export (RFC 6350) with duplicate detection - Bulk operations (multi-select, delete, group add, export) +### Calendar +- JMAP Calendar integration (RFC 8984) with capability detection +- Month, week, day, and agenda views +- Event create, edit, and delete with recurrence rules and reminders +- Multi-day events spanning across days, column-based overlap layout +- Mini-calendar sidebar with calendar visibility toggles +- Locale-aware date formatting (respects user's language) +- Settings for first day of week, time format (12h/24h), and default view +- Real-time updates via JMAP push notifications +- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate) + ### Vacation Responder - JMAP VacationResponse management with date range scheduling - Dedicated settings tab with message configuration diff --git a/ROADMAP.md b/ROADMAP.md index 41d3ede0..2c58adc5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,6 +120,28 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Sidebar indicator when vacation auto-reply is active - [x] i18n support (all 8 languages) +### Calendar Integration +- [x] JMAP Calendar types (RFC 8984) and client methods +- [x] Calendar capability detection (urn:ietf:params:jmap:calendars) +- [x] Calendar store with Zustand (persist middleware) +- [x] Month, week, day, and agenda views +- [x] Event modal (create/edit/delete with recurrence, reminders) +- [x] Mini-calendar sidebar with calendar visibility toggles +- [x] Calendar settings (default view, week start, time format) +- [x] Multi-day event spanning across all covered days +- [x] Column-based overlap layout for concurrent events +- [x] Locale-aware date formatting via next-intl +- [x] First day of week and time format settings wired to views +- [x] Push notification handling for calendar state changes +- [x] Calendar page capability check (redirect if unsupported) +- [x] Error handling with toast feedback on event CRUD +- [x] Timezone auto-detection on event creation +- [x] Input validation, color sanitization, focus trap +- [x] ARIA grid roles and event card accessible labels +- [x] Mobile touch targets (44px minimum) +- [x] Calendar keyboard shortcuts (m/w/d/a views, t today, n new event) +- [x] i18n support with ICU pluralization (all 8 languages) + ### Email Display - [x] Proper email layout without horizontal scroll or clipping - [x] Blocked image container collapsing (no empty spaces in newsletters) @@ -148,7 +170,11 @@ This document tracks the development status and planned features for JMAP Webmai ### Advanced Features - [ ] Email filters and rules -- [ ] Calendar integration (JMAP Calendars) +- [ ] Calendar event drag-and-drop rescheduling +- [ ] Participant scheduling with iTIP invitations +- [ ] Free/busy queries (Principal/getAvailability) +- [ ] iCalendar import via CalendarEvent/parse +- [ ] Calendar sharing UI (JMAP Sharing RFC 9670) - [ ] Email templates - [ ] Email encryption (PGP/GPG) - [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx new file mode 100644 index 00000000..ca0b71b2 --- /dev/null +++ b/app/[locale]/calendar/page.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useRouter } from "@/i18n/navigation"; +import { useTranslations } from "next-intl"; +import { + startOfMonth, endOfMonth, startOfWeek, endOfWeek, + addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, + format, +} from "date-fns"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { toast } from "@/stores/toast-store"; +import { useIsMobile } from "@/hooks/use-media-query"; +import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; +import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; +import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; +import { CalendarDayView } from "@/components/calendar/calendar-day-view"; +import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; +import { MiniCalendar } from "@/components/calendar/mini-calendar"; +import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; +import { EventModal } from "@/components/calendar/event-modal"; +import type { CalendarEvent } from "@/lib/jmap/types"; + +export default function CalendarPage() { + const router = useRouter(); + const t = useTranslations("calendar"); + const isMobile = useIsMobile(); + const { client, isAuthenticated } = useAuthStore(); + const { + calendars, events, selectedDate, viewMode, selectedCalendarIds, + isLoading, isLoadingEvents, supportsCalendar, error, + fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, + setSelectedDate, setViewMode, toggleCalendarVisibility, + } = useCalendarStore(); + const { firstDayOfWeek, timeFormat } = useSettingsStore(); + + const [showEventModal, setShowEventModal] = useState(false); + const [editEvent, setEditEvent] = useState(null); + const [defaultModalDate, setDefaultModalDate] = useState(); + const [miniMonth, setMiniMonth] = useState(new Date()); + const hasFetched = useRef(false); + + useEffect(() => { + if (!isAuthenticated) { + router.push("/login"); + } else if (!supportsCalendar) { + router.push("/"); + } + }, [isAuthenticated, supportsCalendar, router]); + + useEffect(() => { + if (error) { + toast.error(error); + } + }, [error]); + + useEffect(() => { + if (client && !hasFetched.current) { + hasFetched.current = true; + fetchCalendars(client); + } + }, [client, fetchCalendars]); + + const dateRange = useMemo(() => { + const d = selectedDate; + switch (viewMode) { + case "month": { + const ms = startOfMonth(d); + const me = endOfMonth(d); + return { + start: format(startOfWeek(ms, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'00:00:00"), + end: format(endOfWeek(me, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'23:59:59"), + }; + } + case "week": { + const ws = startOfWeek(d, { weekStartsOn: firstDayOfWeek }); + return { + start: format(ws, "yyyy-MM-dd'T'00:00:00"), + end: format(addDays(ws, 6), "yyyy-MM-dd'T'23:59:59"), + }; + } + case "day": + return { + start: format(d, "yyyy-MM-dd'T'00:00:00"), + end: format(d, "yyyy-MM-dd'T'23:59:59"), + }; + case "agenda": + return { + start: format(d, "yyyy-MM-dd'T'00:00:00"), + end: format(addDays(d, 30), "yyyy-MM-dd'T'23:59:59"), + }; + } + }, [selectedDate, viewMode, firstDayOfWeek]); + + useEffect(() => { + if (client && calendars.length > 0) { + fetchEvents(client, dateRange.start, dateRange.end); + } + }, [client, calendars.length, selectedCalendarIds, dateRange, fetchEvents]); + + const navigatePrev = useCallback(() => { + let next: Date; + switch (viewMode) { + case "month": next = subMonths(selectedDate, 1); break; + case "week": next = subWeeks(selectedDate, 1); break; + case "day": next = subDays(selectedDate, 1); break; + case "agenda": next = subMonths(selectedDate, 1); break; + } + setSelectedDate(next); + setMiniMonth(next); + }, [viewMode, selectedDate, setSelectedDate]); + + const navigateNext = useCallback(() => { + let next: Date; + switch (viewMode) { + case "month": next = addMonths(selectedDate, 1); break; + case "week": next = addWeeks(selectedDate, 1); break; + case "day": next = addDays(selectedDate, 1); break; + case "agenda": next = addMonths(selectedDate, 1); break; + } + setSelectedDate(next); + setMiniMonth(next); + }, [viewMode, selectedDate, setSelectedDate]); + + const goToToday = useCallback(() => { + setSelectedDate(new Date()); + setMiniMonth(new Date()); + }, [setSelectedDate]); + + const handleSelectDate = useCallback((date: Date) => { + setSelectedDate(date); + setMiniMonth(date); + }, [setSelectedDate]); + + const handleMiniMonthChange = useCallback((date: Date) => { + setMiniMonth(date); + setSelectedDate(date); + }, [setSelectedDate]); + + const openCreateModal = useCallback((date?: Date) => { + setEditEvent(null); + setDefaultModalDate(date || selectedDate); + setShowEventModal(true); + }, [selectedDate]); + + const openEditModal = useCallback((event: CalendarEvent) => { + setEditEvent(event); + setDefaultModalDate(undefined); + setShowEventModal(true); + }, []); + + const handleSaveEvent = useCallback(async (data: Partial) => { + if (!client) return; + try { + if (editEvent) { + await updateEvent(client, editEvent.id, data); + toast.success(t("notifications.event_updated")); + } else { + const created = await createEvent(client, data); + if (!created) { + toast.error(t("notifications.event_error")); + return; + } + toast.success(t("notifications.event_created")); + } + setShowEventModal(false); + setEditEvent(null); + } catch { + toast.error(t("notifications.event_error")); + } + }, [client, editEvent, createEvent, updateEvent, t]); + + const handleDeleteEvent = useCallback(async (id: string) => { + if (!client) return; + try { + await deleteEvent(client, id); + toast.success(t("notifications.event_deleted")); + } catch { + toast.error(t("notifications.event_error")); + } + }, [client, deleteEvent, t]); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; + if (showEventModal) return; + + switch (e.key) { + case "ArrowLeft": e.preventDefault(); navigatePrev(); break; + case "ArrowRight": e.preventDefault(); navigateNext(); break; + case "t": goToToday(); break; + case "m": setViewMode("month"); break; + case "w": setViewMode("week"); break; + case "d": setViewMode("day"); break; + case "a": setViewMode("agenda"); break; + case "n": openCreateModal(); break; + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal]); + + const visibleEvents = useMemo(() => + events.filter((e) => { + const calIds = Object.keys(e.calendarIds); + return calIds.some((id) => selectedCalendarIds.includes(id)); + }), + [events, selectedCalendarIds] + ); + + if (!isAuthenticated || !supportsCalendar) return null; + + const renderView = () => { + if (isLoading && calendars.length === 0) { + return ( +
+

{t("status.loading_calendars")}

+
+ ); + } + + const viewContent = (() => { + switch (viewMode) { + case "month": + return ( + + ); + case "week": + return ( + + ); + case "day": + return ( + + ); + case "agenda": + return ( + + ); + } + })(); + + return ( +
+ {viewContent} + {isLoadingEvents && calendars.length > 0 && ( +
+
+
+ )} +
+ ); + }; + + return ( +
+ router.push("/")} + onPrev={navigatePrev} + onNext={navigateNext} + onToday={goToToday} + onViewModeChange={setViewMode} + onCreateEvent={() => openCreateModal()} + isMobile={isMobile} + /> + +
+ {!isMobile && ( +
+ + +
+ )} + + {renderView()} +
+ + {showEventModal && ( + { setShowEventModal(false); setEditEvent(null); }} + /> + )} +
+ ); +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index ded5df73..379e44af 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -10,11 +10,12 @@ import { EmailSettings } from '@/components/settings/email-settings'; import { AccountSettings } from '@/components/settings/account-settings'; import { IdentitySettings } from '@/components/settings/identity-settings'; import { VacationSettings } from '@/components/settings/vacation-settings'; +import { CalendarSettings } from '@/components/settings/calendar-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { useAuthStore } from '@/stores/auth-store'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'advanced'; export default function SettingsPage() { const router = useRouter(); @@ -23,6 +24,7 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState('appearance'); const supportsVacation = client?.supportsVacationResponse() ?? false; + const supportsCalendar = client?.supportsCalendars() ?? false; const tabs: { id: Tab; label: string }[] = [ { id: 'appearance', label: t('tabs.appearance') }, @@ -30,6 +32,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account') }, { id: 'identities', label: t('tabs.identities') }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []), + ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []), { id: 'advanced', label: t('tabs.advanced') }, ]; @@ -89,6 +92,7 @@ export default function SettingsPage() { {activeTab === 'account' && } {activeTab === 'identities' && } {activeTab === 'vacation' && } + {activeTab === 'calendar' && } {activeTab === 'advanced' && }
diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx new file mode 100644 index 00000000..d0aaacee --- /dev/null +++ b/components/calendar/calendar-agenda-view.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { format, parseISO, isToday, isTomorrow } from "date-fns"; +import { Calendar as CalendarIcon, MapPin } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { parseDuration, getEventColor } from "./event-card"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; + +interface CalendarAgendaViewProps { + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + onSelectEvent: (event: CalendarEvent) => void; + timeFormat?: "12h" | "24h"; +} + +interface DayGroup { + date: Date; + dateKey: string; + events: CalendarEvent[]; +} + +function getEventEndDate(event: CalendarEvent): Date { + const start = new Date(event.start); + if (!event.duration) return start; + const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0"); + const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0"); + const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0"); + const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0"); + const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000; + return new Date(start.getTime() + totalMs); +} + +export function CalendarAgendaView({ + events, + calendars, + onSelectEvent, + timeFormat = "24h", +}: CalendarAgendaViewProps) { + const t = useTranslations("calendar"); + const intlFormatter = useFormatter(); + + const calendarMap = useMemo(() => { + const map = new Map(); + calendars.forEach((c) => map.set(c.id, c)); + return map; + }, [calendars]); + + const grouped = useMemo(() => { + const sorted = [...events].sort((a, b) => + new Date(a.start).getTime() - new Date(b.start).getTime() + ); + + const groups: DayGroup[] = []; + const groupMap = new Map(); + + sorted.forEach((ev) => { + try { + const start = new Date(ev.start); + const end = getEventEndDate(ev); + const startKey = format(start, "yyyy-MM-dd"); + const endKey = format(end, "yyyy-MM-dd"); + + if (startKey === endKey || ev.showWithoutTime) { + let group = groupMap.get(startKey); + if (!group) { + group = { date: start, dateKey: startKey, events: [] }; + groupMap.set(startKey, group); + groups.push(group); + } + group.events.push(ev); + } else { + const cursor = new Date(start); + cursor.setHours(0, 0, 0, 0); + const endDay = new Date(end); + endDay.setHours(0, 0, 0, 0); + while (cursor <= endDay) { + const key = format(cursor, "yyyy-MM-dd"); + let group = groupMap.get(key); + if (!group) { + group = { date: new Date(cursor), dateKey: key, events: [] }; + groupMap.set(key, group); + groups.push(group); + } + group.events.push(ev); + cursor.setDate(cursor.getDate() + 1); + } + } + } catch { /* skip invalid dates */ } + }); + + groups.sort((a, b) => a.date.getTime() - b.date.getTime()); + return groups; + }, [events]); + + const formatDateHeader = (date: Date): string => { + if (isToday(date)) return t("events.today_header"); + if (isTomorrow(date)) return t("events.tomorrow_header"); + return intlFormatter.dateTime(date, { weekday: "long", month: "long", day: "numeric" }); + }; + + const formatTime = (date: Date): string => { + if (timeFormat === "12h") { + return intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true }); + } + return format(date, "HH:mm"); + }; + + if (grouped.length === 0) { + return ( +
+ +

{t("events.no_events")}

+
+ ); + } + + return ( +
+ {grouped.map((group) => ( +
+
+ + {formatDateHeader(group.date)} + + + {intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })} + +
+ +
+ {group.events.map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + const calendar = calendarMap.get(calId); + const color = getEventColor(ev, calendar); + const start = parseISO(ev.start); + const durMin = parseDuration(ev.duration); + const end = new Date(start.getTime() + durMin * 60000); + const locationName = ev.locations + ? Object.values(ev.locations)[0]?.name + : null; + + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx new file mode 100644 index 00000000..d46533bd --- /dev/null +++ b/components/calendar/calendar-day-view.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useMemo, useEffect, useRef, useState } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { format, isToday, parseISO } from "date-fns"; +import { cn } from "@/lib/utils"; +import { EventCard, parseDuration } from "./event-card"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; + +interface CalendarDayViewProps { + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + onSelectEvent: (event: CalendarEvent) => void; + onCreateAtTime: (date: Date) => void; + timeFormat?: "12h" | "24h"; +} + +const HOUR_HEIGHT = 64; +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function getEventEndDate(event: CalendarEvent): Date { + const start = new Date(event.start); + if (!event.duration) return start; + const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0"); + const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0"); + const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0"); + const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0"); + const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000; + return new Date(start.getTime() + totalMs); +} + +function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] { + const sorted = [...events].sort((a, b) => { + const diff = new Date(a.start).getTime() - new Date(b.start).getTime(); + if (diff !== 0) return diff; + return parseDuration(b.duration) - parseDuration(a.duration); + }); + + const columns: { event: CalendarEvent; end: number }[][] = []; + const result: { event: CalendarEvent; column: number; totalColumns: number }[] = []; + + for (const event of sorted) { + const start = parseISO(event.start); + const startMin = start.getHours() * 60 + start.getMinutes(); + const endMin = startMin + Math.max(15, parseDuration(event.duration)); + let placed = false; + for (let col = 0; col < columns.length; col++) { + if (columns[col].every(e => e.end <= startMin)) { + columns[col].push({ event, end: endMin }); + result.push({ event, column: col, totalColumns: 0 }); + placed = true; + break; + } + } + if (!placed) { + columns.push([{ event, end: endMin }]); + result.push({ event, column: columns.length - 1, totalColumns: 0 }); + } + } + + const total = columns.length; + result.forEach(r => r.totalColumns = total); + return result; +} + +export function CalendarDayView({ + selectedDate, + events, + calendars, + onSelectEvent, + onCreateAtTime, + timeFormat = "24h", +}: CalendarDayViewProps) { + const t = useTranslations("calendar"); + const intlFormatter = useFormatter(); + const scrollRef = useRef(null); + + const calendarMap = useMemo(() => { + const map = new Map(); + calendars.forEach((c) => map.set(c.id, c)); + return map; + }, [calendars]); + + const { timedEvents, allDayEvents } = useMemo(() => { + const timed: CalendarEvent[] = []; + const allDay: CalendarEvent[] = []; + events.forEach((ev) => { + try { + const start = new Date(ev.start); + const end = getEventEndDate(ev); + const startDay = new Date(start); startDay.setHours(0, 0, 0, 0); + const endDay = new Date(end); endDay.setHours(0, 0, 0, 0); + const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0); + + const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); + if (!spansThisDay) return; + + if (ev.showWithoutTime) allDay.push(ev); + else timed.push(ev); + } catch { /* skip invalid dates */ } + }); + return { timedEvents: timed, allDayEvents: allDay }; + }, [events, selectedDate]); + + useEffect(() => { + if (scrollRef.current) { + const now = new Date(); + scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); + } + }, []); + + const today = isToday(selectedDate); + const [nowMinutes, setNowMinutes] = useState(() => { + const now = new Date(); + return now.getHours() * 60 + now.getMinutes(); + }); + useEffect(() => { + const interval = setInterval(() => { + setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes()); + }, 60000); + return () => clearInterval(interval); + }, []); + + const formatHour = (h: number): string => { + if (timeFormat === "12h") { + const d = new Date(2000, 0, 1, h); + return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true }); + } + return format(new Date(2000, 0, 1, h), "HH:mm"); + }; + + const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]); + + return ( +
+
+

+ {intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })} +

+
+ + {allDayEvents.length > 0 && ( +
+
{t("events.all_day")}
+
+ {allDayEvents.map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + return ( + onSelectEvent(ev)} + /> + ); + })} +
+
+ )} + +
+
+
+ {HOURS.map((h) => ( +
+ {formatHour(h)} +
+ ))} +
+ +
+ {HOURS.map((h) => ( +
{ + const d = new Date(selectedDate); + d.setHours(h, 0, 0, 0); + onCreateAtTime(d); + }} + className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" + style={{ height: HOUR_HEIGHT }} + /> + ))} + + {layouted.map(({ event: ev, column, totalColumns }) => { + const start = parseISO(ev.start); + const startMin = start.getHours() * 60 + start.getMinutes(); + const durMin = Math.max(15, parseDuration(ev.duration)); + const top = (startMin / 60) * HOUR_HEIGHT; + const height = Math.max(24, (durMin / 60) * HOUR_HEIGHT); + const calId = Object.keys(ev.calendarIds)[0]; + const leftPct = (column / totalColumns) * 100; + const widthPct = (1 / totalColumns) * 100; + + return ( +
+ onSelectEvent(ev)} + /> +
+ ); + })} + + {today && ( +
+
+
+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx new file mode 100644 index 00000000..8dec2291 --- /dev/null +++ b/components/calendar/calendar-month-view.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { + startOfMonth, endOfMonth, startOfWeek, endOfWeek, + eachDayOfInterval, isSameDay, isSameMonth, isToday, format, +} from "date-fns"; +import { cn } from "@/lib/utils"; +import { EventCard } from "./event-card"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; + +interface CalendarMonthViewProps { + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + onSelectDate: (date: Date) => void; + onSelectEvent: (event: CalendarEvent) => void; + firstDayOfWeek?: number; +} + +function getEventEndDate(event: CalendarEvent): Date { + const start = new Date(event.start); + if (!event.duration) return start; + const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0"); + const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0"); + const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0"); + const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0"); + const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000; + return new Date(start.getTime() + totalMs); +} + +export function CalendarMonthView({ + selectedDate, + events, + calendars, + onSelectDate, + onSelectEvent, + firstDayOfWeek = 1, +}: CalendarMonthViewProps) { + const t = useTranslations("calendar"); + const intlFormatter = useFormatter(); + const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + + const days = useMemo(() => { + const monthStart = startOfMonth(selectedDate); + const monthEnd = endOfMonth(selectedDate); + const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); + const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart }); + return eachDayOfInterval({ start: gridStart, end: gridEnd }); + }, [selectedDate, weekStart]); + + const calendarMap = useMemo(() => { + const map = new Map(); + calendars.forEach((c) => map.set(c.id, c)); + return map; + }, [calendars]); + + const eventsByDate = useMemo(() => { + const map = new Map(); + events.forEach((e) => { + try { + const start = new Date(e.start); + const end = getEventEndDate(e); + const startDay = new Date(start); + startDay.setHours(0, 0, 0, 0); + const endDay = new Date(end); + endDay.setHours(0, 0, 0, 0); + + const cursor = new Date(startDay); + while (cursor <= endDay) { + const key = format(cursor, "yyyy-MM-dd"); + const arr = map.get(key) || []; + arr.push(e); + map.set(key, arr); + cursor.setDate(cursor.getDate() + 1); + } + } catch { /* skip invalid dates */ } + }); + return map; + }, [events]); + + const dayHeaders = firstDayOfWeek === 0 + ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const + : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; + + const weeks = useMemo(() => { + const result: Date[][] = []; + for (let i = 0; i < days.length; i += 7) { + result.push(days.slice(i, i + 7)); + } + return result; + }, [days]); + + return ( +
+
+ {dayHeaders.map((d) => ( +
+ {t(`days.${d}`)} +
+ ))} +
+ +
+ {weeks.map((week, wi) => ( +
+ {week.map((day) => { + const inMonth = isSameMonth(day, selectedDate); + const selected = isSameDay(day, selectedDate); + const today = isToday(day); + const key = format(day, "yyyy-MM-dd"); + const dayEvents = eventsByDate.get(key) || []; + const maxVisible = 3; + const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + + return ( +
onSelectDate(day)} + className={cn( + "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors", + !inMonth && "bg-muted/30", + "hover:bg-muted/50" + )} + > +
+ + {format(day, "d")} + +
+
+ {dayEvents.slice(0, maxVisible).map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + return ( + onSelectEvent(ev)} + /> + ); + })} + {dayEvents.length > maxVisible && ( +
+ {t("events.more", { count: dayEvents.length - maxVisible })} +
+ )} +
+
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx new file mode 100644 index 00000000..a32fa44d --- /dev/null +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; +import type { Calendar } from "@/lib/jmap/types"; + +interface CalendarSidebarPanelProps { + calendars: Calendar[]; + selectedCalendarIds: string[]; + onToggleVisibility: (id: string) => void; +} + +export function CalendarSidebarPanel({ + calendars, + selectedCalendarIds, + onToggleVisibility, +}: CalendarSidebarPanelProps) { + const t = useTranslations("calendar"); + + if (calendars.length === 0) return null; + + return ( +
+

+ {t("my_calendars")} +

+
+ {calendars.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + + return ( + + ); + })} +
+
+ ); +} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx new file mode 100644 index 00000000..a8c92406 --- /dev/null +++ b/components/calendar/calendar-toolbar.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useTranslations, useFormatter } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, ChevronLeft, ChevronRight, Plus } from "lucide-react"; +import { addDays, startOfWeek } from "date-fns"; +import { cn } from "@/lib/utils"; +import type { CalendarViewMode } from "@/stores/calendar-store"; + +interface CalendarToolbarProps { + selectedDate: Date; + viewMode: CalendarViewMode; + onNavigateBack: () => void; + onPrev: () => void; + onNext: () => void; + onToday: () => void; + onViewModeChange: (mode: CalendarViewMode) => void; + onCreateEvent: () => void; + isMobile?: boolean; + firstDayOfWeek?: number; +} + +export function CalendarToolbar({ + selectedDate, + viewMode, + onNavigateBack, + onPrev, + onNext, + onToday, + onViewModeChange, + onCreateEvent, + isMobile, + firstDayOfWeek = 1, +}: CalendarToolbarProps) { + const t = useTranslations("calendar"); + const formatter = useFormatter(); + const views: CalendarViewMode[] = ["month", "week", "day", "agenda"]; + + const getDateLabel = (): string => { + switch (viewMode) { + case "month": + return formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + case "week": { + const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); + const we = addDays(ws, 6); + const sameMonth = ws.getMonth() === we.getMonth(); + if (sameMonth) { + return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`; + } + return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`; + } + case "day": + return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + case "agenda": + return formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + } + }; + + return ( +
+ + +
+ + + {getDateLabel()} + + +
+ + + +
+ + {!isMobile && ( +
+ {views.map((v) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx new file mode 100644 index 00000000..23ce4ed7 --- /dev/null +++ b/components/calendar/calendar-week-view.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useMemo, useEffect, useRef, useState } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { + startOfWeek, addDays, format, isSameDay, isToday, parseISO, +} from "date-fns"; +import { cn } from "@/lib/utils"; +import { EventCard, parseDuration } from "./event-card"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; + +interface CalendarWeekViewProps { + selectedDate: Date; + events: CalendarEvent[]; + calendars: Calendar[]; + onSelectDate: (date: Date) => void; + onSelectEvent: (event: CalendarEvent) => void; + onCreateAtTime: (date: Date) => void; + firstDayOfWeek?: number; + timeFormat?: "12h" | "24h"; +} + +const HOUR_HEIGHT = 60; +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function getEventEndDate(event: CalendarEvent): Date { + const start = new Date(event.start); + if (!event.duration) return start; + const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0"); + const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0"); + const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0"); + const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0"); + const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000; + return new Date(start.getTime() + totalMs); +} + +function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] { + const sorted = [...events].sort((a, b) => { + const diff = new Date(a.start).getTime() - new Date(b.start).getTime(); + if (diff !== 0) return diff; + return parseDuration(b.duration) - parseDuration(a.duration); + }); + + const columns: { event: CalendarEvent; end: number }[][] = []; + const result: { event: CalendarEvent; column: number; totalColumns: number }[] = []; + + for (const event of sorted) { + const start = parseISO(event.start); + const startMin = start.getHours() * 60 + start.getMinutes(); + const endMin = startMin + Math.max(15, parseDuration(event.duration)); + let placed = false; + for (let col = 0; col < columns.length; col++) { + if (columns[col].every(e => e.end <= startMin)) { + columns[col].push({ event, end: endMin }); + result.push({ event, column: col, totalColumns: 0 }); + placed = true; + break; + } + } + if (!placed) { + columns.push([{ event, end: endMin }]); + result.push({ event, column: columns.length - 1, totalColumns: 0 }); + } + } + + const total = columns.length; + result.forEach(r => r.totalColumns = total); + return result; +} + +export function CalendarWeekView({ + selectedDate, + events, + calendars, + onSelectDate, + onSelectEvent, + onCreateAtTime, + firstDayOfWeek = 1, + timeFormat = "24h", +}: CalendarWeekViewProps) { + const t = useTranslations("calendar"); + const intlFormatter = useFormatter(); + const scrollRef = useRef(null); + const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + + const weekDays = useMemo(() => { + const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); + return Array.from({ length: 7 }, (_, i) => addDays(start, i)); + }, [selectedDate, weekStart]); + + const calendarMap = useMemo(() => { + const map = new Map(); + calendars.forEach((c) => map.set(c.id, c)); + return map; + }, [calendars]); + + const { timedEvents, allDayEvents } = useMemo(() => { + const timed: Map = new Map(); + const allDay: Map = new Map(); + + events.forEach((ev) => { + try { + const start = new Date(ev.start); + const end = getEventEndDate(ev); + const startDay = new Date(start); startDay.setHours(0, 0, 0, 0); + const endDay = new Date(end); endDay.setHours(0, 0, 0, 0); + + const cursor = new Date(startDay); + while (cursor <= endDay) { + const key = format(cursor, "yyyy-MM-dd"); + if (ev.showWithoutTime) { + const arr = allDay.get(key) || []; + arr.push(ev); + allDay.set(key, arr); + } else { + const arr = timed.get(key) || []; + arr.push(ev); + timed.set(key, arr); + } + cursor.setDate(cursor.getDate() + 1); + } + } catch { /* skip invalid dates */ } + }); + return { timedEvents: timed, allDayEvents: allDay }; + }, [events]); + + const hasAllDay = useMemo(() => { + return weekDays.some(day => { + const key = format(day, "yyyy-MM-dd"); + return (allDayEvents.get(key) || []).length > 0; + }); + }, [weekDays, allDayEvents]); + + useEffect(() => { + if (scrollRef.current) { + const now = new Date(); + const scrollTo = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); + scrollRef.current.scrollTop = scrollTo; + } + }, []); + + const [nowMinutes, setNowMinutes] = useState(() => { + const now = new Date(); + return now.getHours() * 60 + now.getMinutes(); + }); + useEffect(() => { + const interval = setInterval(() => { + setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes()); + }, 60000); + return () => clearInterval(interval); + }, []); + + const handleSlotClick = (day: Date, hour: number) => { + const d = new Date(day); + d.setHours(hour, 0, 0, 0); + onCreateAtTime(d); + }; + + const formatHour = (h: number): string => { + if (timeFormat === "12h") { + const d = new Date(2000, 0, 1, h); + return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true }); + } + return format(new Date(2000, 0, 1, h), "HH:mm"); + }; + + return ( +
+ {hasAllDay && ( +
+
+ {t("events.all_day")} +
+
+ {weekDays.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const dayAllDay = allDayEvents.get(key) || []; + return ( +
+ {dayAllDay.map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + return ( + onSelectEvent(ev)} + /> + ); + })} +
+ ); + })} +
+
+ )} + +
+
+
+ {weekDays.map((day) => { + const todayCol = isToday(day); + const selected = isSameDay(day, selectedDate); + const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + return ( + + ); + })} +
+
+ +
+
+
+ {HOURS.map((h) => ( +
+ {formatHour(h)} +
+ ))} +
+ +
+ {weekDays.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const dayEvents = timedEvents.get(key) || []; + const todayCol = isToday(day); + const layouted = layoutOverlappingEvents(dayEvents); + + return ( +
+ {HOURS.map((h) => ( +
handleSlotClick(day, h)} + className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" + style={{ height: HOUR_HEIGHT }} + /> + ))} + + {layouted.map(({ event: ev, column, totalColumns }) => { + const start = parseISO(ev.start); + const startMin = start.getHours() * 60 + start.getMinutes(); + const durMin = Math.max(15, parseDuration(ev.duration)); + const top = (startMin / 60) * HOUR_HEIGHT; + const height = Math.max(20, (durMin / 60) * HOUR_HEIGHT); + const calId = Object.keys(ev.calendarIds)[0]; + const leftPct = (column / totalColumns) * 100; + const widthPct = (1 / totalColumns) * 100; + + return ( +
+ onSelectEvent(ev)} + /> +
+ ); + })} + + {todayCol && ( +
+
+
+
+
+
+ )} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx new file mode 100644 index 00000000..18908780 --- /dev/null +++ b/components/calendar/event-card.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import { format, parseISO } from "date-fns"; + +interface EventCardProps { + event: CalendarEvent; + calendar?: Calendar; + variant: "chip" | "block"; + onClick?: () => void; + isSelected?: boolean; +} + +function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string { + if (!color) return fallback; + if (/^#[0-9a-fA-F]{3,8}$/.test(color)) return color; + if (/^(rgb|hsl)a?\([\d\s,.%/]+\)$/.test(color)) return color; + return fallback; +} + +function getEventColor(event: CalendarEvent, calendar?: Calendar): string { + return sanitizeColor(event.color, sanitizeColor(calendar?.color)); +} + +function parseDuration(duration: string): number { + let totalMinutes = 0; + const weekMatch = duration.match(/(\d+)W/); + const hourMatch = duration.match(/(\d+)H/); + const minMatch = duration.match(/(\d+)M/); + const dayMatch = duration.match(/(\d+)D/); + if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60; + if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60; + if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60; + if (minMatch) totalMinutes += parseInt(minMatch[1]); + return totalMinutes; +} + +export function EventCard({ event, calendar, variant, onClick, isSelected }: EventCardProps) { + const t = useTranslations("calendar"); + const color = getEventColor(event, calendar); + const startDate = parseISO(event.start); + + const calendarName = calendar?.name || ""; + const durationMinutes = parseDuration(event.duration); + const endTime = new Date(startDate.getTime() + durationMinutes * 60000); + const timeString = `${format(startDate, "HH:mm")} – ${format(endTime, "HH:mm")}`; + const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; + + if (variant === "chip") { + return ( + + ); + } + + return ( + + ); +} + +export { parseDuration, getEventColor, sanitizeColor }; diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx new file mode 100644 index 00000000..132b786a --- /dev/null +++ b/components/calendar/event-modal.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { X, Trash2 } from "lucide-react"; +import { format, parseISO, addHours } from "date-fns"; +import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; +import { parseDuration } from "./event-card"; + +interface EventModalProps { + event?: CalendarEvent | null; + calendars: Calendar[]; + defaultDate?: Date; + onSave: (data: Partial) => void; + onDelete?: (id: string) => void; + onClose: () => void; +} + +function formatDateInput(d: Date): string { + return format(d, "yyyy-MM-dd"); +} + +function formatTimeInput(d: Date): string { + return format(d, "HH:mm"); +} + +function buildDuration(startDate: Date, endDate: Date): string { + const diffMs = endDate.getTime() - startDate.getTime(); + const totalMinutes = Math.max(0, Math.floor(diffMs / 60000)); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + let dur = "P"; + if (days > 0) dur += `${days}D`; + dur += "T"; + if (hours > 0) dur += `${hours}H`; + if (minutes > 0) dur += `${minutes}M`; + if (dur === "PT") dur = "PT0M"; + return dur; +} + +type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly"; +type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440"; + +export function EventModal({ + event, + calendars, + defaultDate, + onSave, + onDelete, + onClose, +}: EventModalProps) { + const t = useTranslations("calendar"); + const isEdit = !!event; + + const getInitialStart = (): Date => { + if (event?.start) return parseISO(event.start); + if (defaultDate) { + const d = new Date(defaultDate); + const now = new Date(); + d.setHours(now.getHours() + 1, 0, 0, 0); + return d; + } + const d = new Date(); + d.setHours(d.getHours() + 1, 0, 0, 0); + return d; + }; + + const getInitialEnd = (): Date => { + if (event?.start) { + const s = parseISO(event.start); + const dur = parseDuration(event.duration); + return new Date(s.getTime() + dur * 60000); + } + return addHours(getInitialStart(), 1); + }; + + const [title, setTitle] = useState(event?.title || ""); + const [description, setDescription] = useState(event?.description || ""); + const [location, setLocation] = useState( + event?.locations ? Object.values(event.locations)[0]?.name || "" : "" + ); + const [startDate, setStartDate] = useState(formatDateInput(getInitialStart())); + const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); + const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); + const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); + const [allDay, setAllDay] = useState(event?.showWithoutTime || false); + const [calendarId, setCalendarId] = useState(() => { + if (event?.calendarIds) return Object.keys(event.calendarIds)[0] || calendars[0]?.id || ""; + const defaultCal = calendars.find(c => c.isDefault); + return defaultCal?.id || calendars[0]?.id || ""; + }); + const [recurrence, setRecurrence] = useState(() => { + if (!event?.recurrenceRules?.length) return "none"; + return event.recurrenceRules[0].frequency as RecurrenceOption; + }); + const [alert, setAlert] = useState(() => { + if (!event?.alerts) return "none"; + const first = Object.values(event.alerts)[0]; + if (!first) return "none"; + if (first.trigger["@type"] === "OffsetTrigger") { + const offset = first.trigger.offset; + if (offset === "PT0S") return "at_time"; + const minMatch = offset.match(/-?PT?(\d+)M$/); + if (minMatch) return minMatch[1] as AlertOption; + const hourMatch = offset.match(/-?PT?(\d+)H$/); + if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption; + const dayMatch = offset.match(/-?P(\d+)D/); + if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption; + } + return "none"; + }); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const handleSave = useCallback(() => { + const trimmedTitle = title.trim(); + if (!trimmedTitle) return; + if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; + + const startStr = allDay + ? `${startDate}T00:00:00` + : `${startDate}T${startTime}:00`; + const endStr = allDay + ? `${endDate}T23:59:59` + : `${endDate}T${endTime}:00`; + + const start = new Date(startStr); + let end = new Date(endStr); + + if (end <= start) { + end = new Date(start.getTime() + 3600000); + } + + const duration = allDay + ? `P${Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))}D` + : buildDuration(start, end); + + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const data: Partial = { + title: trimmedTitle, + description: description.trim(), + start: startStr, + duration, + timeZone, + showWithoutTime: allDay, + calendarIds: { [calendarId]: true }, + status: "confirmed", + freeBusyStatus: "busy", + privacy: "public", + }; + + if (location.trim()) { + data.locations = { + loc1: { + "@type": "Location", + name: location.trim(), + description: null, + locationTypes: null, + coordinates: null, + timeZone: null, + links: null, + relativeTo: null, + }, + }; + } + + if (recurrence !== "none") { + data.recurrenceRules = [{ + "@type": "RecurrenceRule", + frequency: recurrence, + interval: 1, + rscale: "gregorian", + skip: "omit", + firstDayOfWeek: "mo", + byDay: null, + byMonthDay: null, + byMonth: null, + byYearDay: null, + byWeekNo: null, + byHour: null, + byMinute: null, + bySecond: null, + bySetPosition: null, + count: null, + until: null, + }]; + } + + if (alert !== "none") { + const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`; + data.alerts = { + alert1: { + "@type": "Alert", + trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, + action: "display", + acknowledged: null, + relatedTo: null, + }, + }; + } + + onSave(data); + }, [title, description, location, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, onSave]); + + const modalRef = useRef(null); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose, handleSave]); + + useEffect(() => { + const modal = modalRef.current; + if (!modal) return; + const focusableEls = modal.querySelectorAll( + 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' + ); + const firstEl = focusableEls[0]; + const lastEl = focusableEls[focusableEls.length - 1]; + + const handler = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + if (e.shiftKey && document.activeElement === firstEl) { + e.preventDefault(); + lastEl?.focus(); + } else if (!e.shiftKey && document.activeElement === lastEl) { + e.preventDefault(); + firstEl?.focus(); + } + }; + modal.addEventListener("keydown", handler); + firstEl?.focus(); + return () => modal.removeEventListener("keydown", handler); + }, []); + + return ( +
+