From 65fc489b9cf4ace9dd1a672d23f209714a9e0496 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:21:44 +0100 Subject: [PATCH] feat: add pending event preview functionality to calendar views and event modal --- app/[locale]/calendar/page.tsx | 9 ++- components/calendar/calendar-day-view.tsx | 33 +++++++++- components/calendar/calendar-month-view.tsx | 73 +++++++++++++++------ components/calendar/calendar-week-view.tsx | 31 +++++++++ components/calendar/event-modal.tsx | 22 +++++++ 5 files changed, 146 insertions(+), 22 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 310b6dc1..45a5214b 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -25,7 +25,7 @@ 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 { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; @@ -82,6 +82,7 @@ export default function CalendarPage() { const [pendingScopeAction, setPendingScopeAction] = useState(null); const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); + const [pendingPreview, setPendingPreview] = useState(null); const hasFetched = useRef(false); // Sidebar resize state @@ -648,6 +649,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "week": @@ -664,6 +666,7 @@ export default function CalendarPage() { firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "day": @@ -678,6 +681,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} timeFormat={timeFormat} isMobile={isMobile} + pendingPreview={pendingPreview} /> ); case "agenda": @@ -808,7 +812,8 @@ export default function CalendarPage() { onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }} + onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index fbc9af96..09cd5372 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -2,13 +2,14 @@ import { useMemo, useEffect, useRef, useState } from "react"; import { useTranslations, useFormatter } from "next-intl"; -import { format, isToday, parseISO } from "date-fns"; +import { format, isSameDay, isToday, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; +import type { PendingEventPreview } from "./event-modal"; interface CalendarDayViewProps { selectedDate: Date; @@ -20,6 +21,7 @@ interface CalendarDayViewProps { onCreateAtTime: (date: Date, endDate?: Date) => void; timeFormat?: "12h" | "24h"; isMobile?: boolean; + pendingPreview?: PendingEventPreview | null; } const HOUR_HEIGHT = 64; @@ -35,6 +37,7 @@ export function CalendarDayView({ onCreateAtTime, timeFormat = "24h", isMobile, + pendingPreview, }: CalendarDayViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -274,6 +277,34 @@ export function CalendarDayView({ )} + + {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && ( + (() => { + const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); + const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + const durationMin = Math.max(15, endMin - startMin); + const cal = calendars.find(c => c.id === pendingPreview.calendarId); + const color = cal?.color || "hsl(var(--primary))"; + return ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 8974a917..be21714c 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; +import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; interface CalendarMonthViewProps { @@ -25,6 +26,7 @@ interface CalendarMonthViewProps { onCreateAtTime?: (date: Date) => void; firstDayOfWeek?: number; isMobile?: boolean; + pendingPreview?: PendingEventPreview | null; } export function CalendarMonthView({ @@ -38,6 +40,7 @@ export function CalendarMonthView({ onCreateAtTime, firstDayOfWeek = 1, isMobile, + pendingPreview, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -194,31 +197,63 @@ export function CalendarMonthView({ {isMobile ? ( - dayEvents.length > 0 && ( -
- {dayEvents.slice(0, 3).map((ev) => { - const calId = getPrimaryCalendarId(ev); - const cal = calId ? calendarMap.get(calId) : undefined; - const evColor = ev.color || cal?.color || "#3b82f6"; - return ( - - ); - })} - {dayEvents.length > 3 && ( - - )} -
- ) +
+ {dayEvents.slice(0, 3).map((ev) => { + const calId = getPrimaryCalendarId(ev); + const cal = calId ? calendarMap.get(calId) : undefined; + const evColor = ev.color || cal?.color || "#3b82f6"; + return ( + + ); + })} + {dayEvents.length > 3 && ( + + )} + {pendingPreview && isSameDay(pendingPreview.start, day) && ( + + )} +
) : null} ); })} + {!isMobile && pendingPreview && (() => { + const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start)); + if (previewDayIdx === -1) return null; + const previewRow = rowCount; + const cal = calendarMap.get(pendingPreview.calendarId); + const color = cal?.color || "#3b82f6"; + return ( +
+
+
+ {pendingPreview.title} +
+
+
+ ); + })()} + {!isMobile && segments.length > 0 && (
{segments.map((segment) => { diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index bd4543ec..33327f6e 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input"; import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; +import type { PendingEventPreview } from "./event-modal"; interface CalendarWeekViewProps { selectedDate: Date; @@ -24,6 +25,7 @@ interface CalendarWeekViewProps { firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; isMobile?: boolean; + pendingPreview?: PendingEventPreview | null; } const HOUR_HEIGHT = 60; @@ -41,6 +43,7 @@ export function CalendarWeekView({ firstDayOfWeek = 1, timeFormat = "24h", isMobile, + pendingPreview, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -366,6 +369,34 @@ export function CalendarWeekView({
)} + + {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( + (() => { + const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); + const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + const durationMin = Math.max(15, endMin - startMin); + const cal = calendars.find(c => c.id === pendingPreview.calendarId); + const color = cal?.color || "hsl(var(--primary))"; + return ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )} ); })} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 8f08c8d5..61961bbf 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -20,6 +20,14 @@ import { } from "@/lib/calendar-participants"; import { useSettingsStore } from "@/stores/settings-store"; +export interface PendingEventPreview { + start: Date; + end: Date; + title: string; + allDay: boolean; + calendarId: string; +} + interface EventModalProps { event?: CalendarEvent | null; calendars: Calendar[]; @@ -30,6 +38,7 @@ interface EventModalProps { onDuplicate?: (data: Partial) => void; onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void; onClose: () => void; + onPreviewChange?: (preview: PendingEventPreview | null) => void; currentUserEmails?: string[]; isMobile?: boolean; } @@ -105,6 +114,7 @@ export function EventModal({ onDuplicate, onRsvp, onClose, + onPreviewChange, currentUserEmails = [], isMobile = false, }: EventModalProps) { @@ -219,6 +229,18 @@ export function EventModal({ }); const [sendInvitations, setSendInvitations] = useState(true); + // Report live preview to parent for grid outline + useEffect(() => { + if (!onPreviewChange || isEdit) return; + const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`; + const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`; + const s = new Date(startStr); + const e = new Date(endStr); + if (isNaN(s.getTime()) || isNaN(e.getTime())) return; + onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId }); + return () => onPreviewChange(null); + }, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]); + const statusCounts = useMemo(() => { if (!event?.participants) return null; return getStatusCounts(event);