Files
SRCmail/components/calendar/calendar-agenda-view.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
2026-02-17 01:09:57 +01:00

204 lines
7.3 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
interface CalendarAgendaViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectEvent: (event: CalendarEvent) => void;
timeFormat?: "12h" | "24h";
}
interface DayGroup {
date: Date;
dateKey: string;
events: CalendarEvent[];
}
function getEventEndDate(event: CalendarEvent): Date {
const start = new Date(event.start);
if (!event.duration) return start;
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
return new Date(start.getTime() + totalMs);
}
export function CalendarAgendaView({
events,
calendars,
onSelectEvent,
timeFormat = "24h",
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
calendars.forEach((c) => map.set(c.id, c));
return map;
}, [calendars]);
const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime()
);
const groups: DayGroup[] = [];
const groupMap = new Map<string, DayGroup>();
sorted.forEach((ev) => {
try {
const start = new Date(ev.start);
const end = getEventEndDate(ev);
const startKey = format(start, "yyyy-MM-dd");
const endKey = format(end, "yyyy-MM-dd");
if (startKey === endKey || ev.showWithoutTime) {
let group = groupMap.get(startKey);
if (!group) {
group = { date: start, dateKey: startKey, events: [] };
groupMap.set(startKey, group);
groups.push(group);
}
group.events.push(ev);
} else {
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 */ }
});
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
return groups;
}, [events]);
const formatDateHeader = (date: Date): string => {
if (isToday(date)) return t("events.today_header");
if (isTomorrow(date)) return t("events.tomorrow_header");
return intlFormatter.dateTime(date, { weekday: "long", month: "long", day: "numeric" });
};
const formatTime = (date: Date): string => {
if (timeFormat === "12h") {
return intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true });
}
return format(date, "HH:mm");
};
if (grouped.length === 0) {
return (
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground">
<CalendarIcon className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">{t("events.no_events")}</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
{grouped.map((group) => (
<div key={group.dateKey}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
<span className={cn(
"text-sm font-medium",
isToday(group.date) && "text-primary"
)}>
{formatDateHeader(group.date)}
</span>
<span className="text-xs text-muted-foreground ml-2">
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
<div className="divide-y divide-border">
{group.events.map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
const calendar = calendarMap.get(calId);
const color = getEventColor(ev, calendar);
const start = parseISO(ev.start);
const durMin = parseDuration(ev.duration);
const end = new Date(start.getTime() + durMin * 60000);
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
return (
<button
key={ev.id}
onClick={() => onSelectEvent(ev)}
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
{ev.showWithoutTime ? (
<span className="text-xs font-medium text-muted-foreground">
{t("events.all_day")}
</span>
) : (
<>
<span className="text-sm font-medium">{formatTime(start)}</span>
<span className="text-xs text-muted-foreground">{formatTime(end)}</span>
</>
)}
</div>
<div
className="w-1 self-stretch rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{ev.title || t("events.no_title")}
</div>
{locationName && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<MapPin className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{locationName}</span>
</div>
)}
{getParticipantCount(ev) > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Users className="w-3 h-3 flex-shrink-0" />
<span>{getParticipantCount(ev)}</span>
</div>
)}
{calendar && (
<div className="text-xs text-muted-foreground mt-0.5">
{calendar.name}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
);
}