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