feat: enhance calendar event handling with full-day detection and layout adjustments

This commit is contained in:
Linus Rath
2026-03-27 22:04:57 +01:00
parent edcd5aa2a7
commit 92ff5fe449
5 changed files with 259 additions and 63 deletions
+1 -1
View File
@@ -873,7 +873,7 @@ export default function CalendarPage() {
})(); })();
return ( return (
<div className="relative flex-1 flex flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{viewContent} {viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && ( {isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
+9 -11
View File
@@ -5,9 +5,9 @@ import { useTranslations, useFormatter } from "next-intl";
import { format, isSameDay, isToday, parseISO } from "date-fns"; import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -66,7 +66,7 @@ export function CalendarDayView({
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
if (!spansThisDay) return; if (!spansThisDay) return;
if (ev.showWithoutTime) allDay.push(ev); if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev);
else timed.push(ev); else timed.push(ev);
} catch { /* skip invalid dates */ } } catch { /* skip invalid dates */ }
}); });
@@ -127,10 +127,10 @@ export function CalendarDayView({
return format(new Date(2000, 0, 1, h), "HH:mm"); 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 ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}> <div className="flex min-h-0 flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}> <div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}> <h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile {isMobile
@@ -200,7 +200,7 @@ export function CalendarDayView({
</div> </div>
)} )}
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -241,11 +241,9 @@ export function CalendarDayView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
+22 -16
View File
@@ -7,9 +7,9 @@ import {
} from "date-fns"; } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card"; import { EventCard } from "./event-card";
import { QuickEventInput } from "./quick-event-input"; 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 type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal"; import type { PendingEventPreview } from "./event-modal";
@@ -77,7 +77,7 @@ export function CalendarWeekView({
const cursor = new Date(startDay); const cursor = new Date(startDay);
while (cursor <= endDay) { while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd"); const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) { if (!ev.showWithoutTime && !isTimedEventFullDayOnDate(ev, cursor)) {
const arr = timed.get(key) || []; const arr = timed.get(key) || [];
arr.push(ev); arr.push(ev);
timed.set(key, arr); timed.set(key, arr);
@@ -89,10 +89,18 @@ export function CalendarWeekView({
return timed; return timed;
}, [events]); }, [events]);
const allDaySegments = useMemo(() => buildWeekSegments( const allDaySegments = useMemo(() => {
events.filter((event) => event.showWithoutTime), const explicitAllDay = buildWeekSegmentsRaw(
weekDays, events.filter((event) => event.showWithoutTime),
), [events, weekDays]); weekDays,
);
const timedFullDay = buildTimedFullDayWeekSegments(
events.filter((event) => !event.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, weekDays]);
const allDayRowCount = useMemo(() => { const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
@@ -189,13 +197,13 @@ export function CalendarWeekView({
<div <div
ref={rootRef} ref={rootRef}
className={cn( className={cn(
"flex flex-col flex-1", "flex min-h-0 min-w-0 flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden" isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)} )}
role="grid" role="grid"
aria-label={t("views.week")} aria-label={t("views.week")}
> >
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && ( <div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<div <div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
@@ -321,7 +329,7 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
@@ -344,7 +352,7 @@ export function CalendarWeekView({
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day); const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents); const layouted = layoutOverlappingEvents(dayEvents, day);
return ( return (
<div <div
@@ -371,11 +379,9 @@ export function CalendarWeekView({
/> />
))} ))}
{layouted.map(({ event: ev, column, totalColumns }) => { {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const start = parseISO(ev.start); const durMin = Math.max(15, endMinutes - startMinutes);
const startMin = start.getHours() * 60 + start.getMinutes(); const top = (startMinutes / 60) * HOUR_HEIGHT;
const durMin = Math.max(15, parseDuration(ev.duration));
const top = (startMin / 60) * HOUR_HEIGHT;
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev); const calId = getPrimaryCalendarId(ev);
+93
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarEvent } from '@/lib/jmap/types';
import { import {
buildTimedFullDayWeekSegments,
buildWeekSegments, buildWeekSegments,
buildAllDayDuration, buildAllDayDuration,
getEventDayBounds, getEventDayBounds,
getEventDisplayEndDate, getEventDisplayEndDate,
getEventEndDate, getEventEndDate,
getTimedEventBoundsForDay,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
normalizeAllDayDuration, normalizeAllDayDuration,
} from '../calendar-utils'; } from '../calendar-utils';
@@ -95,6 +99,95 @@ describe('calendar-utils all-day handling', () => {
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11); 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', () => { it('normalizes imported all-day durations to day units', () => {
expect(normalizeAllDayDuration('PT24H')).toBe('P1D'); expect(normalizeAllDayDuration('PT24H')).toBe('P1D');
expect(normalizeAllDayDuration('PT72H')).toBe('P3D'); expect(normalizeAllDayDuration('PT72H')).toBe('P3D');
+134 -35
View File
@@ -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 { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types"; import type { CalendarEvent } from "@/lib/jmap/types";
@@ -11,6 +11,42 @@ export interface CalendarWeekSegment {
continuesAfter: boolean; 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 { export function getEventEndDate(event: CalendarEvent): Date {
const start = parseISO(event.start); const start = parseISO(event.start);
if (!event.duration) return 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 { export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined; if (!duration) return undefined;
const totalMinutes = parseDuration(duration); const totalMinutes = parseDuration(duration);
@@ -44,7 +113,7 @@ export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
return `P${dayCount}D`; 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 []; if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]); const weekStart = startOfDay(weekDays[0]);
@@ -71,59 +140,89 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca
} satisfies CalendarWeekSegment]; } satisfies CalendarWeekSegment];
}); });
rawSegments.sort((left, right) => { return rawSegments;
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) { export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
return left.event.showWithoutTime ? -1 : 1; 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; const segments: CalendarWeekSegment[] = [];
return (left.event.title || "").localeCompare(right.event.title || ""); 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 packWeekSegments(rawSegments);
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 layoutOverlappingEvents( export function layoutOverlappingEvents(
events: CalendarEvent[], events: CalendarEvent[],
): { event: CalendarEvent; column: number; totalColumns: number }[] { day: Date,
const sorted = [...events].sort((a, b) => { ): TimedEventLayout[] {
const diff = parseISO(a.start).getTime() - parseISO(b.start).getTime(); 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; 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 columns: { event: CalendarEvent; end: number }[][] = [];
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = []; const result: TimedEventLayout[] = [];
for (const event of sorted) { 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; let placed = false;
for (let col = 0; col < columns.length; col++) { for (let col = 0; col < columns.length; col++) {
if (columns[col].every(e => e.end <= startMin)) { if (columns[col].every(e => e.end <= event.startMinutes)) {
columns[col].push({ event, end: endMin }); columns[col].push({ event: event.event, end: event.endMinutes });
result.push({ event, column: col, totalColumns: 0 }); result.push({ ...event, column: col, totalColumns: 0 });
placed = true; placed = true;
break; break;
} }
} }
if (!placed) { if (!placed) {
columns.push([{ event, end: endMin }]); columns.push([{ event: event.event, end: event.endMinutes }]);
result.push({ event, column: columns.length - 1, totalColumns: 0 }); result.push({ ...event, column: columns.length - 1, totalColumns: 0 });
} }
} }