feat: add JMAP Calendar integration with month/week/day/agenda views
Full calendar support via JMAP Calendars (RFC 8984): - Event create/edit/delete with recurrence rules and reminders - Multi-day event spanning, column-based overlap layout - Locale-aware date formatting, first day of week and time format settings - Real-time updates via push notifications - ARIA accessibility, input validation, color sanitization - Keyboard shortcuts, mobile touch targets, focus trap - ICU pluralization for all 8 supported languages
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||
const sorted = [...events].sort((a, b) => {
|
||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
const start = parseISO(event.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = 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 });
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
columns.push([{ event, end: endMin }]);
|
||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const total = columns.length;
|
||||
result.forEach(r => r.totalColumns = total);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CalendarDayView({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
calendars.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [calendars]);
|
||||
|
||||
const { timedEvents, allDayEvents } = useMemo(() => {
|
||||
const timed: CalendarEvent[] = [];
|
||||
const allDay: CalendarEvent[] = [];
|
||||
events.forEach((ev) => {
|
||||
try {
|
||||
const start = new Date(ev.start);
|
||||
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 spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime();
|
||||
if (!spansThisDay) return;
|
||||
|
||||
if (ev.showWithoutTime) allDay.push(ev);
|
||||
else timed.push(ev);
|
||||
} catch { /* skip invalid dates */ }
|
||||
});
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const today = isToday(selectedDate);
|
||||
const [nowMinutes, setNowMinutes] = useState(() => {
|
||||
const now = new Date();
|
||||
return now.getHours() * 60 + now.getMinutes();
|
||||
});
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes());
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
if (timeFormat === "12h") {
|
||||
const d = new Date(2000, 0, 1, h);
|
||||
return intlFormatter.dateTime(d, { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||
};
|
||||
|
||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
||||
|
||||
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="px-4 py-3 border-b border-border">
|
||||
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
|
||||
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-16 flex-shrink-0">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-xs text-muted-foreground text-right pr-3"
|
||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
||||
>
|
||||
{formatHour(h)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative border-l border-border" role="row">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
role="gridcell"
|
||||
aria-label={formatHour(h)}
|
||||
onClick={() => {
|
||||
const d = new Date(selectedDate);
|
||||
d.setHours(h, 0, 0, 0);
|
||||
onCreateAtTime(d);
|
||||
}}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns }) => {
|
||||
const start = parseISO(ev.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10"
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="block"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{today && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user