"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, MotionConfig, useReducedMotion } from "motion/react";
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Menu, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
addDays,
addMinutes,
addMonths,
atMinutes,
dateKey,
expandEvents,
minutesOfDay,
monthMatrix,
parseLocal,
rangeTitle,
startOfDay,
startOfWeek,
toLocalISO,
} from "./cal-utils";
import { IconButton, focusRing, useMinWidth } from "./calendar-ui";
import { DEFAULT_CALENDARS, SEED_EVENTS, SEED_NOW } from "./data";
import { EventDialog, EventPopover, type EditTarget } from "./event-dialogs";
import { MonthView } from "./month-view";
import { CalendarSidebar } from "./sidebar";
import { TimeGrid, type GridDraft } from "./time-grid";
import type { CalendarEvent, CalendarSource, CalendarView, Occurrence } from "./types";
export type { CalendarEvent, CalendarSource, CalendarView, RepeatRule, CalendarColor } from "./types";
export interface CalendarAppProps {
/** Events to show. Defaults to a seeded, deterministic demo schedule. */
initialEvents?: CalendarEvent[];
calendars?: CalendarSource[];
/** Calendar ids hidden at start. */
initialHidden?: string[];
initialView?: CalendarView;
/** "YYYY-MM-DD" or Date to open on. Defaults to today. */
initialDate?: string | Date;
/** Reference "now" (current-time line, today highlight). Defaults to the seed time (demo) or the client clock (your data). */
now?: string | Date;
weekStartsOn?: 0 | 1;
/** Pixel height of one hour on the time grid. */
hourHeight?: number;
appName?: string;
onEventCreate?: (event: CalendarEvent) => void;
onEventUpdate?: (event: CalendarEvent) => void;
onEventDelete?: (event: CalendarEvent) => void;
/** Fires with the full list after every change. */
onChange?: (events: CalendarEvent[]) => void;
className?: string;
}
type Toast = { id: number; text: string; undo?: () => void };
type PopoverState = { occ: Occurrence; anchor: { top: number; left: number; right: number; bottom: number } } | null;
let seq = 0;
const newId = () => `evt-${Date.now().toString(36)}-${(++seq).toString(36)}`;
const VIEWS: { id: CalendarView; label: string; key: string }[] = [
{ id: "month", label: "Month", key: "M" },
{ id: "week", label: "Week", key: "W" },
{ id: "day", label: "Day", key: "D" },
];
export function CalendarApp({
initialEvents,
calendars = DEFAULT_CALENDARS,
initialHidden = [],
initialView,
initialDate,
now: nowProp,
weekStartsOn = 0,
hourHeight = 48,
appName = "Lumen Calendar",
onEventCreate,
onEventUpdate,
onEventDelete,
onChange,
className,
}: CalendarAppProps) {
const reduced = useReducedMotion();
const [events, setEvents] = React.useState<CalendarEvent[]>(initialEvents ?? SEED_EVENTS);
const [hidden, setHidden] = React.useState<Set<string>>(() => new Set(initialHidden));
const [base, setBase] = React.useState(() => parseLocal(typeof nowProp === "string" ? nowProp : nowProp ? toLocalISO(nowProp) : SEED_NOW));
const [elapsed, setElapsed] = React.useState(0);
const now = React.useMemo(() => new Date(base.getTime() + elapsed), [base, elapsed]);
const [view, setView] = React.useState<CalendarView>(initialView ?? "week");
const [date, setDate] = React.useState<Date>(() =>
startOfDay(initialDate ? (typeof initialDate === "string" ? parseLocal(initialDate) : initialDate) : base),
);
const [dir, setDir] = React.useState(0);
const [popover, setPopover] = React.useState<PopoverState>(null);
const [edit, setEdit] = React.useState<EditTarget | null>(null);
const [draft, setDraft] = React.useState<GridDraft>(null);
const [drawer, setDrawer] = React.useState(false);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const rootRef = React.useRef<HTMLDivElement>(null);
const [bounds, setBounds] = React.useState({ width: 1280, height: 760 });
const scrollMemory = React.useRef<number | null>(null);
const wide = useMinWidth(640);
const lg = useMinWidth(1024);
const compact = !wide;
// Clock: anchored to the seed for the demo, real time for your own data.
React.useEffect(() => {
const t0 = Date.now();
if (!nowProp && initialEvents) {
const real = new Date();
setBase(real);
if (!initialDate) setDate(startOfDay(real));
}
const id = window.setInterval(() => setElapsed(Date.now() - t0), 30_000);
return () => window.clearInterval(id);
}, [nowProp, initialEvents, initialDate]);
// Phones open on the day view unless a view was requested.
React.useEffect(() => {
if (!initialView && !window.matchMedia("(min-width: 640px)").matches) setView("day");
}, [initialView]);
React.useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setBounds({ width: el.clientWidth, height: el.clientHeight }));
ro.observe(el);
return () => ro.disconnect();
}, []);
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const first = React.useRef(true);
React.useEffect(() => {
if (first.current) {
first.current = false;
return;
}
onChangeRef.current?.(events);
}, [events]);
const calById = React.useMemo(() => Object.fromEntries(calendars.map((c) => [c.id, c])), [calendars]);
const visibleEvents = React.useMemo(() => events.filter((e) => !hidden.has(e.calendarId) && calById[e.calendarId]), [events, hidden, calById]);
/* ------------------------------ range ------------------------------ */
const days = React.useMemo(() => {
if (view === "day") return [date];
if (view === "week") {
const s = startOfWeek(date, weekStartsOn);
return Array.from({ length: 7 }, (_, i) => addDays(s, i));
}
return monthMatrix(date, weekStartsOn);
}, [view, date, weekStartsOn]);
const from = days[0];
const to = addDays(days[days.length - 1], 1);
const occurrences = React.useMemo(() => expandEvents(visibleEvents, from, to), [visibleEvents, from, to]);
const busy = React.useMemo(() => {
const m = monthMatrix(date, weekStartsOn);
const occ = expandEvents(visibleEvents, addDays(m[0], -42), addDays(m[41], 43));
const s = new Set<string>();
for (const o of occ) s.add(dateKey(o.start));
return s;
}, [visibleEvents, date, weekStartsOn]);
const title = rangeTitle(view, date, weekStartsOn);
const shortTitle =
view === "day"
? date.toLocaleDateString("en-US", { month: "short", day: "numeric", weekday: "short" })
: title.replace(/([A-Z][a-z]{2})[a-z]+/g, "$1");
const rangeKey = `${view}-${dateKey(view === "month" ? new Date(date.getFullYear(), date.getMonth(), 1) : days[0])}`;
/* ------------------------------ toasts ------------------------------ */
const toast = React.useCallback((text: string, undo?: () => void) => {
const id = ++seq;
setToasts((ts) => [...ts.slice(-1), { id, text, undo }]);
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 5000);
}, []);
const dismiss = (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id));
/* ------------------------------ navigation ------------------------------ */
const navigate = (n: number) => {
setPopover(null);
setDir(n);
setDate((d) => (view === "month" ? addMonths(new Date(d.getFullYear(), d.getMonth(), 1), n) : addDays(d, n * (view === "week" ? 7 : 1))));
};
const goTo = (d: Date, v: CalendarView = view) => {
setPopover(null);
const target = startOfDay(d);
setDir(target > date ? 1 : target < date ? -1 : 0);
setDate(target);
setView(v);
setDrawer(false);
};
const goToday = () => goTo(now);
const switchView = (v: CalendarView) => {
if (v === view) return;
setPopover(null);
setDir(0);
setView(v);
};
/* ------------------------------ mutations ------------------------------ */
const patchEvent = (id: string, fn: (e: CalendarEvent) => CalendarEvent) => {
const cur = events.find((e) => e.id === id);
if (!cur) return;
const next = fn(cur);
setEvents((es) => es.map((e) => (e.id === id ? next : e)));
onEventUpdate?.(next);
};
const withUndo = (text: string, change: () => void) => {
const snapshot = events;
change();
toast(text, () => setEvents(snapshot));
};
const shift = (e: CalendarEvent, deltaMs: number, which: "both" | "end") => ({
...e,
start: which === "both" ? toLocalISO(new Date(parseLocal(e.start).getTime() + deltaMs)) : e.start,
end: toLocalISO(new Date(parseLocal(e.end).getTime() + deltaMs)),
});
const moveOcc = (occ: Occurrence, start: Date) => {
const delta = start.getTime() - occ.start.getTime();
const recurring = occ.event.repeat && occ.event.repeat !== "none";
setPopover(null);
withUndo(recurring ? "Moved all occurrences" : `Moved to ${start.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}${occ.event.allDay ? "" : `, ${start.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`}`, () => {
patchEvent(occ.event.id, (e) => shift(e, delta, "both"));
});
};
const resizeOcc = (occ: Occurrence, end: Date) => {
const delta = end.getTime() - occ.end.getTime();
setPopover(null);
withUndo(`Ends at ${end.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`, () => {
patchEvent(occ.event.id, (e) => shift(e, delta, "end"));
});
};
const defaultCalendar = () => calendars.find((c) => c.group !== "other" && !hidden.has(c.id))?.id ?? calendars[0]?.id ?? "default";
const startCreate = (start: Date, end: Date, allDay?: boolean) => {
setPopover(null);
const ev: CalendarEvent = { id: newId(), title: "", start: toLocalISO(start), end: toLocalISO(end), calendarId: defaultCalendar(), allDay: allDay || undefined, repeat: "none" };
setDraft({ start, end, allDay });
setEdit({ mode: "create", event: ev });
};
/** New 1-hour event: next full hour when looking at today, otherwise 9am. */
const createAtNextSlot = () => {
const isToday = dateKey(date) === dateKey(now);
const m = isToday ? Math.min(Math.ceil((minutesOfDay(now) + 1) / 60) * 60, 23 * 60) : 9 * 60;
const s = atMinutes(date, m);
startCreate(s, addMinutes(s, 60));
};
const save = (ev: CalendarEvent) => {
const isNew = edit?.mode === "create";
setEdit(null);
setDraft(null);
if (isNew) {
setEvents((es) => [...es, ev]);
onEventCreate?.(ev);
if (hidden.has(ev.calendarId)) setHidden((h) => new Set([...h].filter((x) => x !== ev.calendarId)));
toast(`Created “${ev.title || "(No title)"}”`);
const s = parseLocal(ev.start);
if (s < from || s >= to) goTo(s);
} else {
withUndo("Event saved", () => {
setEvents((es) => es.map((e) => (e.id === ev.id ? ev : e)));
});
onEventUpdate?.(ev);
}
};
const remove = (ev: CalendarEvent) => {
setEdit(null);
setPopover(null);
setDraft(null);
withUndo(`Deleted “${ev.title || "(No title)"}”`, () => setEvents((es) => es.filter((e) => e.id !== ev.id)));
onEventDelete?.(ev);
};
const duplicate = (occ: Occurrence) => {
setPopover(null);
const dur = occ.end.getTime() - occ.start.getTime();
const ev: CalendarEvent = { ...occ.event, id: newId(), title: occ.event.title ? `${occ.event.title} (copy)` : "", start: toLocalISO(occ.start), end: toLocalISO(new Date(occ.start.getTime() + dur)) };
setDraft({ start: occ.start, end: new Date(occ.start.getTime() + dur), allDay: occ.event.allDay });
setEdit({ mode: "create", event: ev });
};
const openOcc = (occ: Occurrence, el: HTMLElement) => {
const root = rootRef.current?.getBoundingClientRect();
const r = el.getBoundingClientRect();
if (!root) return;
if (popover?.occ.key === occ.key) return setPopover(null);
setPopover({ occ, anchor: { top: r.top - root.top, bottom: r.bottom - root.top, left: r.left - root.left, right: r.right - root.left } });
};
const editOcc = (occ: Occurrence) => {
setPopover(null);
setEdit({ mode: "edit", event: occ.event });
};
/* ------------------------------ keyboard ------------------------------ */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement;
if (e.key === "Escape") {
if (popover) return setPopover(null);
if (drawer) return setDrawer(false);
return;
}
if (edit || t.closest("input,textarea,select,[contenteditable]")) return;
const act = (fn: () => void) => {
e.preventDefault();
fn();
};
const k = e.key.toLowerCase();
if (e.key === "ArrowLeft") act(() => navigate(-1));
else if (e.key === "ArrowRight") act(() => navigate(1));
else if (k === "t") act(goToday);
else if (k === "n" || k === "c") act(createAtNextSlot);
else if (k === "m") act(() => switchView("month"));
else if (k === "w") act(() => switchView("week"));
else if (k === "d") act(() => switchView("day"));
};
});
React.useEffect(() => {
const on = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", on);
return () => window.removeEventListener("keydown", on);
}, []);
/* -------------------------------- render ------------------------------- */
const sidebar = (
<CalendarSidebar
selected={date}
today={now}
view={view}
weekStartsOn={weekStartsOn}
busy={busy}
onPick={(d) => goTo(d)}
calendars={calendars}
hidden={hidden}
onToggle={(id) =>
setHidden((h) => {
const n = new Set(h);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
onOnly={(id) => setHidden(new Set(calendars.filter((c) => c.id !== id).map((c) => c.id)))}
onCreate={() => {
setDrawer(false);
createAtNextSlot();
}}
/>
);
const shared = {
occurrences,
calendars: calById,
now,
activeKey: popover?.occ.key ?? null,
compact,
onCreate: startCreate,
onOpen: openOcc,
onMove: moveOcc,
onPickDay: (d: Date) => goTo(d, "day"),
};
const variants = {
enter: (d: number) => (reduced ? { opacity: 0 } : d === 0 ? { opacity: 0, scale: 0.985 } : { opacity: 0, x: d * 48 }),
center: { opacity: 1, x: 0, scale: 1 },
exit: (d: number) => (reduced ? { opacity: 0 } : d === 0 ? { opacity: 0, scale: 1.01 } : { opacity: 0, x: d * -48 }),
};
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={edit || drawer ? true : undefined} className="flex min-w-0 flex-1">
<aside aria-label="Calendars" className="hidden w-64 shrink-0 border-r bg-muted/25 lg:block dark:bg-muted/15">
<div className="flex h-14 items-center gap-2.5 px-5">
<Logo />
<span className="text-[15px] font-semibold tracking-tight">{appName}</span>
</div>
<div className="h-[calc(100%-3.5rem)]">{sidebar}</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Toolbar */}
<header className="flex h-14 shrink-0 items-center gap-1.5 border-b px-2 sm:gap-2 sm:px-4">
<IconButton label="Open calendars" onClick={() => setDrawer(true)} className="lg:hidden">
<Menu className="size-[18px]" />
</IconButton>
<button
type="button"
onClick={goToday}
title="Today (T)"
className={cn("h-8 shrink-0 rounded-lg border px-2.5 text-[13px] font-medium transition hover:bg-accent sm:px-3.5", focusRing)}
>
Today
</button>
<div className="flex shrink-0">
<IconButton label={`Previous ${view}`} onClick={() => navigate(-1)}>
<ChevronLeft className="size-[18px]" />
</IconButton>
<IconButton label={`Next ${view}`} onClick={() => navigate(1)}>
<ChevronRight className="size-[18px]" />
</IconButton>
</div>
<div className="relative min-w-0 flex-1 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.h1
key={title}
initial={{ opacity: 0, y: dir >= 0 ? 8 : -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: dir >= 0 ? -8 : 8 }}
transition={{ duration: 0.18 }}
aria-live="polite"
className="truncate text-[15px] font-semibold tracking-tight sm:text-lg"
>
{compact ? shortTitle : title}
</motion.h1>
</AnimatePresence>
</div>
<LayoutGroup id="cal-views">
<div role="radiogroup" aria-label="View" className="flex shrink-0 rounded-lg bg-muted p-0.5 text-[12px] font-medium">
{VIEWS.map((v) => (
<button
key={v.id}
type="button"
role="radio"
aria-checked={view === v.id}
aria-label={v.label}
title={`${v.label} (${v.key})`}
onClick={() => switchView(v.id)}
className={cn(
"relative h-7 rounded-md px-2.5 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring sm:px-3",
view === v.id ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{view === v.id && <motion.span layoutId="cal-view-pill" className="absolute inset-0 rounded-md bg-background shadow-sm dark:bg-accent" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<span className="relative">{compact ? v.key : v.label}</span>
</button>
))}
</div>
</LayoutGroup>
{!lg && (
<button
type="button"
onClick={createAtNextSlot}
className={cn("hidden h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 sm:inline-flex", focusRing)}
>
<Plus className="size-4" aria-hidden />
New
</button>
)}
</header>
<main aria-label={`${title}, ${view} view`} className="relative min-h-0 flex-1 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.div
key={rangeKey}
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.24, ease: [0.2, 0, 0, 1] }}
className="absolute inset-0"
>
{view === "month" ? (
<MonthView month={date} selected={date} weekStartsOn={weekStartsOn} {...shared} />
) : (
<TimeGrid
days={days}
hourHeight={hourHeight}
draft={draft}
getSavedScroll={() => scrollMemory.current}
onScrollTop={(top) => (scrollMemory.current = top)}
onResize={resizeOcc}
{...shared}
/>
)}
</motion.div>
</AnimatePresence>
</main>
</div>
</div>
{/* Mobile create */}
<motion.button
type="button"
onClick={createAtNextSlot}
aria-label="New event"
whileTap={{ scale: 0.92 }}
className={cn(
"absolute bottom-5 right-5 z-30 grid size-14 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-xl shadow-primary/30 sm:hidden",
focusRing,
(edit || popover) && "hidden",
)}
>
<Plus className="size-6" />
</motion.button>
{/* Drawer */}
<AnimatePresence>
{drawer && (
<>
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setDrawer(false)}
className="absolute inset-0 z-30 bg-black/25 lg:hidden dark:bg-black/50"
/>
<motion.aside
aria-label="Calendars"
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
className="absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85%] flex-col border-r bg-background shadow-2xl lg:hidden"
>
<div className="flex h-14 shrink-0 items-center gap-2.5 px-5">
<Logo />
<span className="flex-1 text-[15px] font-semibold tracking-tight">{appName}</span>
<IconButton label="Close calendars" onClick={() => setDrawer(false)}>
<X className="size-4" />
</IconButton>
</div>
<div className="min-h-0 flex-1">{sidebar}</div>
</motion.aside>
</>
)}
</AnimatePresence>
<EventPopover
occ={popover?.occ ?? null}
anchor={popover?.anchor ?? null}
bounds={bounds}
sheet={compact}
calendar={popover ? calById[popover.occ.event.calendarId] : undefined}
onClose={() => setPopover(null)}
onEdit={() => popover && editOcc(popover.occ)}
onDelete={() => popover && remove(popover.occ.event)}
onDuplicate={() => popover && duplicate(popover.occ)}
/>
<EventDialog
target={edit}
calendars={calendars}
onCancel={() => {
setEdit(null);
setDraft(null);
}}
onSave={save}
onDelete={remove}
onDraftChange={setDraft}
/>
{/* Toasts */}
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-4 z-[60] flex flex-col items-center gap-2 px-4">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 24, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
role="status"
className="pointer-events-auto relative flex min-w-64 max-w-full items-center gap-2.5 overflow-hidden rounded-xl bg-foreground py-2.5 pl-3 pr-2 text-[13px] text-background shadow-xl shadow-black/20"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-400 dark:text-emerald-600" aria-hidden />
<span className="min-w-0 flex-1 truncate font-medium">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
dismiss(t.id);
}}
className="h-7 rounded-md px-2 text-xs font-semibold text-background/90 outline-none hover:bg-background/10 focus-visible:ring-2 focus-visible:ring-background/60"
>
Undo
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="grid size-6 place-items-center rounded-md text-background/60 outline-none hover:bg-background/10 hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
<motion.span aria-hidden className="absolute bottom-0 left-0 h-0.5 bg-background/30" initial={{ width: "100%" }} animate={{ width: "0%" }} transition={{ duration: 5, ease: "linear" }} />
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
function Logo() {
return (
<span aria-hidden className="relative grid size-8 place-items-center overflow-hidden rounded-xl bg-gradient-to-br from-violet-500 via-indigo-500 to-sky-500 text-white shadow-md shadow-indigo-500/25">
<CalendarDays className="size-4" />
</span>
);
}
export default CalendarApp;