213 lines
7.7 KiB
TypeScript
213 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useRef, useEffect, useCallback } from "react";
|
|
import { useTranslations, useFormatter } from "next-intl";
|
|
import { format, isToday, isTomorrow, startOfDay } from "date-fns";
|
|
import { MapPin, Users } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { parseDuration, getEventColor } from "./event-card";
|
|
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
|
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, anchorRect: DOMRect) => void;
|
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
|
onHoverLeave?: () => void;
|
|
timeFormat?: "12h" | "24h";
|
|
}
|
|
|
|
interface DayGroup {
|
|
date: Date;
|
|
dateKey: string;
|
|
events: CalendarEvent[];
|
|
}
|
|
|
|
export function CalendarAgendaView({
|
|
selectedDate,
|
|
events,
|
|
calendars,
|
|
onSelectEvent,
|
|
onHoverEvent,
|
|
onHoverLeave,
|
|
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 todayRef = useRef<HTMLDivElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const grouped = useMemo(() => {
|
|
const sorted = [...events].sort((a, b) =>
|
|
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
|
|
);
|
|
|
|
const groups: DayGroup[] = [];
|
|
const groupMap = new Map<string, DayGroup>();
|
|
|
|
sorted.forEach((ev) => {
|
|
try {
|
|
const { startDay, endDay } = getEventDayBounds(ev);
|
|
const cursor = new Date(startDay);
|
|
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 */ }
|
|
});
|
|
|
|
// Always include today's date in the groups so the view has a "Today" anchor
|
|
const todayKey = format(new Date(), "yyyy-MM-dd");
|
|
if (!groupMap.has(todayKey)) {
|
|
const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] };
|
|
groupMap.set(todayKey, todayGroup);
|
|
groups.push(todayGroup);
|
|
}
|
|
|
|
groups.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
return groups;
|
|
}, [events]);
|
|
|
|
// Auto-scroll to today's section on mount and when selectedDate changes to today
|
|
const scrollToToday = useCallback(() => {
|
|
if (todayRef.current) {
|
|
todayRef.current.scrollIntoView({ block: "start" });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Scroll to today on mount
|
|
const frame = requestAnimationFrame(scrollToToday);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [scrollToToday]);
|
|
|
|
useEffect(() => {
|
|
// Scroll to today when selectedDate changes to today
|
|
if (isToday(selectedDate)) {
|
|
scrollToToday();
|
|
}
|
|
}, [selectedDate, scrollToToday]);
|
|
|
|
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");
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
|
{grouped.map((group) => (
|
|
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
|
|
<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>
|
|
|
|
{group.events.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
{t("events.no_events")}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{group.events.map((ev) => {
|
|
const calId = getPrimaryCalendarId(ev);
|
|
const calendar = calId ? calendarMap.get(calId) : undefined;
|
|
const color = getEventColor(ev, calendar);
|
|
const start = getEventStartDate(ev);
|
|
const durMin = parseDuration(ev.duration);
|
|
const end = getEventEndDate(ev);
|
|
const locationName = ev.locations
|
|
? Object.values(ev.locations)[0]?.name
|
|
: null;
|
|
|
|
return (
|
|
<button
|
|
key={ev.id}
|
|
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
|
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
|
onMouseLeave={() => onHoverLeave?.()}
|
|
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
|
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
|
>
|
|
<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>
|
|
);
|
|
}
|