feat: enhance calendar event handling with full-day detection and layout adjustments
This commit is contained in:
@@ -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');
|
||||
|
||||
+134
-35
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user