fix: correct all-day multi-day event rendering

This commit is contained in:
Linus Rath
2026-03-16 17:57:56 +01:00
parent 4b262a2746
commit cde1d61d02
9 changed files with 436 additions and 201 deletions
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import {
buildWeekSegments,
buildAllDayDuration,
getEventDayBounds,
getEventDisplayEndDate,
getEventEndDate,
normalizeAllDayDuration,
} from '../calendar-utils';
function expectLocalDateParts(date: Date, year: number, month: number, day: number, hour: number, minute = 0, second = 0, millisecond = 0) {
expect(date.getFullYear()).toBe(year);
expect(date.getMonth()).toBe(month - 1);
expect(date.getDate()).toBe(day);
expect(date.getHours()).toBe(hour);
expect(date.getMinutes()).toBe(minute);
expect(date.getSeconds()).toBe(second);
expect(date.getMilliseconds()).toBe(millisecond);
}
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
calendarIds: { 'cal-1': true },
isDraft: false,
isOrigin: true,
utcStart: '2026-03-14T00:00:00Z',
utcEnd: '2026-03-17T00:00:00Z',
'@type': 'Event',
uid: 'uid-1',
title: 'Test Event',
description: '',
descriptionContentType: 'text/plain',
created: null,
updated: '2026-03-01T09:00:00Z',
sequence: 0,
start: '2026-03-14T00:00:00',
duration: 'P3D',
timeZone: 'UTC',
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
...overrides,
};
}
describe('calendar-utils all-day handling', () => {
it('treats all-day event end as exclusive for display', () => {
const event = makeEvent({
start: '2026-03-14T00:00:00',
duration: 'P3D',
showWithoutTime: true,
});
expectLocalDateParts(getEventEndDate(event), 2026, 3, 17, 0);
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 16, 23, 59, 59, 999);
const { startDay, endDay } = getEventDayBounds(event);
expectLocalDateParts(startDay, 2026, 3, 14, 0);
expectLocalDateParts(endDay, 2026, 3, 16, 0);
});
it('leaves timed event display end unchanged', () => {
const event = makeEvent({
start: '2026-03-14T09:00:00',
duration: 'PT2H',
showWithoutTime: false,
utcStart: '2026-03-14T09:00:00Z',
utcEnd: '2026-03-14T11:00:00Z',
});
expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11);
});
it('normalizes imported all-day durations to day units', () => {
expect(normalizeAllDayDuration('PT24H')).toBe('P1D');
expect(normalizeAllDayDuration('PT72H')).toBe('P3D');
expect(normalizeAllDayDuration('P1DT12H')).toBe('P2D');
expect(normalizeAllDayDuration(undefined)).toBeUndefined();
});
it('builds an inclusive all-day duration from editor dates', () => {
const start = new Date('2026-03-14T00:00:00Z');
const inclusiveEnd = new Date('2026-03-16T00:00:00Z');
expect(buildAllDayDuration(start, inclusiveEnd)).toBe('P3D');
});
it('builds a single week segment for a five-day event instead of one entry per day', () => {
const week = [
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'),
new Date('2026-03-21T00:00:00Z'),
new Date('2026-03-22T00:00:00Z'),
];
const event = makeEvent({
start: '2026-03-16T00:00:00',
duration: 'P5D',
title: 'Full day',
showWithoutTime: true,
});
const segments = buildWeekSegments([event], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
startIndex: 0,
span: 5,
row: 0,
continuesBefore: false,
continuesAfter: false,
});
});
it('splits a continuing event across weeks without snaking inside a week row', () => {
const week = [
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'),
new Date('2026-03-21T00:00:00Z'),
new Date('2026-03-22T00:00:00Z'),
];
const event = makeEvent({
start: '2026-03-14T00:00:00',
duration: 'P10D',
title: 'Long event',
showWithoutTime: true,
});
const segments = buildWeekSegments([event], week);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
startIndex: 0,
span: 7,
row: 0,
continuesBefore: true,
continuesAfter: true,
});
});
});
+89 -1
View File
@@ -1,13 +1,101 @@
import { parseISO } from "date-fns";
import { differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types";
export interface CalendarWeekSegment {
event: CalendarEvent;
startIndex: number;
span: number;
row: number;
continuesBefore: boolean;
continuesAfter: boolean;
}
export function getEventEndDate(event: CalendarEvent): Date {
const start = new Date(event.start);
if (!event.duration) return start;
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
}
export function getEventDisplayEndDate(event: CalendarEvent): Date {
const end = getEventEndDate(event);
if (!event.showWithoutTime || end.getTime() <= new Date(event.start).getTime()) {
return end;
}
return subMilliseconds(end, 1);
}
export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } {
return {
startDay: startOfDay(new Date(event.start)),
endDay: startOfDay(getEventDisplayEndDate(event)),
};
}
export function normalizeAllDayDuration(duration: string | undefined): string | undefined {
if (!duration) return undefined;
const totalMinutes = parseDuration(duration);
const totalDays = Math.max(1, Math.ceil(totalMinutes / (24 * 60)));
return `P${totalDays}D`;
}
export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string {
const startDay = startOfDay(start);
const endDay = startOfDay(inclusiveEnd);
const dayCount = Math.max(1, Math.round((endDay.getTime() - startDay.getTime()) / 86400000) + 1);
return `P${dayCount}D`;
}
export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
const weekEnd = startOfDay(weekDays[weekDays.length - 1]);
const rawSegments = events.flatMap((event) => {
const { startDay, endDay } = getEventDayBounds(event);
if (endDay < weekStart || startDay > weekEnd) {
return [];
}
const segmentStart = startDay < weekStart ? weekStart : startDay;
const segmentEnd = endDay > weekEnd ? weekEnd : endDay;
const startIndex = differenceInCalendarDays(segmentStart, weekStart);
const span = differenceInCalendarDays(segmentEnd, segmentStart) + 1;
return [{
event,
startIndex,
span,
row: -1,
continuesBefore: startDay < weekStart,
continuesAfter: endDay > weekEnd,
} 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 (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 layoutOverlappingEvents(
events: CalendarEvent[],
): { event: CalendarEvent; column: number; totalColumns: number }[] {