openclaw/apps/web/app/components/workspace/object-calendar.tsx

771 lines
25 KiB
TypeScript

"use client";
import { useMemo, useState, useCallback, useRef } from "react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
startOfDay, addDays, addWeeks, addMonths, addYears,
subDays, subWeeks, subMonths, subYears,
eachDayOfInterval, format, isSameDay, isSameMonth,
isToday, parseISO, getHours, getMinutes, differenceInMinutes,
startOfYear, eachMonthOfInterval, endOfYear,
addMinutes,
} from "date-fns";
import type { CalendarMode } from "@/lib/object-filters";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
};
type CalendarEvent = {
id: string;
title: string;
date: Date;
endDate?: Date;
entry: Record<string, unknown>;
color?: string;
};
export type CalendarDateChangePayload = {
entryId: string;
newDate: string;
newEndDate?: string;
};
type ObjectCalendarProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
dateField: string;
endDateField?: string;
mode: CalendarMode;
onModeChange: (mode: CalendarMode) => void;
members?: Array<{ id: string; name: string }>;
onEntryClick?: (entryId: string) => void;
onEntryDateChange?: (payload: CalendarDateChangePayload) => void;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function safeString(val: unknown): string {
if (val == null) {return "";}
if (typeof val === "string") {return val;}
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
if (typeof val === "object") {return JSON.stringify(val);}
return "";
}
function resolveTitle(entry: Record<string, unknown>, fields: Field[]): string {
const titleField = fields.find((f) =>
f.type === "text" && /name|title/i.test(f.name),
) ?? fields.find((f) => f.type === "text");
return titleField ? safeString(entry[titleField.name]) : safeString(entry.id);
}
function resolveColor(entry: Record<string, unknown>, fields: Field[]): string | undefined {
const enumField = fields.find((f) => f.type === "enum" && f.enum_colors?.length);
if (!enumField) {return undefined;}
const val = safeString(entry[enumField.name]);
const idx = enumField.enum_values?.indexOf(val) ?? -1;
return idx >= 0 ? enumField.enum_colors![idx] : undefined;
}
function parseEvents(
entries: Record<string, unknown>[],
fields: Field[],
dateField: string,
endDateField?: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
for (const entry of entries) {
const raw = safeString(entry[dateField]);
if (!raw) {continue;}
try {
const date = parseISO(raw);
if (Number.isNaN(date.getTime())) {continue;}
let endDate: Date | undefined;
if (endDateField) {
const rawEnd = safeString(entry[endDateField]);
if (rawEnd) {
const ed = parseISO(rawEnd);
if (!Number.isNaN(ed.getTime())) {endDate = ed;}
}
}
events.push({
id: safeString(entry.entry_id ?? entry.id),
title: resolveTitle(entry, fields),
date,
endDate,
entry,
color: resolveColor(entry, fields),
});
} catch { /* skip unparseable dates */ }
}
return events;
}
const HOUR_HEIGHT = 60;
const DAY_START_HOUR = 0;
const DAY_END_HOUR = 24;
function toISODate(d: Date): string { return format(d, "yyyy-MM-dd"); }
function toISODateTime(d: Date): string { return d.toISOString(); }
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function navigateDate(date: Date, mode: CalendarMode, direction: 1 | -1): Date {
switch (mode) {
case "day": return direction === 1 ? addDays(date, 1) : subDays(date, 1);
case "week": return direction === 1 ? addWeeks(date, 1) : subWeeks(date, 1);
case "month": return direction === 1 ? addMonths(date, 1) : subMonths(date, 1);
case "year": return direction === 1 ? addYears(date, 1) : subYears(date, 1);
}
}
function formatDateHeader(date: Date, mode: CalendarMode): string {
switch (mode) {
case "day": return format(date, "EEEE, MMMM d, yyyy");
case "week": {
const start = startOfWeek(date, { weekStartsOn: 1 });
const end = endOfWeek(date, { weekStartsOn: 1 });
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
}
case "month": return format(date, "MMMM yyyy");
case "year": return format(date, "yyyy");
}
}
// ---------------------------------------------------------------------------
// EventChip (used in month view, supports drag)
// ---------------------------------------------------------------------------
function EventChip({
event,
compact,
onClick,
draggable,
onDragStart,
}: {
event: CalendarEvent;
compact?: boolean;
onClick?: () => void;
draggable?: boolean;
onDragStart?: (e: React.DragEvent) => void;
}) {
const bg = event.color ?? "var(--color-accent)";
return (
<button
type="button"
draggable={draggable}
onDragStart={onDragStart}
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
className={`text-left rounded px-1.5 truncate cursor-pointer transition-opacity hover:opacity-80 ${compact ? "text-[10px] py-0" : "text-[11px] py-0.5"}`}
style={{
backgroundColor: bg,
color: "#fff",
border: "none",
width: "100%",
lineHeight: compact ? "16px" : "18px",
}}
title={event.title}
>
{!compact && <span className="opacity-70 mr-1">{format(event.date, "HH:mm")}</span>}
{event.title || "Untitled"}
</button>
);
}
// ---------------------------------------------------------------------------
// Month View (drag events between day cells)
// ---------------------------------------------------------------------------
function MonthView({
date,
events,
onEntryClick,
onEntryDateChange,
}: {
date: Date;
events: CalendarEvent[];
onEntryClick?: (id: string) => void;
onEntryDateChange?: (payload: CalendarDateChangePayload) => void;
}) {
const [dragOverDay, setDragOverDay] = useState<string | null>(null);
const dragEventRef = useRef<CalendarEvent | null>(null);
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: calStart, end: calEnd });
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const ev of events) {
const key = format(ev.date, "yyyy-MM-dd");
if (!map.has(key)) {map.set(key, []);}
map.get(key)!.push(ev);
if (ev.endDate && !isSameDay(ev.date, ev.endDate)) {
const spanDays = eachDayOfInterval({ start: addDays(ev.date, 1), end: ev.endDate });
for (const d of spanDays) {
const dk = format(d, "yyyy-MM-dd");
if (!map.has(dk)) {map.set(dk, []);}
map.get(dk)!.push(ev);
}
}
}
return map;
}, [events]);
const handleDragStart = useCallback((ev: CalendarEvent) => {
dragEventRef.current = ev;
}, []);
const handleDragOver = useCallback((e: React.DragEvent, dayKey: string) => {
e.preventDefault();
setDragOverDay(dayKey);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetDay: Date) => {
e.preventDefault();
setDragOverDay(null);
const ev = dragEventRef.current;
if (!ev || !onEntryDateChange) {return;}
if (isSameDay(ev.date, targetDay)) {return;}
const dayDelta = differenceInMinutes(startOfDay(targetDay), startOfDay(ev.date));
const newDate = addMinutes(ev.date, dayDelta);
const payload: CalendarDateChangePayload = {
entryId: ev.id,
newDate: toISODate(newDate),
};
if (ev.endDate) {
payload.newEndDate = toISODate(addMinutes(ev.endDate, dayDelta));
}
onEntryDateChange(payload);
dragEventRef.current = null;
}, [onEntryDateChange]);
const handleDragLeave = useCallback(() => setDragOverDay(null), []);
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
return (
<div className="w-full">
<div className="grid grid-cols-7 border-b" style={{ borderColor: "var(--color-border)" }}>
{weekdays.map((wd) => (
<div key={wd} className="text-center text-[11px] font-medium py-2" style={{ color: "var(--color-text-muted)" }}>
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDay.get(key) ?? [];
const inMonth = isSameMonth(day, date);
const today = isToday(day);
const isDragTarget = dragOverDay === key;
return (
<div
key={key}
className="min-h-[100px] border-b border-r p-1 transition-colors"
style={{
borderColor: "var(--color-border)",
opacity: inMonth ? 1 : 0.4,
background: isDragTarget ? "var(--color-accent-light, rgba(99,102,241,0.12))" : today ? "var(--color-surface-hover)" : undefined,
}}
onDragOver={(e) => handleDragOver(e, key)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, day)}
>
<div
className={`text-[11px] mb-0.5 ${today ? "font-bold" : ""}`}
style={{ color: today ? "var(--color-accent)" : "var(--color-text-muted)" }}
>
{format(day, "d")}
</div>
<div className="flex flex-col gap-0.5">
{dayEvents.slice(0, 3).map((ev) => (
<EventChip
key={ev.id}
event={ev}
compact
onClick={() => onEntryClick?.(ev.id)}
draggable={!!onEntryDateChange}
onDragStart={() => handleDragStart(ev)}
/>
))}
{dayEvents.length > 3 && (
<span className="text-[10px] pl-1" style={{ color: "var(--color-text-muted)" }}>
+{dayEvents.length - 3} more
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Time-grid drag (used by Week and Day views)
// ---------------------------------------------------------------------------
function DraggableTimeEvent({
event,
hourHeight,
onEntryClick,
onEntryDateChange,
}: {
event: CalendarEvent;
hourHeight: number;
onEntryClick?: (id: string) => void;
onEntryDateChange?: (payload: CalendarDateChangePayload) => void;
}) {
const elRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<"move" | "resize" | null>(null);
const dragStartRef = useRef({ y: 0, startMinute: 0, duration: 0 });
const topOffset = (getMinutes(event.date) / 60) * hourHeight;
const duration = event.endDate
? Math.max(differenceInMinutes(event.endDate, event.date), 15)
: 30;
const height = (duration / 60) * hourHeight;
const [offsetY, setOffsetY] = useState(0);
const [resizeDelta, setResizeDelta] = useState(0);
const handleMoveStart = useCallback((e: React.PointerEvent) => {
if (!onEntryDateChange) {return;}
e.preventDefault();
e.stopPropagation();
setDragging("move");
dragStartRef.current = { y: e.clientY, startMinute: getHours(event.date) * 60 + getMinutes(event.date), duration };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [onEntryDateChange, event.date, duration]);
const handleResizeStart = useCallback((e: React.PointerEvent) => {
if (!onEntryDateChange) {return;}
e.preventDefault();
e.stopPropagation();
setDragging("resize");
dragStartRef.current = { y: e.clientY, startMinute: 0, duration };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [onEntryDateChange, duration]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging) {return;}
const deltaY = e.clientY - dragStartRef.current.y;
if (dragging === "move") {
setOffsetY(deltaY);
} else {
setResizeDelta(deltaY);
}
}, [dragging]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragging || !onEntryDateChange) {return;}
const deltaY = e.clientY - dragStartRef.current.y;
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15; // snap to 15min
if (dragging === "move" && deltaMinutes !== 0) {
const newDate = addMinutes(event.date, deltaMinutes);
const payload: CalendarDateChangePayload = {
entryId: event.id,
newDate: toISODateTime(newDate),
};
if (event.endDate) {
payload.newEndDate = toISODateTime(addMinutes(event.endDate, deltaMinutes));
}
onEntryDateChange(payload);
} else if (dragging === "resize" && deltaMinutes !== 0) {
const newEndDate = addMinutes(event.endDate ?? addMinutes(event.date, duration), deltaMinutes);
if (newEndDate > event.date) {
onEntryDateChange({
entryId: event.id,
newDate: toISODateTime(event.date),
newEndDate: toISODateTime(newEndDate),
});
}
}
setDragging(null);
setOffsetY(0);
setResizeDelta(0);
}, [dragging, onEntryDateChange, event, hourHeight, duration]);
const renderTop = topOffset + (dragging === "move" ? offsetY : 0);
const renderHeight = Math.max(height + (dragging === "resize" ? resizeDelta : 0), 15);
return (
<div
ref={elRef}
className="absolute left-0.5 right-0.5 rounded px-1 text-[10px] truncate select-none"
style={{
top: renderTop,
height: Math.min(renderHeight, hourHeight * 6),
backgroundColor: event.color ?? "var(--color-accent)",
color: "#fff",
lineHeight: "16px",
zIndex: dragging ? 20 : 2,
cursor: onEntryDateChange ? (dragging === "move" ? "grabbing" : "grab") : "pointer",
opacity: dragging ? 0.85 : 1,
transition: dragging ? "none" : "top 0.15s, height 0.15s",
}}
onPointerDown={handleMoveStart}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => { if (!dragging) { e.stopPropagation(); onEntryClick?.(event.id); } }}
title={event.title}
>
{event.title || "Untitled"}
{/* Resize handle at bottom */}
{onEntryDateChange && (
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize"
style={{ borderRadius: "0 0 4px 4px" }}
onPointerDown={handleResizeStart}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Week View
// ---------------------------------------------------------------------------
function WeekView({
date,
events,
onEntryClick,
onEntryDateChange,
}: {
date: Date;
events: CalendarEvent[];
onEntryClick?: (id: string) => void;
onEntryDateChange?: (payload: CalendarDateChangePayload) => void;
}) {
const weekStart = startOfWeek(date, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: weekStart, end: addDays(weekStart, 6) });
const hours = Array.from({ length: DAY_END_HOUR - DAY_START_HOUR }, (_, i) => i + DAY_START_HOUR);
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const ev of events) {
const key = format(ev.date, "yyyy-MM-dd");
if (!map.has(key)) {map.set(key, []);}
map.get(key)!.push(ev);
}
return map;
}, [events]);
return (
<div className="w-full overflow-auto" style={{ maxHeight: "calc(100vh - 280px)" }}>
<div className="grid" style={{ gridTemplateColumns: "50px repeat(7, 1fr)" }}>
<div className="sticky top-0 z-10" style={{ background: "var(--color-bg)" }} />
{days.map((day) => (
<div
key={day.toISOString()}
className="sticky top-0 z-10 text-center py-2 border-b border-l text-[12px] font-medium"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
color: isToday(day) ? "var(--color-accent)" : "var(--color-text)",
}}
>
<div>{format(day, "EEE")}</div>
<div className={`text-lg ${isToday(day) ? "font-bold" : ""}`}>{format(day, "d")}</div>
</div>
))}
{hours.map((hour) => (
<>
<div
key={`label-${hour}`}
className="text-[10px] text-right pr-2 pt-0.5"
style={{ color: "var(--color-text-muted)", height: HOUR_HEIGHT }}
>
{format(new Date(2000, 0, 1, hour), "ha")}
</div>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = (eventsByDay.get(key) ?? []).filter(
(ev) => getHours(ev.date) === hour,
);
return (
<div
key={`${key}-${hour}`}
className="border-b border-l relative"
style={{ borderColor: "var(--color-border)", height: HOUR_HEIGHT }}
>
{dayEvents.map((ev) => (
<DraggableTimeEvent
key={ev.id}
event={ev}
hourHeight={HOUR_HEIGHT}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
))}
</div>
);
})}
</>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Day View
// ---------------------------------------------------------------------------
function DayView({
date,
events,
onEntryClick,
onEntryDateChange,
}: {
date: Date;
events: CalendarEvent[];
onEntryClick?: (id: string) => void;
onEntryDateChange?: (payload: CalendarDateChangePayload) => void;
}) {
const hours = Array.from({ length: DAY_END_HOUR - DAY_START_HOUR }, (_, i) => i + DAY_START_HOUR);
const dayEvents = useMemo(
() => events.filter((ev) => isSameDay(ev.date, date)),
[events, date],
);
return (
<div className="w-full overflow-auto" style={{ maxHeight: "calc(100vh - 280px)" }}>
<div className="grid" style={{ gridTemplateColumns: "60px 1fr" }}>
{hours.map((hour) => {
const hourEvents = dayEvents.filter((ev) => getHours(ev.date) === hour);
return (
<>
<div
key={`label-${hour}`}
className="text-[11px] text-right pr-3 pt-0.5"
style={{ color: "var(--color-text-muted)", height: HOUR_HEIGHT }}
>
{format(new Date(2000, 0, 1, hour), "h:mm a")}
</div>
<div
key={`cell-${hour}`}
className="border-b border-l relative"
style={{ borderColor: "var(--color-border)", height: HOUR_HEIGHT }}
>
{hourEvents.map((ev) => (
<DraggableTimeEvent
key={ev.id}
event={ev}
hourHeight={HOUR_HEIGHT}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
))}
</div>
</>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Year View (no drag — too compact)
// ---------------------------------------------------------------------------
function YearView({
date,
events,
}: {
date: Date;
events: CalendarEvent[];
}) {
const yearStart = startOfYear(date);
const yearEnd = endOfYear(date);
const months = eachMonthOfInterval({ start: yearStart, end: yearEnd });
const eventsByMonth = useMemo(() => {
const map = new Map<number, CalendarEvent[]>();
for (const ev of events) {
const m = ev.date.getMonth();
if (!map.has(m)) {map.set(m, []);}
map.get(m)!.push(ev);
}
return map;
}, [events]);
return (
<div className="grid grid-cols-3 md:grid-cols-4 gap-4 p-2">
{months.map((month) => {
const mStart = startOfMonth(month);
const mEnd = endOfMonth(month);
const calStart = startOfWeek(mStart, { weekStartsOn: 1 });
const calEnd = endOfWeek(mEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: calStart, end: calEnd });
const monthEvents = eventsByMonth.get(month.getMonth()) ?? [];
return (
<div
key={month.toISOString()}
className="rounded-lg border p-2"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="text-[12px] font-semibold mb-1" style={{ color: "var(--color-text)" }}>
{format(month, "MMMM")}
</div>
<div className="grid grid-cols-7 gap-px">
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<div key={i} className="text-[9px] text-center" style={{ color: "var(--color-text-muted)" }}>{d}</div>
))}
{days.map((day) => {
const inMonth = isSameMonth(day, month);
const today = isToday(day);
const hasEvents = monthEvents.some((ev) => isSameDay(ev.date, day));
return (
<div
key={day.toISOString()}
className="text-[9px] text-center py-0.5 relative"
style={{
color: !inMonth ? "transparent" : today ? "var(--color-accent)" : "var(--color-text)",
fontWeight: today ? 700 : 400,
}}
>
{format(day, "d")}
{hasEvents && inMonth && (
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full"
style={{ backgroundColor: "var(--color-accent)" }}
/>
)}
</div>
);
})}
</div>
{monthEvents.length > 0 && (
<div className="mt-1 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{monthEvents.length} event{monthEvents.length !== 1 ? "s" : ""}
</div>
)}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function CalendarEmptyState({ reason }: { reason: "no-date-field" | "no-events" }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="18" height="18" x="3" y="4" rx="2" /><path d="M16 2v4" /><path d="M8 2v4" /><path d="M3 10h18" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{reason === "no-date-field"
? "No date field configured for calendar view. Open view settings to select one."
: "No events to display in this range."}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ObjectCalendar({
objectName: _objectName,
fields,
entries,
dateField,
endDateField,
mode,
onModeChange,
members: _members,
onEntryClick,
onEntryDateChange,
}: ObjectCalendarProps) {
const [currentDate, setCurrentDate] = useState(() => new Date());
const events = useMemo(
() => parseEvents(entries, fields, dateField, endDateField),
[entries, fields, dateField, endDateField],
);
const handlePrev = useCallback(() => setCurrentDate((d) => navigateDate(d, mode, -1)), [mode]);
const handleNext = useCallback(() => setCurrentDate((d) => navigateDate(d, mode, 1)), [mode]);
const handleToday = useCallback(() => setCurrentDate(new Date()), []);
if (!dateField) {return <CalendarEmptyState reason="no-date-field" />;}
const modes: CalendarMode[] = ["day", "week", "month", "year"];
return (
<div className="w-full">
<div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-2">
<button type="button" onClick={handlePrev} className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6" /></svg>
</button>
<button type="button" onClick={handleNext} className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m9 18 6-6-6-6" /></svg>
</button>
<button type="button" onClick={handleToday} className="text-[12px] px-2.5 py-1 rounded-md border hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)", borderColor: "var(--color-border)" }}>
Today
</button>
<h3 className="text-sm font-semibold ml-2" style={{ color: "var(--color-text)" }}>
{formatDateHeader(currentDate, mode)}
</h3>
</div>
<div className="flex rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)" }}>
{modes.map((m) => (
<button
key={m}
type="button"
onClick={() => onModeChange(m)}
className="text-[11px] px-3 py-1 capitalize transition-colors"
style={{
background: m === mode ? "var(--color-accent)" : "var(--color-surface)",
color: m === mode ? "#fff" : "var(--color-text-muted)",
borderRight: m !== "year" ? "1px solid var(--color-border)" : undefined,
}}
>
{m}
</button>
))}
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}>
{mode === "month" && <MonthView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "week" && <WeekView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "day" && <DayView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "year" && <YearView date={currentDate} events={events} />}
</div>
</div>
);
}