diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 3f73ad0c..41007bca 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -873,7 +873,7 @@ export default function CalendarPage() { })(); return ( -
+
{viewContent} {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 55feb06d..630292c9 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl"; import { format, isSameDay, isToday, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; -import { EventCard, parseDuration } from "./event-card"; +import { EventCard } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; +import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; @@ -66,7 +66,7 @@ export function CalendarDayView({ const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); if (!spansThisDay) return; - if (ev.showWithoutTime) allDay.push(ev); + if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev); else timed.push(ev); } catch { /* skip invalid dates */ } }); @@ -127,10 +127,10 @@ export function CalendarDayView({ return format(new Date(2000, 0, 1, h), "HH:mm"); }; - const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]); + const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]); return ( -
+

{isMobile @@ -200,7 +200,7 @@ export function CalendarDayView({

)} -
+
{HOURS.map((h) => ( @@ -241,11 +241,9 @@ export function CalendarDayView({ /> ))} - {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; + {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { + const durMin = Math.max(15, endMinutes - startMinutes); + const top = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 1269fe96..102e3b23 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -7,9 +7,9 @@ import { } from "date-fns"; import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; -import { EventCard, parseDuration } from "./event-card"; +import { EventCard } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; -import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils"; +import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; @@ -77,7 +77,7 @@ export function CalendarWeekView({ const cursor = new Date(startDay); while (cursor <= endDay) { const key = format(cursor, "yyyy-MM-dd"); - if (!ev.showWithoutTime) { + if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) { const arr = timed.get(key) || []; arr.push(ev); timed.set(key, arr); @@ -89,10 +89,18 @@ export function CalendarWeekView({ return timed; }, [events]); - const allDaySegments = useMemo(() => buildWeekSegments( - events.filter((event) => event.showWithoutTime), - weekDays, - ), [events, weekDays]); + const allDaySegments = useMemo(() => { + const explicitAllDay = buildWeekSegmentsRaw( + events.filter((event) => event.showWithoutTime), + weekDays, + ); + const timedFullDay = buildTimedFullDayWeekSegments( + events.filter((event) => !event.showWithoutTime), + weekDays, + ); + + return packWeekSegments([...explicitAllDay, ...timedFullDay]); + }, [events, weekDays]); const allDayRowCount = useMemo(() => { return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); @@ -189,13 +197,13 @@ export function CalendarWeekView({
-
{hasAllDay && ( +
{hasAllDay && (
-
+
{HOURS.map((h) => ( @@ -344,7 +352,7 @@ export function CalendarWeekView({ const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; const todayCol = isToday(day); - const layouted = layoutOverlappingEvents(dayEvents); + const layouted = layoutOverlappingEvents(dayEvents, day); return (
))} - {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; + {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { + const durMin = Math.max(15, endMinutes - startMinutes); + const top = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts index d0c9b195..f8647e2b 100644 --- a/lib/__tests__/calendar-utils.test.ts +++ b/lib/__tests__/calendar-utils.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { CalendarEvent } from '@/lib/jmap/types'; import { + buildTimedFullDayWeekSegments, buildWeekSegments, buildAllDayDuration, getEventDayBounds, getEventDisplayEndDate, getEventEndDate, + getTimedEventBoundsForDay, + isTimedEventFullDayOnDate, + layoutOverlappingEvents, normalizeAllDayDuration, } from '../calendar-utils'; @@ -95,6 +99,95 @@ describe('calendar-utils all-day handling', () => { expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11); }); + it('clips timed multi-day events to the visible day bounds', () => { + const event = makeEvent({ + start: '2026-03-14T22:00:00', + duration: 'PT4H', + showWithoutTime: false, + utcStart: '2026-03-14T22:00:00Z', + utcEnd: '2026-03-15T02:00:00Z', + }); + + expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({ + startMinutes: 1320, + endMinutes: 1440, + continuesBefore: false, + continuesAfter: true, + }); + + expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({ + startMinutes: 0, + endMinutes: 120, + continuesBefore: true, + continuesAfter: false, + }); + }); + + it('lays out continued timed events using clipped bounds for the active day', () => { + const event = makeEvent({ + start: '2026-03-14T22:00:00', + duration: 'PT4H', + showWithoutTime: false, + utcStart: '2026-03-14T22:00:00Z', + utcEnd: '2026-03-15T02:00:00Z', + }); + + const layout = layoutOverlappingEvents([event], new Date('2026-03-15T00:00:00Z')); + + expect(layout).toHaveLength(1); + expect(layout[0]).toMatchObject({ + startMinutes: 0, + endMinutes: 120, + column: 0, + totalColumns: 1, + continuesBefore: true, + continuesAfter: false, + }); + }); + + it('detects when a timed multi-day event fully occupies an intermediate day', () => { + const event = makeEvent({ + start: '2026-03-14T12:00:00', + duration: 'PT48H', + showWithoutTime: false, + utcStart: '2026-03-14T12:00:00Z', + utcEnd: '2026-03-16T12:00:00Z', + }); + + expect(isTimedEventFullDayOnDate(event, new Date('2026-03-15T00:00:00Z'))).toBe(true); + expect(isTimedEventFullDayOnDate(event, new Date('2026-03-14T00:00:00Z'))).toBe(false); + expect(isTimedEventFullDayOnDate(event, new Date('2026-03-16T00:00:00Z'))).toBe(false); + }); + + it('creates week-bar segments for timed events that fully cover visible days', () => { + const week = [ + new Date('2026-03-14T00:00:00Z'), + new Date('2026-03-15T00:00:00Z'), + new Date('2026-03-16T00:00:00Z'), + new Date('2026-03-17T00:00:00Z'), + new Date('2026-03-18T00:00:00Z'), + new Date('2026-03-19T00:00:00Z'), + new Date('2026-03-20T00:00:00Z'), + ]; + const event = makeEvent({ + start: '2026-03-14T12:00:00', + duration: 'PT72H', + showWithoutTime: false, + utcStart: '2026-03-14T12:00:00Z', + utcEnd: '2026-03-17T12:00:00Z', + }); + + const segments = buildTimedFullDayWeekSegments([event], week); + + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ + startIndex: 1, + span: 2, + continuesBefore: false, + continuesAfter: false, + }); + }); + it('normalizes imported all-day durations to day units', () => { expect(normalizeAllDayDuration('PT24H')).toBe('P1D'); expect(normalizeAllDayDuration('PT72H')).toBe('P3D'); diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 7a8e98f3..f4ae7c19 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -1,4 +1,4 @@ -import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; +import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns"; import { parseDuration } from "@/components/calendar/event-card"; import type { CalendarEvent } from "@/lib/jmap/types"; @@ -11,6 +11,42 @@ export interface CalendarWeekSegment { continuesAfter: boolean; } +export interface TimedEventLayout { + event: CalendarEvent; + column: number; + totalColumns: number; + startMinutes: number; + endMinutes: number; + continuesBefore: boolean; + continuesAfter: boolean; +} + +export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] { + rawSegments.sort((left, right) => { + if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex; + if (left.span !== right.span) return right.span - left.span; + if (left.event.showWithoutTime !== right.event.showWithoutTime) { + return left.event.showWithoutTime ? -1 : 1; + } + const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime(); + if (timeDiff !== 0) return timeDiff; + return (left.event.title || "").localeCompare(right.event.title || ""); + }); + + const rowEndIndices: number[] = []; + return rawSegments.map((segment) => { + const segmentEndIndex = segment.startIndex + segment.span - 1; + let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex); + if (row === -1) { + row = rowEndIndices.length; + rowEndIndices.push(segmentEndIndex); + } else { + rowEndIndices[row] = segmentEndIndex; + } + return { ...segment, row }; + }); +} + export function getEventEndDate(event: CalendarEvent): Date { const start = parseISO(event.start); if (!event.duration) return start; @@ -32,6 +68,39 @@ export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDa }; } +export function getTimedEventBoundsForDay( + event: CalendarEvent, + day: Date, +): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null { + if (event.showWithoutTime) return null; + + const eventStart = parseISO(event.start); + const eventEnd = getEventEndDate(event); + const dayStart = startOfDay(day); + const nextDayStart = addDays(dayStart, 1); + + if (eventEnd <= dayStart || eventStart >= nextDayStart) { + return null; + } + + const clippedStart = eventStart > dayStart ? eventStart : dayStart; + const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart; + const startMinutes = Math.max(0, Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000)); + const endMinutes = Math.min(1440, Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000)); + + return { + startMinutes, + endMinutes, + continuesBefore: eventStart < dayStart, + continuesAfter: eventEnd > nextDayStart, + }; +} + +export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean { + const bounds = getTimedEventBoundsForDay(event, day); + return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440; +} + export function normalizeAllDayDuration(duration: string | undefined): string | undefined { if (!duration) return undefined; const totalMinutes = parseDuration(duration); @@ -44,7 +113,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string { return `P${dayCount}D`; } -export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] { +export function buildWeekSegmentsRaw(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] { if (weekDays.length === 0) return []; const weekStart = startOfDay(weekDays[0]); @@ -71,59 +140,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca } satisfies CalendarWeekSegment]; }); - rawSegments.sort((left, right) => { - if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex; - if (left.span !== right.span) return right.span - left.span; - if (left.event.showWithoutTime !== right.event.showWithoutTime) { - return left.event.showWithoutTime ? -1 : 1; + return rawSegments; +} + +export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] { + return packWeekSegments(buildWeekSegmentsRaw(events, weekDays)); +} + +export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] { + if (weekDays.length === 0) return []; + + const rawSegments = events.flatMap((event) => { + const fullDayIndices = weekDays + .map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1)) + .filter((index) => index >= 0); + + if (fullDayIndices.length === 0) { + return []; } - const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime(); - if (timeDiff !== 0) return timeDiff; - return (left.event.title || "").localeCompare(right.event.title || ""); + + const segments: CalendarWeekSegment[] = []; + let rangeStart = fullDayIndices[0]; + let previousIndex = fullDayIndices[0]; + + const pushSegment = (startIndex: number, endIndex: number) => { + const startDay = weekDays[startIndex]; + const endDay = weekDays[endIndex]; + segments.push({ + event, + startIndex, + span: endIndex - startIndex + 1, + row: -1, + continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)), + continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)), + }); + }; + + for (let index = 1; index < fullDayIndices.length; index++) { + const currentIndex = fullDayIndices[index]; + if (currentIndex !== previousIndex + 1) { + pushSegment(rangeStart, previousIndex); + rangeStart = currentIndex; + } + previousIndex = currentIndex; + } + + pushSegment(rangeStart, previousIndex); + return segments; }); - const rowEndIndices: number[] = []; - return rawSegments.map((segment) => { - const segmentEndIndex = segment.startIndex + segment.span - 1; - let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex); - if (row === -1) { - row = rowEndIndices.length; - rowEndIndices.push(segmentEndIndex); - } else { - rowEndIndices[row] = segmentEndIndex; - } - return { ...segment, row }; - }); + return packWeekSegments(rawSegments); } export function layoutOverlappingEvents( events: CalendarEvent[], -): { event: CalendarEvent; column: number; totalColumns: number }[] { - const sorted = [...events].sort((a, b) => { - const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime(); + day: Date, +): TimedEventLayout[] { + const layoutInputs = events.flatMap((event) => { + const bounds = getTimedEventBoundsForDay(event, day); + return bounds ? [{ event, ...bounds }] : []; + }); + + const sorted = layoutInputs.sort((a, b) => { + const diff = a.startMinutes - b.startMinutes; if (diff !== 0) return diff; - return parseDuration(b.duration) - parseDuration(a.duration); + return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes); }); const columns: { event: CalendarEvent; end: number }[][] = []; - const result: { event: CalendarEvent; column: number; totalColumns: number }[] = []; + const result: TimedEventLayout[] = []; for (const event of sorted) { - const start = parseISO(event.start); - const startMin = start.getHours() * 60 + start.getMinutes(); - const endMin = Math.min(1440, 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 }); + if (columns[col].every(e => e.end <= event.startMinutes)) { + columns[col].push({ event: event.event, end: event.endMinutes }); + 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 }); + columns.push([{ event: event.event, end: event.endMinutes }]); + result.push({ ...event, column: columns.length - 1, totalColumns: 0 }); } }