"use client"; import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { CalendarDaysIcon, CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, MapPinIcon, PlusIcon, Trash2Icon, UsersIcon, VideoIcon, XIcon, } from "lucide-react"; import { type KeyboardEvent, type PointerEvent as ReactPointerEvent, type RefObject, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Kbd } from "@/components/ui/kbd"; import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; export type CalendarTone = "teal" | "violet" | "sky" | "amber" | "rose"; export interface CalendarSource { id: string; name: string; tone: CalendarTone; visible?: boolean; } export interface CalendarEvent { id: string; title: string; calendar: string; date: string; start: number; end: number; allDay?: boolean; days?: number; location?: string; video?: boolean; people?: string[]; } export interface CalendarWeekProps { calendars?: CalendarSource[]; events?: CalendarEvent[]; now?: string; className?: string; } const toneDot: Record = { teal: "bg-teal-500", violet: "bg-violet-500", sky: "bg-sky-500", amber: "bg-amber-500", rose: "bg-rose-500", }; const toneBlock: Record = { teal: "bg-[color-mix(in_oklab,var(--color-teal-500)_14%,var(--background))] text-teal-950 before:bg-teal-500 dark:bg-[color-mix(in_oklab,var(--color-teal-400)_16%,var(--background))] dark:text-teal-50", violet: "bg-[color-mix(in_oklab,var(--color-violet-500)_14%,var(--background))] text-violet-950 before:bg-violet-500 dark:bg-[color-mix(in_oklab,var(--color-violet-400)_18%,var(--background))] dark:text-violet-50", sky: "bg-[color-mix(in_oklab,var(--color-sky-500)_14%,var(--background))] text-sky-950 before:bg-sky-500 dark:bg-[color-mix(in_oklab,var(--color-sky-400)_16%,var(--background))] dark:text-sky-50", amber: "bg-[color-mix(in_oklab,var(--color-amber-500)_18%,var(--background))] text-amber-950 before:bg-amber-500 dark:bg-[color-mix(in_oklab,var(--color-amber-400)_16%,var(--background))] dark:text-amber-50", rose: "bg-[color-mix(in_oklab,var(--color-rose-500)_14%,var(--background))] text-rose-950 before:bg-rose-500 dark:bg-[color-mix(in_oklab,var(--color-rose-400)_16%,var(--background))] dark:text-rose-50", }; const toneSolid: Record = { teal: "bg-teal-600 text-white before:bg-teal-800 dark:bg-teal-500", violet: "bg-violet-600 text-white before:bg-violet-800 dark:bg-violet-500", sky: "bg-sky-600 text-white before:bg-sky-800 dark:bg-sky-500", amber: "bg-amber-500 text-amber-950 before:bg-amber-700", rose: "bg-rose-600 text-white before:bg-rose-800 dark:bg-rose-500", }; const toneCheck: Record = { teal: "border-teal-500 bg-teal-500", violet: "border-violet-500 bg-violet-500", sky: "border-sky-500 bg-sky-500", amber: "border-amber-500 bg-amber-500", rose: "border-rose-500 bg-rose-500", }; const HOUR = 48; const PX = HOUR / 60; const SNAP = 15; const ease = [0.23, 1, 0.32, 1] as const; const settle = { type: "spring", stiffness: 640, damping: 46 } as const; const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; function parse(iso: string) { const [y, m, d] = iso.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d)); } function iso(d: Date) { return d.toISOString().slice(0, 10); } function addDays(date: string, n: number) { const d = parse(date); d.setUTCDate(d.getUTCDate() + n); return iso(d); } function weekday(date: string) { return (parse(date).getUTCDay() + 6) % 7; } function startOfWeek(date: string) { return addDays(date, -weekday(date)); } function isoWeek(date: string) { const d = parse(date); d.setUTCDate(d.getUTCDate() + 3 - ((d.getUTCDay() + 6) % 7)); const jan4 = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); return 1 + Math.round(((d.getTime() - jan4.getTime()) / 86400000 - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7); } function diffDays(a: string, b: string) { return Math.round((parse(a).getTime() - parse(b).getTime()) / 86400000); } function fmtTime(min: number, withPeriod = true) { const h = Math.floor(min / 60) % 24; const m = min % 60; const h12 = ((h + 11) % 12) + 1; const period = h >= 12 ? "PM" : "AM"; return `${h12}${m ? `:${String(m).padStart(2, "0")}` : ""}${withPeriod ? ` ${period}` : ""}`; } function fmtRange(a: number, b: number) { const samePeriod = a < 720 === b < 720; return `${fmtTime(a, !samePeriod)} – ${fmtTime(b)}`; } function fmtDuration(min: number) { const h = Math.floor(min / 60); const m = min % 60; return h ? `${h}h${m ? ` ${m}m` : ""}` : `${m}m`; } const snap = (min: number) => Math.round(min / SNAP) * SNAP; const defaultCalendars: CalendarSource[] = [ { id: "me", name: "Maya Okafor", tone: "teal", visible: true }, { id: "team", name: "Team", tone: "violet", visible: true }, { id: "product", name: "Product reviews", tone: "sky", visible: true }, { id: "launches", name: "Launches", tone: "amber", visible: true }, { id: "holidays", name: "Holidays", tone: "rose", visible: true }, ]; function buildEvents(): CalendarEvent[] { const wk = "2026-10-19"; const d = (n: number) => addDays(wk, n); const t = (h: number, m = 0) => h * 60 + m; const standups: CalendarEvent[] = []; for (let w = -1; w <= 1; w++) { for (let i = 0; i < 5; i++) { standups.push({ id: `standup-${w}-${i}`, title: "Standup", calendar: "team", date: addDays(wk, w * 7 + i), start: t(9), end: t(9, 30), video: true, }); } } return [ ...standups, { id: "e1", title: "Pricing model review", calendar: "me", date: d(0), start: t(11), end: t(12), location: "Room Atlas", people: ["Jonas Weber", "Priya Raman"] }, { id: "e2", title: "Onboarding v3 working session", calendar: "product", date: d(0), start: t(14), end: t(15, 30), video: true, people: ["Priya Raman", "Leo Marchetti"] }, { id: "e3", title: "1:1 Jonas", calendar: "me", date: d(0), start: t(16), end: t(16, 30), video: true, people: ["Jonas Weber"] }, { id: "e4", title: "Research readout", calendar: "team", date: d(1), start: t(10), end: t(11), location: "Big room", people: ["Ana Sousa"] }, { id: "e5", title: "Lunch with Ellie", calendar: "me", date: d(1), start: t(12, 30), end: t(13, 30), location: "Canal Café" }, { id: "e6", title: "Interview: Staff Engineer", calendar: "me", date: d(1), start: t(15), end: t(16), video: true, people: ["Sam Whitfield"] }, { id: "e7", title: "Hiring sync", calendar: "team", date: d(1), start: t(15, 30), end: t(16, 15), people: ["Ana Sousa", "Sam Whitfield"] }, { id: "e8", title: "Incident review: webhooks", calendar: "team", date: d(2), start: t(13), end: t(14), video: true, people: ["Sam Whitfield"] }, { id: "e9", title: "Board prep", calendar: "me", date: d(2), start: t(16), end: t(17, 30), location: "Room Atlas" }, { id: "e10", title: "Q4 pricing decision", calendar: "me", date: d(3), start: t(10), end: t(10, 45), video: true, people: ["Jonas Weber", "Priya Raman"] }, { id: "e11", title: "Customer call: Fieldnote", calendar: "me", date: d(3), start: t(11, 30), end: t(12), video: true, people: ["Noor Haddad"] }, { id: "e12", title: "Design crit", calendar: "product", date: d(3), start: t(14), end: t(15), location: "Studio", people: ["Priya Raman", "Leo Marchetti", "Ana Sousa"] }, { id: "e13", title: "Release notes review", calendar: "launches", date: d(3), start: t(14, 30), end: t(15), people: ["Jonas Weber"] }, { id: "e14", title: "Climbing", calendar: "me", date: d(3), start: t(18), end: t(19, 30), location: "Boulder Hall" }, { id: "e15", title: "Focus: Q4 plan", calendar: "me", date: d(4), start: t(10), end: t(12) }, { id: "e16", title: "Starter pricing goes live", calendar: "launches", date: d(4), start: t(15), end: t(15, 30) }, { id: "e17", title: "Team drinks", calendar: "team", date: d(4), start: t(17), end: t(18, 30), location: "The Lantern" }, { id: "e18", title: "Run club", calendar: "me", date: d(5), start: t(9), end: t(10) }, { id: "a1", title: "Leo out", calendar: "team", date: d(3), start: 0, end: 0, allDay: true, days: 2 }, { id: "a2", title: "Clocks go back", calendar: "holidays", date: d(6), start: 0, end: 0, allDay: true }, { id: "n1", title: "Roadmap sync", calendar: "product", date: addDays(wk, 7), start: t(11), end: t(12), video: true }, { id: "n2", title: "Lisbon offsite", calendar: "team", date: addDays(wk, 9), start: 0, end: 0, allDay: true, days: 2 }, { id: "p1", title: "Quarterly planning", calendar: "product", date: addDays(wk, -5), start: t(13), end: t(16) }, ]; } interface Placed { ev: CalendarEvent; col: number; cols: number; } function placeDay(events: CalendarEvent[]): Placed[] { const sorted = [...events].sort((a, b) => a.start - b.start || b.end - a.end); const out: Placed[] = []; let cluster: Placed[] = []; let clusterEnd = -1; const flush = () => { const cols = Math.max(1, ...cluster.map((p) => p.col + 1)); for (const p of cluster) p.cols = cols; out.push(...cluster); cluster = []; }; for (const ev of sorted) { if (ev.start >= clusterEnd && cluster.length) flush(); const used = new Set(cluster.filter((p) => p.ev.end > ev.start).map((p) => p.col)); let col = 0; while (used.has(col)) col++; cluster.push({ ev, col, cols: 1 }); clusterEnd = Math.max(clusterEnd, ev.end); } if (cluster.length) flush(); return out; } function packAllDay(list: CalendarEvent[], first: string, count: number) { const rows: number[][] = []; return [...list] .sort((a, b) => a.date.localeCompare(b.date) || (b.days ?? 1) - (a.days ?? 1)) .map((e) => { const startIdx = Math.max(0, diffDays(e.date, first)); const endIdx = Math.min(count - 1, diffDays(addDays(e.date, (e.days ?? 1) - 1), first)); let row = rows.findIndex((r) => r.every((c) => c < startIdx || c > endIdx)); if (row < 0) row = rows.push([]) - 1; for (let c = startIdx; c <= endIdx; c++) rows[row].push(c); return { e, row, startIdx, endIdx }; }); } function toDate(date: string) { const [y, m, d] = date.split("-").map(Number); return new Date(y, m - 1, d); } function fromDate(d: Date) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function MiniMonth({ month, onMonth, selected, today, weekStart, wholeWeek, onPick, busy, }: { month: string; onMonth: (m: string) => void; selected: string; today: string; weekStart: string; wholeWeek: boolean; onPick: (d: string) => void; busy: Set; }) { const range = wholeWeek ? { from: toDate(weekStart), to: toDate(addDays(weekStart, 6)) } : { from: toDate(selected), to: toDate(selected) }; return ( d !== today).map(toDate) }} modifiersClassNames={{ busy: "*:before:pointer-events-none *:before:absolute *:before:bottom-1 *:before:start-1/2 *:before:size-[3px] *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-muted-foreground/60 [&[data-selected]:not(.range-middle)>*]:before:bg-primary-foreground/72", }} month={toDate(month)} onDayClick={(day) => onPick(fromDate(day))} onMonthChange={(m) => onMonth(fromDate(m))} onSelect={() => {}} selected={range} today={toDate(today)} weekStartsOn={1} /> ); } type DragState = { id: string; mode: "move" | "resize" | "create"; x: number; y: number; start: number; end: number; date: string; moved: boolean; anchorMin: number; }; type ViewMode = "day" | "week"; export function CalendarWeek({ calendars: initialCalendars = defaultCalendars, events: initialEvents, now = "2026-10-22T10:40", className, }: CalendarWeekProps) { const today = now.slice(0, 10); const [hh, mm] = now.slice(11, 16).split(":").map(Number); const [nowMin, setNowMin] = useState(hh * 60 + mm); const [calendars, setCalendars] = useState(initialCalendars); const [events, setEvents] = useState(() => initialEvents ?? buildEvents()); const [view, setView] = useState("week"); const [focusDay, setFocusDay] = useState(today); const [month, setMonth] = useState(today.slice(0, 8) + "01"); const [selectedId, setSelectedId] = useState(null); const [editing, setEditing] = useState(null); const [drag, setDrag] = useState(null); const [narrow, setNarrow] = useState(false); const [docked, setDocked] = useState(true); const [toast, setToast] = useState<{ key: number; text: string; undo?: () => void } | null>(null); const rootRef = useRef(null); const scrollRef = useRef(null); const gridRef = useRef(null); const titleRef = useRef(null); const mainRef = useRef(null); const [allDayAt, setAllDayAt] = useState({ left: 12, top: 112 }); useEffect(() => { const id = window.setInterval(() => setNowMin((m) => Math.min(m + 1, 24 * 60 - 1)), 60000); return () => window.clearInterval(id); }, []); useEffect(() => { const el = rootRef.current; if (!el) return; const ro = new ResizeObserver(([e]) => { setNarrow(e.contentRect.width < 672); setDocked(e.contentRect.width >= 896); }); ro.observe(el); return () => ro.disconnect(); }, []); useLayoutEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 8 * HOUR - 12; }, []); useEffect(() => { if (!toast) return; const id = window.setTimeout(() => setToast(null), 4500); return () => window.clearTimeout(id); }, [toast]); const mode: ViewMode = narrow ? "day" : view; const weekStart = startOfWeek(focusDay); const days = mode === "week" ? Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) : [focusDay]; const visibleCal = new Set(calendars.filter((c) => c.visible !== false).map((c) => c.id)); const calOf = (id: string) => calendars.find((c) => c.id === id) ?? calendars[0]; const shown = events.filter((e) => visibleCal.has(e.calendar)); const timed = shown.filter((e) => !e.allDay); const allDay = shown.filter((e) => e.allDay && diffDays(addDays(e.date, (e.days ?? 1) - 1), days[0]) >= 0 && diffDays(e.date, days[days.length - 1]) <= 0); const busy = new Set(shown.map((e) => e.date)); const selected = events.find((e) => e.id === selectedId) ?? null; const patch = (id: string, p: Partial) => setEvents((es) => es.map((e) => (e.id === id ? { ...e, ...p } : e))); const goto = (d: string) => { setFocusDay(d); setMonth(d.slice(0, 8) + "01"); }; const step = (dir: number) => goto(addDays(focusDay, dir * (mode === "week" ? 7 : 1))); const header = () => { const a = parse(days[0]); const b = parse(days[days.length - 1]); if (mode === "day" && !narrow) return { main: `${MONTHS[a.getUTCMonth()]} ${a.getUTCDate()}`, sub: DAY_NAMES[weekday(days[0])] }; if (a.getUTCMonth() === b.getUTCMonth()) return { main: MONTHS[a.getUTCMonth()], sub: String(a.getUTCFullYear()) }; return { main: `${MONTHS[a.getUTCMonth()].slice(0, 3)} – ${MONTHS[b.getUTCMonth()].slice(0, 3)}`, sub: String(b.getUTCFullYear()) }; }; const head = header(); const columnWidth = () => (gridRef.current ? gridRef.current.getBoundingClientRect().width / days.length : 1); const minuteAt = (clientY: number) => { const r = gridRef.current?.getBoundingClientRect(); if (!r) return 0; return Math.max(0, Math.min(24 * 60, (clientY - r.top) / PX)); }; const beginDrag = (e: ReactPointerEvent, ev: CalendarEvent, m: "move" | "resize") => { if (e.button !== 0) return; e.stopPropagation(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); setDrag({ id: ev.id, mode: m, x: e.clientX, y: e.clientY, start: ev.start, end: ev.end, date: ev.date, moved: false, anchorMin: 0 }); }; const beginCreate = (e: ReactPointerEvent, date: string) => { if (e.button !== 0) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); const at = Math.floor(minuteAt(e.clientY) / SNAP) * SNAP; const ev: CalendarEvent = { id: `new-${Date.now()}`, title: "", calendar: calendars[0]?.id ?? "me", date, start: at, end: at + 30 }; setEvents((es) => [...es, ev]); setSelectedId(null); setEditing(null); setDrag({ id: ev.id, mode: "create", x: e.clientX, y: e.clientY, start: at, end: at + 30, date, moved: false, anchorMin: at }); }; const onPointerMove = (e: ReactPointerEvent) => { if (!drag) return; const dy = e.clientY - drag.y; const dx = e.clientX - drag.x; const moved = drag.moved || Math.abs(dy) > 3 || Math.abs(dx) > 3; if (!moved) return; if (!drag.moved) setDrag({ ...drag, moved: true }); const deltaMin = snap(dy / PX); if (drag.mode === "move") { const len = drag.end - drag.start; const start = Math.max(0, Math.min(24 * 60 - len, drag.start + deltaMin)); const dDay = mode === "week" ? Math.round(dx / columnWidth()) : 0; const idx = Math.max(0, Math.min(days.length - 1, diffDays(drag.date, days[0]) + dDay)); patch(drag.id, { start, end: start + len, date: days[idx] }); } else if (drag.mode === "resize") { const end = Math.max(drag.start + SNAP, Math.min(24 * 60, drag.end + deltaMin)); patch(drag.id, { end }); } else { const at = snap(minuteAt(e.clientY)); const a = Math.min(drag.anchorMin, at); const b = Math.max(drag.anchorMin + SNAP, at); patch(drag.id, { start: a, end: Math.max(a + SNAP, b) }); } }; const onPointerUp = () => { if (!drag) return; const d = drag; setDrag(null); if (d.mode === "create") { setSelectedId(d.id); setEditing(d.id); requestAnimationFrame(() => titleRef.current?.focus()); return; } if (!d.moved) { setSelectedId(d.id); setEditing(null); return; } const ev = events.find((x) => x.id === d.id); if (ev && (ev.start !== d.start || ev.end !== d.end || ev.date !== d.date)) { setSelectedId(d.id); setToast({ key: Date.now(), text: `${ev.title || "Event"} → ${DOW[weekday(ev.date)]} ${fmtRange(ev.start, ev.end)}`, undo: () => { patch(d.id, { start: d.start, end: d.end, date: d.date }); setToast(null); rootRef.current?.focus({ preventScroll: true }); }, }); } }; const remove = (id: string) => { const ev = events.find((e) => e.id === id); if (!ev) return; setEvents((es) => es.filter((e) => e.id !== id)); setSelectedId(null); setEditing(null); if (ev.title) { setToast({ key: Date.now(), text: `Deleted “${ev.title}”`, undo: () => { setEvents((es) => [...es, ev]); setToast(null); rootRef.current?.focus({ preventScroll: true }); }, }); } rootRef.current?.focus({ preventScroll: true }); }; const closeEditor = () => { if (editing) { const ev = events.find((e) => e.id === editing); if (ev && !ev.title.trim()) setEvents((es) => es.filter((e) => e.id !== editing)); } setEditing(null); setSelectedId(null); rootRef.current?.focus({ preventScroll: true }); }; const newEventNow = () => { const date = mode === "day" ? focusDay : days.includes(today) ? today : days[0]; const start = Math.ceil((date === today ? nowMin : 9 * 60) / 30) * 30; const ev: CalendarEvent = { id: `new-${Date.now()}`, title: "", calendar: calendars[0]?.id ?? "me", date, start, end: start + 30 }; setEvents((es) => [...es, ev]); setSelectedId(ev.id); setEditing(ev.id); if (scrollRef.current) scrollRef.current.scrollTop = Math.max(0, start * PX - 120); requestAnimationFrame(() => titleRef.current?.focus()); }; const onKeyDown = (e: KeyboardEvent) => { const t = e.target as HTMLElement; if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === "Escape") { if (editing || selectedId) closeEditor(); return; } if (selected && !selected.allDay) { const len = selected.end - selected.start; if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); const d = e.key === "ArrowUp" ? -SNAP : SNAP; if (e.shiftKey) patch(selected.id, { end: Math.max(selected.start + SNAP, Math.min(1440, selected.end + d)) }); else { const start = Math.max(0, Math.min(1440 - len, selected.start + d)); patch(selected.id, { start, end: start + len }); } return; } if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); const date = addDays(selected.date, e.key === "ArrowLeft" ? -1 : 1); patch(selected.id, { date }); if (!days.includes(date)) goto(date); return; } if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault(); remove(selected.id); return; } if (e.key === "Enter") { e.preventDefault(); setEditing(selected.id); requestAnimationFrame(() => titleRef.current?.focus()); return; } } const k = e.key.toLowerCase(); if (e.key === "ArrowLeft" || k === "k") step(-1); else if (e.key === "ArrowRight" || k === "j") step(1); else if (k === "t") goto(today); else if (k === "d") setView("day"); else if (k === "w") setView("week"); else if (k === "n" || k === "c") { e.preventDefault(); newEventNow(); } }; const panel = selected ? { ev: selected, cal: calOf(selected.calendar), dayIndex: days.indexOf(selected.date) } : null; const sidebar = (
{ setFocusDay(d); if (mode === "day" || narrow) setView("day"); }} selected={focusDay} today={today} weekStart={weekStart} wholeWeek={mode === "week"} />
Calendars
    {calendars.map((c) => { const on = c.visible !== false; return (
  • ); })}
← → week T today
↑ ↓ move event ⇧ resize
); const nowTop = nowMin * PX; const todayIdx = days.indexOf(today); return (
{docked ? (

{head.main} {head.sub}

) : ( {head.main} {head.sub}
setFocusDay(d)} selected={focusDay} today={today} weekStart={weekStart} wholeWeek={mode === "week"} />
{calendars.map((c) => { const on = c.visible !== false; return ( ); })}
)}
{(["day", "week"] as const).map((v) => ( ))}
{narrow && (
{Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)).map((d) => { const on = d === focusDay; const isToday = d === today; return ( ); })}
)}
{!narrow && ( W{isoWeek(days[0])} CEST )} All day
{!narrow && (
{days.map((d) => { const isToday = d === today; return ( ); })}
)}
{days.map((d) => (
))} {packAllDay(allDay, days[0], days.length).map(({ e, row, startIdx, endIdx }) => { const cal = calOf(e.calendar); return ( ); })}
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => ( = 0 && Math.abs(h * 60 - nowMin) < 24 && "opacity-0", )} key={h} style={{ top: h * HOUR }} > {fmtTime(h * 60)} ))} {todayIdx >= 0 && ( {fmtTime(nowMin).replace(" AM", "").replace(" PM", "")} )}
{Array.from({ length: 24 }, (_, h) => (
))} {Array.from({ length: 24 }, (_, h) => (
))} {todayIdx >= 0 && nowMin <= 24 * 60 &&
}
{days.map((d) => { const placed = placeDay(timed.filter((e) => e.date === d)); const isToday = d === today; return (
= 5 && "bg-muted/40")} key={d} onPointerDown={(e) => beginCreate(e, d)} role="group" > {isToday && nowMin <= 24 * 60 && (
)} {placed.map(({ ev, col, cols }) => { const cal = calOf(ev.calendar); const isSel = selectedId === ev.id; const isDrag = drag?.id === ev.id && (drag.moved || drag.mode === "create"); const height = Math.max((ev.end - ev.start) * PX, 18); const short = height < 40; const past = d < today || (d === today && ev.end <= nowMin); const faded = past && !isSel && !isDrag; return ( div]:opacity-70 before:opacity-60", cols > 1 && "ring-1 ring-background", )} exit={{ opacity: 0, scale: 0.96 }} initial={{ opacity: 0, scale: 0.96, y: ev.start * PX }} key={ev.id} onDoubleClick={() => { setSelectedId(ev.id); setEditing(ev.id); requestAnimationFrame(() => titleRef.current?.focus()); }} onKeyDown={(e) => { if (e.key === " ") { e.preventDefault(); setSelectedId(ev.id); } }} onPointerDown={(e) => beginDrag(e, ev, "move")} role="button" style={{ height, left: `calc(${(col / cols) * 100}% + 2px)`, width: `calc(${(col === cols - 1 ? 1 / cols : Math.min(1 - col / cols, (mode === "week" ? 1.7 : 1) / cols)) * 100}% - 6px)`, zIndex: isDrag ? 30 : 10 + col, }} tabIndex={0} onFocus={() => setSelectedId(ev.id)} transition={isDrag ? { duration: 0 } : { y: settle, opacity: { duration: 0.15 }, scale: { duration: 0.15 } }} >
{ev.title || "New event"} {short ? fmtTime(ev.start) : fmtRange(ev.start, ev.end)} {!short && height > 64 && ev.location && ( {ev.location} )}
{isDrag && height >= 40 && ( {fmtDuration(ev.end - ev.start)} )} beginDrag(e, ev, "resize")} >
); })}
); })} {panel && !drag && panel.dayIndex >= 0 && !panel.ev.allDay && ( patch(panel.ev.id, { calendar: id })} onClose={closeEditor} onDelete={() => remove(panel.ev.id)} onEdit={() => { setEditing(panel.ev.id); requestAnimationFrame(() => titleRef.current?.focus()); }} onSave={() => { if (!panel.ev.title.trim()) patch(panel.ev.id, { title: "Busy" }); setEditing(null); setToast({ key: Date.now(), text: `Saved for ${DOW[weekday(panel.ev.date)]} ${fmtRange(panel.ev.start, panel.ev.end)}` }); rootRef.current?.focus({ preventScroll: true }); }} onTitle={(title) => patch(panel.ev.id, { title })} scrollRef={scrollRef} titleRef={titleRef} /> )}
{selected?.allDay && (
{selected.title}
All day{selected.days && selected.days > 1 ? ` · ${selected.days} days` : ""} · {calOf(selected.calendar).name}
)}
{toast && ( {toast.text} {toast.undo ? ( ) : ( )} )}
); } function EventCard({ ev, calendars, dayIndex, cols, editing, titleRef, scrollRef, onTitle, onCalendar, onSave, onClose, onDelete, onEdit, }: { ev: CalendarEvent; calendars: CalendarSource[]; dayIndex: number; cols: number; editing: boolean; titleRef: RefObject; scrollRef: RefObject; onTitle: (t: string) => void; onCalendar: (id: string) => void; onSave: () => void; onClose: () => void; onDelete: () => void; onEdit: () => void; }) { const cal = calendars.find((c) => c.id === ev.calendar) ?? calendars[0]; const cardRef = useRef(null); useLayoutEffect(() => { const card = cardRef.current; const box = scrollRef.current; if (!card || !box) return; const top = card.offsetTop; const bottom = top + card.offsetHeight; if (bottom > box.scrollTop + box.clientHeight - 12) box.scrollTo({ top: bottom - box.clientHeight + 12, behavior: "smooth" }); else if (top < box.scrollTop + 8) box.scrollTo({ top: top - 8, behavior: "smooth" }); }, [scrollRef, editing]); const toRight = cols === 1 ? false : dayIndex < cols - 2; const single = cols === 1; const top = Math.max(4, ev.start * PX - 8); const style = single ? { top: ev.end * PX + 8, right: 12, width: "min(22rem, calc(100% - 24px))" } : toRight ? { top, left: `calc(${((dayIndex + 1) / cols) * 100}% + 6px)` } : { top, right: `calc(${((cols - dayIndex) / cols) * 100}% + 6px)` }; return ( e.stopPropagation()} ref={cardRef} role="dialog" aria-label={editing ? "Edit event" : ev.title} style={style} transition={{ duration: 0.16, ease }} >
{editing ? ( onTitle(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onSave(); if (e.key === "Escape") onClose(); }} placeholder="Add a title" ref={titleRef} value={ev.title} /> ) : ( )}
{DOW[weekday(ev.date)]} {MONTHS[parse(ev.date).getUTCMonth()].slice(0, 3)} {parse(ev.date).getUTCDate()} · {fmtRange(ev.start, ev.end)} {fmtDuration(ev.end - ev.start)}
{ev.location && (
{ev.location}
)} {ev.video && (
meet.kestrel.dev/{ev.id}
)} {ev.people?.length ? (
{ev.people.join(", ")}
) : null}
{editing ? (
{calendars .filter((c) => c.id !== "holidays") .map((c) => ( ))}
) : ( cal.name )}
{editing ? ( <> ↵ ) : ( ↑↓ move ⇧↑↓ resize )}
); }