From cde1d61d021fa6045ff94cbf9e7d5a74f8a18177 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:57:56 +0100 Subject: [PATCH] fix: correct all-day multi-day event rendering --- components/calendar/calendar-agenda-view.tsx | 35 +--- components/calendar/calendar-day-view.tsx | 7 +- components/calendar/calendar-month-view.tsx | 156 ++++++----------- components/calendar/calendar-week-view.tsx | 98 ++++++----- components/calendar/event-card.tsx | 48 +++++- components/calendar/event-modal.tsx | 30 ++-- lib/__tests__/calendar-utils.test.ts | 168 +++++++++++++++++++ lib/calendar-utils.ts | 90 +++++++++- stores/calendar-store.ts | 5 +- 9 files changed, 436 insertions(+), 201 deletions(-) create mode 100644 lib/__tests__/calendar-utils.test.ts diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 74f5801d..f010d773 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -6,7 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns"; import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseDuration, getEventColor } from "./event-card"; -import { getEventEndDate } from "@/lib/calendar-utils"; +import { getEventDayBounds } from "@/lib/calendar-utils"; import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -53,35 +53,18 @@ export function CalendarAgendaView({ 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); + const { startDay, endDay } = getEventDayBounds(ev); + const cursor = new Date(startDay); + while (cursor <= endDay) { + const key = format(cursor, "yyyy-MM-dd"); + let group = groupMap.get(key); if (!group) { - group = { date: start, dateKey: startKey, events: [] }; - groupMap.set(startKey, group); + group = { date: new Date(cursor), dateKey: key, events: [] }; + groupMap.set(key, 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); - } + cursor.setDate(cursor.getDate() + 1); } } catch { /* skip invalid dates */ } }); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 3fbfa72f..cd78490f 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -6,7 +6,7 @@ import { format, isToday, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; +import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; @@ -52,10 +52,7 @@ export function CalendarDayView({ 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 { startDay, endDay } = getEventDayBounds(ev); const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0); const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index cb90838a..9dee2a98 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useCallback, useRef, useEffect, type DragEvent } from "react"; +import { useMemo, useState, useCallback, type DragEvent } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, @@ -8,7 +8,7 @@ import { } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; -import { getEventEndDate } from "@/lib/calendar-utils"; +import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -59,12 +59,7 @@ export function CalendarMonthView({ 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 { startDay, endDay } = getEventDayBounds(e); const cursor = new Date(startDay); while (cursor <= endDay) { @@ -91,33 +86,15 @@ export function CalendarMonthView({ return result; }, [days]); + const weekSegments = useMemo(() => { + return weeks.map((week) => { + const segments = buildWeekSegments(events, week); + const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); + return { week, segments, rowCount }; + }); + }, [events, weeks]); + const [dropDayKey, setDropDayKey] = useState(null); - const [overflowDay, setOverflowDay] = useState<{ key: string; events: CalendarEvent[]; anchorRect: DOMRect; dayLabel: string } | null>(null); - const overflowRef = useRef(null); - - useEffect(() => { - if (!overflowDay) return; - const handleClickOutside = (e: MouseEvent) => { - if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) { - setOverflowDay(null); - } - }; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") setOverflowDay(null); - }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [overflowDay]); - - const handleMoreClick = useCallback((e: React.MouseEvent, dayKey: string, dayEvents: CalendarEvent[], dayLabel: string) => { - e.stopPropagation(); - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setOverflowDay({ key: dayKey, events: dayEvents, anchorRect: rect, dayLabel }); - }, []); const handleCellDragOver = useCallback((e: DragEvent, dayKey: string) => { if (!e.dataTransfer.types.includes("application/x-calendar-event")) return; @@ -167,18 +144,18 @@ export function CalendarMonthView({
- {weeks.map((week, wi) => ( + {weekSegments.map(({ week, segments, rowCount }, wi) => (
+ )} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}> +
{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 = isMobile ? 0 : 3; const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return ( @@ -233,81 +210,46 @@ export function CalendarMonthView({ )}
) - ) : ( -
- {dayEvents.slice(0, maxVisible).map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; - return ( - onSelectEvent(ev, rect)} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - draggable - /> - ); - })} - {dayEvents.length > maxVisible && ( - - )} -
- )} + ) : null}
); })} +
+ + {!isMobile && segments.length > 0 && ( +
+ {segments.map((segment) => { + const calId = Object.keys(segment.event.calendarIds)[0]; + return ( +
+ onSelectEvent(segment.event, rect)} + onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} + onMouseLeave={onHoverLeave} + draggable + /> +
+ ); + })} +
+ )} ))} - - {overflowDay && (() => { - const viewportW = typeof window !== "undefined" ? window.innerWidth : 1024; - const viewportH = typeof window !== "undefined" ? window.innerHeight : 768; - const popoverW = 260; - const popoverMaxH = 320; - let left = overflowDay.anchorRect.left; - let top = overflowDay.anchorRect.bottom + 4; - - if (left + popoverW > viewportW - 8) left = viewportW - popoverW - 8; - if (left < 8) left = 8; - if (top + popoverMaxH > viewportH - 8) top = overflowDay.anchorRect.top - popoverMaxH - 4; - if (top < 8) top = 8; - - return ( -
-
{overflowDay.dayLabel}
- {overflowDay.events.map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; - return ( - { setOverflowDay(null); onSelectEvent(ev, rect); }} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - draggable - /> - ); - })} -
- ); - })()} ); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2d0335b5..e54bfe9b 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -8,7 +8,7 @@ import { import { cn } from "@/lib/utils"; import { EventCard, parseDuration } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; +import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; @@ -62,25 +62,17 @@ export function CalendarWeekView({ return map; }, [calendars]); - const { timedEvents, allDayEvents } = useMemo(() => { + const timedEvents = 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 { startDay, endDay } = getEventDayBounds(ev); 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 { + if (!ev.showWithoutTime) { const arr = timed.get(key) || []; arr.push(ev); timed.set(key, arr); @@ -89,15 +81,21 @@ export function CalendarWeekView({ } } catch { /* skip invalid dates */ } }); - return { timedEvents: timed, allDayEvents: allDay }; + return timed; }, [events]); + const allDaySegments = useMemo(() => buildWeekSegments( + events.filter((event) => event.showWithoutTime), + weekDays, + ), [events, weekDays]); + + const allDayRowCount = useMemo(() => { + return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); + }, [allDaySegments]); + const hasAllDay = useMemo(() => { - return weekDays.some(day => { - const key = format(day, "yyyy-MM-dd"); - return (allDayEvents.get(key) || []).length > 0; - }); - }, [weekDays, allDayEvents]); + return allDaySegments.length > 0; + }, [allDaySegments]); useEffect(() => { if (scrollRef.current) { @@ -148,32 +146,48 @@ export function CalendarWeekView({
{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, rect)} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - /> - ); - })} -
- ); - })} +
+ {weekDays.map((day) => ( +
+ ))} + +
+ {allDaySegments.map((segment) => { + const calId = Object.keys(segment.event.calendarIds)[0]; + return ( +
+ onSelectEvent(segment.event, rect)} + onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} + onMouseLeave={onHoverLeave} + /> +
+ ); + })} +
)} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index f0574793..9dc5051c 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, type DragEvent } from "react"; +import { useCallback, useState, type CSSProperties, type DragEvent } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -11,12 +11,16 @@ import { getParticipantCount } from "@/lib/calendar-participants"; interface EventCardProps { event: CalendarEvent; calendar?: Calendar; - variant: "chip" | "block"; + variant: "chip" | "block" | "span"; onClick?: (anchorRect: DOMRect) => void; onMouseEnter?: (anchorRect: DOMRect) => void; onMouseLeave?: () => void; isSelected?: boolean; draggable?: boolean; + continuesBefore?: boolean; + continuesAfter?: boolean; + className?: string; + style?: CSSProperties; } function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string { @@ -59,7 +63,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string) return el; } -export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable }: EventCardProps) { +export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) { const t = useTranslations("calendar"); const [isBeingDragged, setIsBeingDragged] = useState(false); const color = getEventColor(event, calendar); @@ -113,9 +117,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM "min-h-[44px] sm:min-h-0", "hover:opacity-80 transition-opacity", isSelected && "ring-2 ring-primary", - isBeingDragged && "opacity-50" + isBeingDragged && "opacity-50", + className )} - style={{ backgroundColor: `${color}20`, color }} + style={{ backgroundColor: `${color}20`, color, ...style }} > { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }} + onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())} + onMouseLeave={() => onMouseLeave?.()} + aria-label={ariaLabel} + {...dragProps} + className={cn( + "w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden", + "hover:opacity-90 transition-opacity cursor-pointer", + continuesBefore && "rounded-l-sm", + continuesAfter && "rounded-r-sm", + continuesBefore && "-ml-0.5", + continuesAfter && "pr-2", + isSelected && "ring-2 ring-primary", + isBeingDragged && "opacity-50", + className + )} + style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }} + > +
+ {event.title || t("events.no_title")} +
+ + ); + } + return (