fix: correct all-day multi-day event rendering
This commit is contained in:
@@ -6,7 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
|||||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { getEventEndDate } from "@/lib/calendar-utils";
|
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -53,35 +53,18 @@ export function CalendarAgendaView({
|
|||||||
|
|
||||||
sorted.forEach((ev) => {
|
sorted.forEach((ev) => {
|
||||||
try {
|
try {
|
||||||
const start = new Date(ev.start);
|
const { startDay, endDay } = getEventDayBounds(ev);
|
||||||
const end = getEventEndDate(ev);
|
const cursor = new Date(startDay);
|
||||||
const startKey = format(start, "yyyy-MM-dd");
|
while (cursor <= endDay) {
|
||||||
const endKey = format(end, "yyyy-MM-dd");
|
const key = format(cursor, "yyyy-MM-dd");
|
||||||
|
let group = groupMap.get(key);
|
||||||
if (startKey === endKey || ev.showWithoutTime) {
|
|
||||||
let group = groupMap.get(startKey);
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = { date: start, dateKey: startKey, events: [] };
|
group = { date: new Date(cursor), dateKey: key, events: [] };
|
||||||
groupMap.set(startKey, group);
|
groupMap.set(key, group);
|
||||||
groups.push(group);
|
groups.push(group);
|
||||||
}
|
}
|
||||||
group.events.push(ev);
|
group.events.push(ev);
|
||||||
} else {
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
const cursor = new Date(start);
|
|
||||||
cursor.setHours(0, 0, 0, 0);
|
|
||||||
const endDay = new Date(end);
|
|
||||||
endDay.setHours(0, 0, 0, 0);
|
|
||||||
while (cursor <= endDay) {
|
|
||||||
const key = format(cursor, "yyyy-MM-dd");
|
|
||||||
let group = groupMap.get(key);
|
|
||||||
if (!group) {
|
|
||||||
group = { date: new Date(cursor), dateKey: key, events: [] };
|
|
||||||
groupMap.set(key, group);
|
|
||||||
groups.push(group);
|
|
||||||
}
|
|
||||||
group.events.push(ev);
|
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { format, isToday, parseISO } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
|
||||||
@@ -52,10 +52,7 @@ export function CalendarDayView({
|
|||||||
const allDay: CalendarEvent[] = [];
|
const allDay: CalendarEvent[] = [];
|
||||||
events.forEach((ev) => {
|
events.forEach((ev) => {
|
||||||
try {
|
try {
|
||||||
const start = new Date(ev.start);
|
const { startDay, endDay } = getEventDayBounds(ev);
|
||||||
const end = getEventEndDate(ev);
|
|
||||||
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
|
||||||
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
|
|
||||||
const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0);
|
const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, useRef, useEffect, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
import { getEventEndDate } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
@@ -59,12 +59,7 @@ export function CalendarMonthView({
|
|||||||
const map = new Map<string, CalendarEvent[]>();
|
const map = new Map<string, CalendarEvent[]>();
|
||||||
events.forEach((e) => {
|
events.forEach((e) => {
|
||||||
try {
|
try {
|
||||||
const start = new Date(e.start);
|
const { startDay, endDay } = getEventDayBounds(e);
|
||||||
const end = getEventEndDate(e);
|
|
||||||
const startDay = new Date(start);
|
|
||||||
startDay.setHours(0, 0, 0, 0);
|
|
||||||
const endDay = new Date(end);
|
|
||||||
endDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const cursor = new Date(startDay);
|
const cursor = new Date(startDay);
|
||||||
while (cursor <= endDay) {
|
while (cursor <= endDay) {
|
||||||
@@ -91,33 +86,15 @@ export function CalendarMonthView({
|
|||||||
return result;
|
return result;
|
||||||
}, [days]);
|
}, [days]);
|
||||||
|
|
||||||
|
const weekSegments = useMemo(() => {
|
||||||
|
return weeks.map((week) => {
|
||||||
|
const segments = buildWeekSegments(events, week);
|
||||||
|
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
|
return { week, segments, rowCount };
|
||||||
|
});
|
||||||
|
}, [events, weeks]);
|
||||||
|
|
||||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||||
const [overflowDay, setOverflowDay] = useState<{ key: string; events: CalendarEvent[]; anchorRect: DOMRect; dayLabel: string } | null>(null);
|
|
||||||
const overflowRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!overflowDay) return;
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (overflowRef.current && !overflowRef.current.contains(e.target as Node)) {
|
|
||||||
setOverflowDay(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") setOverflowDay(null);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
document.removeEventListener("keydown", handleEscape);
|
|
||||||
};
|
|
||||||
}, [overflowDay]);
|
|
||||||
|
|
||||||
const handleMoreClick = useCallback((e: React.MouseEvent, dayKey: string, dayEvents: CalendarEvent[], dayLabel: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
setOverflowDay({ key: dayKey, events: dayEvents, anchorRect: rect, dayLabel });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||||
@@ -167,18 +144,18 @@ export function CalendarMonthView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||||
{weeks.map((week, wi) => (
|
{weekSegments.map(({ week, segments, rowCount }, wi) => (
|
||||||
<div key={wi} className={cn(
|
<div key={wi} className={cn(
|
||||||
"grid grid-cols-7 flex-1 border-b border-border last:border-b-0",
|
"relative flex-1 border-b border-border last:border-b-0",
|
||||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
||||||
)} role="row">
|
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||||
|
<div className="grid grid-cols-7 h-full">
|
||||||
{week.map((day) => {
|
{week.map((day) => {
|
||||||
const inMonth = isSameMonth(day, selectedDate);
|
const inMonth = isSameMonth(day, selectedDate);
|
||||||
const selected = isSameDay(day, selectedDate);
|
const selected = isSameDay(day, selectedDate);
|
||||||
const today = isToday(day);
|
const today = isToday(day);
|
||||||
const key = format(day, "yyyy-MM-dd");
|
const key = format(day, "yyyy-MM-dd");
|
||||||
const dayEvents = eventsByDate.get(key) || [];
|
const dayEvents = eventsByDate.get(key) || [];
|
||||||
const maxVisible = isMobile ? 0 : 3;
|
|
||||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -233,81 +210,46 @@ export function CalendarMonthView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : null}
|
||||||
<div className="space-y-0.5">
|
|
||||||
{dayEvents.slice(0, maxVisible).map((ev) => {
|
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
|
||||||
return (
|
|
||||||
<EventCard
|
|
||||||
key={ev.id}
|
|
||||||
event={ev}
|
|
||||||
calendar={calendarMap.get(calId)}
|
|
||||||
variant="chip"
|
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
|
||||||
onMouseLeave={onHoverLeave}
|
|
||||||
draggable
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{dayEvents.length > maxVisible && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[10px] text-muted-foreground px-1 hover:text-foreground hover:underline cursor-pointer text-left w-full"
|
|
||||||
onClick={(e) => handleMoreClick(e, key, dayEvents, fullDateLabel)}
|
|
||||||
>
|
|
||||||
{t("events.more", { count: dayEvents.length - maxVisible })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && segments.length > 0 && (
|
||||||
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
|
{segments.map((segment) => {
|
||||||
|
const calId = Object.keys(segment.event.calendarIds)[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
|
className="absolute px-0.5 pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||||
|
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||||
|
top: segment.row * 22,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EventCard
|
||||||
|
event={segment.event}
|
||||||
|
calendar={calendarMap.get(calId)}
|
||||||
|
variant="span"
|
||||||
|
continuesBefore={segment.continuesBefore}
|
||||||
|
continuesAfter={segment.continuesAfter}
|
||||||
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
|
onMouseLeave={onHoverLeave}
|
||||||
|
draggable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{overflowDay && (() => {
|
|
||||||
const viewportW = typeof window !== "undefined" ? window.innerWidth : 1024;
|
|
||||||
const viewportH = typeof window !== "undefined" ? window.innerHeight : 768;
|
|
||||||
const popoverW = 260;
|
|
||||||
const popoverMaxH = 320;
|
|
||||||
let left = overflowDay.anchorRect.left;
|
|
||||||
let top = overflowDay.anchorRect.bottom + 4;
|
|
||||||
|
|
||||||
if (left + popoverW > viewportW - 8) left = viewportW - popoverW - 8;
|
|
||||||
if (left < 8) left = 8;
|
|
||||||
if (top + popoverMaxH > viewportH - 8) top = overflowDay.anchorRect.top - popoverMaxH - 4;
|
|
||||||
if (top < 8) top = 8;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={overflowRef}
|
|
||||||
className="fixed z-50 bg-background border border-border rounded-lg shadow-lg p-3 space-y-1 overflow-y-auto"
|
|
||||||
style={{ left, top, width: popoverW, maxHeight: popoverMaxH }}
|
|
||||||
role="dialog"
|
|
||||||
aria-label={overflowDay.dayLabel}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold text-foreground mb-2">{overflowDay.dayLabel}</div>
|
|
||||||
{overflowDay.events.map((ev) => {
|
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
|
||||||
return (
|
|
||||||
<EventCard
|
|
||||||
key={ev.id}
|
|
||||||
event={ev}
|
|
||||||
calendar={calendarMap.get(calId)}
|
|
||||||
variant="chip"
|
|
||||||
onClick={(rect) => { setOverflowDay(null); onSelectEvent(ev, rect); }}
|
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
|
||||||
onMouseLeave={onHoverLeave}
|
|
||||||
draggable
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
import { buildWeekSegments, getEventDayBounds, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
|
||||||
@@ -62,25 +62,17 @@ export function CalendarWeekView({
|
|||||||
return map;
|
return map;
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
const { timedEvents, allDayEvents } = useMemo(() => {
|
const timedEvents = useMemo(() => {
|
||||||
const timed: Map<string, CalendarEvent[]> = new Map();
|
const timed: Map<string, CalendarEvent[]> = new Map();
|
||||||
const allDay: Map<string, CalendarEvent[]> = new Map();
|
|
||||||
|
|
||||||
events.forEach((ev) => {
|
events.forEach((ev) => {
|
||||||
try {
|
try {
|
||||||
const start = new Date(ev.start);
|
const { startDay, endDay } = getEventDayBounds(ev);
|
||||||
const end = getEventEndDate(ev);
|
|
||||||
const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
|
|
||||||
const endDay = new Date(end); endDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
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) {
|
||||||
const arr = allDay.get(key) || [];
|
|
||||||
arr.push(ev);
|
|
||||||
allDay.set(key, arr);
|
|
||||||
} else {
|
|
||||||
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,15 +81,21 @@ export function CalendarWeekView({
|
|||||||
}
|
}
|
||||||
} catch { /* skip invalid dates */ }
|
} catch { /* skip invalid dates */ }
|
||||||
});
|
});
|
||||||
return { timedEvents: timed, allDayEvents: allDay };
|
return timed;
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
const allDaySegments = useMemo(() => buildWeekSegments(
|
||||||
|
events.filter((event) => event.showWithoutTime),
|
||||||
|
weekDays,
|
||||||
|
), [events, weekDays]);
|
||||||
|
|
||||||
|
const allDayRowCount = useMemo(() => {
|
||||||
|
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||||
|
}, [allDaySegments]);
|
||||||
|
|
||||||
const hasAllDay = useMemo(() => {
|
const hasAllDay = useMemo(() => {
|
||||||
return weekDays.some(day => {
|
return allDaySegments.length > 0;
|
||||||
const key = format(day, "yyyy-MM-dd");
|
}, [allDaySegments]);
|
||||||
return (allDayEvents.get(key) || []).length > 0;
|
|
||||||
});
|
|
||||||
}, [weekDays, allDayEvents]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
@@ -148,32 +146,48 @@ export function CalendarWeekView({
|
|||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
||||||
{hasAllDay && (
|
{hasAllDay && (
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<div className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}>
|
<div
|
||||||
|
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
||||||
|
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||||
|
>
|
||||||
{t("events.all_day")}
|
{t("events.all_day")}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("flex-1 grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}>
|
<div
|
||||||
{weekDays.map((day) => {
|
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
||||||
const key = format(day, "yyyy-MM-dd");
|
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||||
const dayAllDay = allDayEvents.get(key) || [];
|
>
|
||||||
return (
|
{weekDays.map((day) => (
|
||||||
<div key={key} className="bg-background p-0.5 min-h-[28px]">
|
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||||
{dayAllDay.map((ev) => {
|
))}
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
|
||||||
return (
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
<EventCard
|
{allDaySegments.map((segment) => {
|
||||||
key={ev.id}
|
const calId = Object.keys(segment.event.calendarIds)[0];
|
||||||
event={ev}
|
return (
|
||||||
calendar={calendarMap.get(calId)}
|
<div
|
||||||
variant="chip"
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
className="absolute px-0.5 pointer-events-auto"
|
||||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
style={{
|
||||||
onMouseLeave={onHoverLeave}
|
left: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
|
||||||
/>
|
width: `calc(${(segment.span / colCount) * 100}% - 2px)`,
|
||||||
);
|
top: segment.row * 24 + 2,
|
||||||
})}
|
height: 20,
|
||||||
</div>
|
}}
|
||||||
);
|
>
|
||||||
})}
|
<EventCard
|
||||||
|
event={segment.event}
|
||||||
|
calendar={calendarMap.get(calId)}
|
||||||
|
variant="span"
|
||||||
|
continuesBefore={segment.continuesBefore}
|
||||||
|
continuesAfter={segment.continuesAfter}
|
||||||
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||||
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||||
|
onMouseLeave={onHoverLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState, type DragEvent } from "react";
|
import { useCallback, useState, type CSSProperties, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
@@ -11,12 +11,16 @@ import { getParticipantCount } from "@/lib/calendar-participants";
|
|||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
calendar?: Calendar;
|
calendar?: Calendar;
|
||||||
variant: "chip" | "block";
|
variant: "chip" | "block" | "span";
|
||||||
onClick?: (anchorRect: DOMRect) => void;
|
onClick?: (anchorRect: DOMRect) => void;
|
||||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||||
onMouseLeave?: () => void;
|
onMouseLeave?: () => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
|
continuesBefore?: boolean;
|
||||||
|
continuesAfter?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
||||||
@@ -59,7 +63,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable }: EventCardProps) {
|
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||||
const color = getEventColor(event, calendar);
|
const color = getEventColor(event, calendar);
|
||||||
@@ -113,9 +117,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
"min-h-[44px] sm:min-h-0",
|
"min-h-[44px] sm:min-h-0",
|
||||||
"hover:opacity-80 transition-opacity",
|
"hover:opacity-80 transition-opacity",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50"
|
isBeingDragged && "opacity-50",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}20`, color }}
|
style={{ backgroundColor: `${color}20`, color, ...style }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
@@ -126,6 +131,34 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (variant === "span") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
|
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||||
|
onMouseLeave={() => onMouseLeave?.()}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{...dragProps}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
|
continuesBefore && "rounded-l-sm",
|
||||||
|
continuesAfter && "rounded-r-sm",
|
||||||
|
continuesBefore && "-ml-0.5",
|
||||||
|
continuesAfter && "pr-2",
|
||||||
|
isSelected && "ring-2 ring-primary",
|
||||||
|
isBeingDragged && "opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
@@ -138,9 +171,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
|||||||
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50"
|
isBeingDragged && "opacity-50",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
|
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy } from "lucide-react";
|
|||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration } from "./event-card";
|
import { parseDuration } from "./event-card";
|
||||||
|
import { buildAllDayDuration, getEventDisplayEndDate } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
import {
|
import {
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
@@ -122,6 +123,9 @@ export function EventModal({
|
|||||||
|
|
||||||
const getInitialEnd = (): Date => {
|
const getInitialEnd = (): Date => {
|
||||||
if (event?.start) {
|
if (event?.start) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
return getEventDisplayEndDate(event);
|
||||||
|
}
|
||||||
const s = parseISO(event.start);
|
const s = parseISO(event.start);
|
||||||
const dur = parseDuration(event.duration);
|
const dur = parseDuration(event.duration);
|
||||||
return new Date(s.getTime() + dur * 60000);
|
return new Date(s.getTime() + dur * 60000);
|
||||||
@@ -196,21 +200,25 @@ export function EventModal({
|
|||||||
const startStr = allDay
|
const startStr = allDay
|
||||||
? `${startDate}T00:00:00`
|
? `${startDate}T00:00:00`
|
||||||
: `${startDate}T${startTime}:00`;
|
: `${startDate}T${startTime}:00`;
|
||||||
const endStr = allDay
|
|
||||||
? `${endDate}T23:59:59`
|
|
||||||
: `${endDate}T${endTime}:00`;
|
|
||||||
|
|
||||||
const start = new Date(startStr);
|
const start = new Date(startStr);
|
||||||
let end = new Date(endStr);
|
let duration: string;
|
||||||
|
|
||||||
if (end <= start) {
|
if (allDay) {
|
||||||
end = new Date(start.getTime() + 3600000);
|
let inclusiveEnd = new Date(`${endDate}T00:00:00`);
|
||||||
|
if (inclusiveEnd < start) {
|
||||||
|
inclusiveEnd = new Date(start);
|
||||||
|
}
|
||||||
|
duration = buildAllDayDuration(start, inclusiveEnd);
|
||||||
|
} else {
|
||||||
|
const endStr = `${endDate}T${endTime}:00`;
|
||||||
|
let end = new Date(endStr);
|
||||||
|
if (end <= start) {
|
||||||
|
end = new Date(start.getTime() + 3600000);
|
||||||
|
}
|
||||||
|
duration = buildDuration(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = allDay
|
|
||||||
? `P${Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))}D`
|
|
||||||
: buildDuration(start, end);
|
|
||||||
|
|
||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
@@ -218,7 +226,7 @@ export function EventModal({
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
start: startStr,
|
start: startStr,
|
||||||
duration,
|
duration,
|
||||||
timeZone,
|
timeZone: allDay ? null : timeZone,
|
||||||
showWithoutTime: allDay,
|
showWithoutTime: allDay,
|
||||||
calendarIds: { [calendarId]: true },
|
calendarIds: { [calendarId]: true },
|
||||||
status: "confirmed",
|
status: "confirmed",
|
||||||
|
|||||||
@@ -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
@@ -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 { parseDuration } from "@/components/calendar/event-card";
|
||||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
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 {
|
export function getEventEndDate(event: CalendarEvent): Date {
|
||||||
const start = new Date(event.start);
|
const start = new Date(event.start);
|
||||||
if (!event.duration) return start;
|
if (!event.duration) return start;
|
||||||
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
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(
|
export function layoutOverlappingEvents(
|
||||||
events: CalendarEvent[],
|
events: CalendarEvent[],
|
||||||
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { JMAPClient } from '@/lib/jmap/client';
|
import type { JMAPClient } from '@/lib/jmap/client';
|
||||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||||
import { debug } from '@/lib/debug';
|
import { debug } from '@/lib/debug';
|
||||||
|
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||||
|
|
||||||
@@ -213,8 +214,8 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
description: src.description,
|
description: src.description,
|
||||||
descriptionContentType: src.descriptionContentType,
|
descriptionContentType: src.descriptionContentType,
|
||||||
start: src.start,
|
start: src.start,
|
||||||
duration: src.duration,
|
duration: src.showWithoutTime ? normalizeAllDayDuration(src.duration) : src.duration,
|
||||||
timeZone: src.timeZone,
|
timeZone: src.showWithoutTime ? null : src.timeZone,
|
||||||
showWithoutTime: src.showWithoutTime,
|
showWithoutTime: src.showWithoutTime,
|
||||||
status: src.status,
|
status: src.status,
|
||||||
freeBusyStatus: src.freeBusyStatus,
|
freeBusyStatus: src.freeBusyStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user