Files
SRCmail/components/calendar/calendar-week-view.tsx
T
Linus Rath b5172802bb refactor: update styling and color classes across components
- Changed error message styling in ICalImportModal to use new color classes.
- Updated task completion styling in TaskListView to use new success color classes.
- Modified selected styling in ContactListItem tests to reflect new background class.
- Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes.
- Refactored background and border colors in CalendarInvitationBanner for various statuses.
- Updated email list item and viewer components to use new warning and success color classes.
- Refined styling for unread indicators in email components.
- Enhanced error fallback styling in error components to use new warning color classes.
- Updated filter rule modal button hover styles to use new destructive color classes.
- Added experimental feature descriptions in Plugins and Themes settings.
- Refined vacation settings validation warning styling to use new warning color classes.
- Updated toast component styles to use new color classes for different states.
- Introduced a new built-in theme 'Qui' with specific color variables.
- Adjusted email security status colors to use new warning color classes.
2026-03-25 23:10:47 +01:00

485 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
} from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarWeekViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
tasks?: CalendarTask[];
onToggleTaskComplete?: (task: CalendarTask) => void;
}
const HOUR_HEIGHT = 60;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
export function CalendarWeekView({
selectedDate,
events,
calendars,
onSelectDate,
onSelectEvent,
onHoverEvent,
onHoverLeave,
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
pendingPreview,
tasks,
onToggleTaskComplete,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart, isMobile]);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
calendars.forEach((c) => map.set(c.id, c));
return map;
}, [calendars]);
const timedEvents = useMemo(() => {
const timed: Map<string, CalendarEvent[]> = new Map();
events.forEach((ev) => {
try {
const { startDay, endDay } = getEventDayBounds(ev);
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
if (!ev.showWithoutTime) {
const arr = timed.get(key) || [];
arr.push(ev);
timed.set(key, arr);
}
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip invalid dates */ }
});
return timed;
}, [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]);
// Tasks grouped by day for the week
const tasksByDay = useMemo(() => {
if (!tasks?.length) return new Map<string, CalendarTask[]>();
const map = new Map<string, CalendarTask[]>();
for (const task of tasks) {
if (!task.due) continue;
try {
const key = format(parseISO(task.due), "yyyy-MM-dd");
const existing = map.get(key) || [];
existing.push(task);
map.set(key, existing);
} catch { /* skip */ }
}
return map;
}, [tasks]);
// Max tasks on any single day in this week
const taskRowCount = useMemo(() => {
let max = 0;
for (const day of weekDays) {
const key = format(day, "yyyy-MM-dd");
const count = tasksByDay.get(key)?.length ?? 0;
if (count > max) max = count;
}
return max;
}, [tasksByDay, weekDays]);
const hasAllDay = useMemo(() => {
return allDaySegments.length > 0 || taskRowCount > 0;
}, [allDaySegments, taskRowCount]);
useEffect(() => {
if (scrollRef.current) {
const now = new Date();
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
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 {
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
calendars,
onCreateRange: onCreateAtTime,
errorMessages: {
resize: t("notifications.event_resize_error"),
move: t("notifications.event_move_error"),
created: t("notifications.event_created"),
error: t("notifications.event_error"),
},
});
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 colCount = isMobile ? 3 : 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<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")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
</div>
<div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{weekDays.map((day) => (
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
))}
<div className="absolute inset-0 pointer-events-none">
{allDaySegments.map((segment) => {
const calId = getPrimaryCalendarId(segment.event);
return (
<div
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
width: `calc(${(segment.span / colCount) * 100}% - 2px)`,
top: segment.row * 24 + 2,
height: 20,
}}
>
<EventCard
event={segment.event}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="span"
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
/>
</div>
);
})}
</div>
{/* Task chips in all-day area */}
{taskRowCount > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
{weekDays.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
return dayTasks.map((task, taskIndex) => {
const isCompleted = task.progress === "completed";
const cal = calendars.find(c => task.calendarIds[c.id]);
const color = cal?.color || "#3b82f6";
return (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: taskIndex * 24,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
onClick={() => onToggleTaskComplete?.(task)}
>
<span className={cn(
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
isCompleted ? "bg-success border-success" : "border-current"
)}>
{isCompleted && <Check className="h-2 w-2 text-white" />}
</span>
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
{task.title}
</span>
</div>
</div>
);
});
})}
</div>
)}
</div>
</div>
)}
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
<button
key={day.toISOString()}
onClick={() => onSelectDate(day)}
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
>
<div className="text-[10px] text-muted-foreground uppercase">
{intlFormatter.dateTime(day, { weekday: "short" })}
</div>
<div className={cn(
"inline-flex items-center justify-center w-7 h-7 rounded-full text-sm",
todayCol && "bg-primary text-primary-foreground",
selected && !todayCol && "bg-accent text-accent-foreground"
)}>
{format(day, "d")}
</div>
</button>
);
})}
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{formatHour(h)}
</span>
)}
</div>
))}
</div>
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
const todayCol = isToday(day);
const layouted = layoutOverlappingEvents(dayEvents);
return (
<div
key={key}
className="relative border-r border-border last:border-r-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
onPointerMove={handleGridPointerMove}
onPointerUp={handleGridPointerUp}
onDragOver={(e) => handleColumnDragOver(e, key)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
>
{HOURS.map((h) => (
<div
key={h}
role="gridcell"
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
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 baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
const leftPct = (column / totalColumns) * 100;
const widthPct = (1 / totalColumns) * 100;
return (
<div
key={ev.id}
className="absolute z-10 group/event"
data-calendar-event
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
>
<EventCard
event={ev}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="block"
onClick={(rect) => onSelectEvent(ev, rect)}
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
onMouseLeave={onHoverLeave}
draggable
/>
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
</div>
</div>
);
})}
{todayCol && (
<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 h-2 rounded-full bg-destructive -ml-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
)}
{quickCreate?.dayKey === key && (
<QuickEventInput
top={quickCreate.top}
onSubmit={handleQuickCreateSubmit}
onCancel={handleQuickCreateCancel}
/>
)}
{dragCreate?.dayKey === key && (
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} {formatSnapTime(dragCreate.endMinutes, timeFormat)}
</div>
</div>
)}
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
{formatSnapTime(dropTarget.minutes, timeFormat)}
</div>
</div>
)}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}