Event Calendar
alphav0.5.0Editable event calendar with month, week, day, and agenda views — multi-day spans, drag and resize editing, clipboard support, and keyboard navigation.
Context
Event Calendar is the fifth surface onto the canonical TaskItem the rest of the task family renders: task-card (list), task-tree (outline), kanban-board (board), gantt-timeline (continuous timeline), and now event-calendar (date grid). A product's 'Calendar' tab is literally the same task data its other tabs show, with no adapter. v0.2 adds the editing layer additively over v0.1's display surface, reusing gantt's controlled-echo vocabulary + the shared TaskPermissions matrix; editable defaults off so consumers opt in. Because every task surface speaks the same TaskItem, copy/paste rides a shared 'ilinxa/task' clipboard envelope — a task copied in the calendar pastes into gantt/kanban/tree and back. Compound structure: EventCalendarRoot (headless provider) + flat parts (Toolbar / MonthView / WeekView / DayView / AgendaView / MiniNav / Inspector / QuickComposer / ContextMenu / edit overlays) + Tier-C primitives (CalendarEventChip / CalendarEventBar / CalendarTimeBlock / MonthDayCell / TimeGrid / TimeGutter / NowIndicator / AgendaRow / EventTooltip / EventEditorPanel / CalendarSkeleton) + the EventCalendar assembly. Each view is its own module so a month-only consumer never pulls the week/day time-grid code; the full-card tooltip + detail editor lazy-load task-card, so the default lightweight tooltip keeps it out of the bundle.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/event-calendarAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/event-calendar-fixturespnpm dlx shadcn@latest add @ilinxa/event-calendar-editingDrag/resize editing, quick-compose, clipboard, keyboard mutations, and permission-gated actions.
CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Demo source
"use client"; import { useMemo, useRef, useState } from "react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { EventCalendar, EventCalendarRoot, CalendarAgendaView, CalendarFullCardTooltip, CalendarMonthView, CalendarSkeleton, CalendarToolbar, useCalendar,} from "./";import type { CalendarHandle, TaskItem } from "./";import { calendarEditing } from "./features/editing";import { buildCalendarDummyData, CALENDAR_LABEL_OPTIONS, CALENDAR_PRIORITY_OPTIONS, CALENDAR_STATUS_OPTIONS,} from "./dummy-data"; /** Distinct accent per status → the event color tracks the status (not time). */const STATUS_COLORS: Record<string, string> = { todo: "oklch(0.62 0.13 255)", // blue — not started "in-progress": "var(--primary)", // signal-lime — active blocked: "var(--destructive)", // red — blocked done: "var(--muted-foreground)", // grey — done};const isHighPriority = (item: TaskItem) => item.priority === "high"; /** Inline view switcher for the hand-assembled "lighter" subset. */function ComposedBody() { const { view } = useCalendar(); return view === "agenda" ? <CalendarAgendaView /> : <CalendarMonthView />;} export default function EventCalendarDemo() { const handle = useRef<CalendarHandle>(null); const [clicked, setClicked] = useState<TaskItem | null>(null); const [today] = useState(() => new Date()); const data = useMemo(() => buildCalendarDummyData(today), [today]); // Editable tab is controlled: edits echo back into local state. const [editData, setEditData] = useState<TaskItem[]>(() => buildCalendarDummyData(today), ); const [lastEdit, setLastEdit] = useState<string>(""); return ( <Tabs defaultValue="calendar" className="w-full gap-4"> <SwipeTabsList> <TabsTrigger value="calendar">Calendar</TabsTrigger> <TabsTrigger value="editable">Editable</TabsTrigger> <TabsTrigger value="composed">Lighter (composed)</TabsTrigger> <TabsTrigger value="fullcard">Full-card tooltip</TabsTrigger> <TabsTrigger value="states">States</TabsTrigger> </SwipeTabsList> {/* 1 — full assembly + imperative handle + mini-nav */} <TabsContent value="calendar" className="space-y-3"> <div className="flex flex-wrap items-center gap-2"> <Button size="sm" variant="outline" onClick={() => handle.current?.goToToday()} > Today </Button> <Button size="sm" variant="outline" onClick={() => handle.current?.setView("week")} > Week </Button> <Button size="sm" variant="outline" onClick={() => handle.current?.setView("day")} > Day </Button> <span className="ml-auto text-xs text-muted-foreground"> {clicked ? `Clicked: ${clicked.name}` : "Switch views · M/W/D/A · ←/→ navigate · click an event"} </span> </div> <EventCalendar ref={handle} data={data} statusOptions={CALENDAR_STATUS_OPTIONS} priorityOptions={CALENDAR_PRIORITY_OPTIONS} statusColors={STATUS_COLORS} flagPriority={isHighPriority} defaultView="month" now={today} showMiniNav onTaskClick={setClicked} /> </TabsContent> {/* 1b — EDITABLE (v0.2.0): controlled data echoed via onChange */} <TabsContent value="editable" className="space-y-3"> <div className="flex flex-wrap items-center gap-2"> <span className="text-xs text-muted-foreground"> <strong>Drag</strong> to reschedule · <strong>drag an edge</strong> to resize · <strong>double-click</strong> (or drag) empty space → quick-create · <strong>right-click</strong> for actions (incl. Copy/Cut) · select → <strong>Edit</strong>. <strong>Keyboard:</strong>{" "} focus an event → arrows move, Shift+arrows resize, Enter edit, F2 rename, Del delete. <strong>Copy/paste</strong> (⌘/Ctrl+C·X·V) carries tasks across task tools. Switch to <strong>Week/Day</strong> for time editing. </span> <span className="ml-auto text-xs text-muted-foreground"> {lastEdit || `${editData.length} root items`} </span> </div> <EventCalendar editable editing={calendarEditing} data={editData} onChange={setEditData} statusOptions={CALENDAR_STATUS_OPTIONS} priorityOptions={CALENDAR_PRIORITY_OPTIONS} labelOptions={CALENDAR_LABEL_OPTIONS} statusColors={STATUS_COLORS} flagPriority={isHighPriority} defaultView="month" now={today} showInspector onItemAdded={(e) => setLastEdit(`Added: ${e.item.name}`)} onItemRemoved={(e) => setLastEdit(`Removed: ${e.removed.name}`)} onFieldEdited={(e) => setLastEdit(`Edited ${e.key} on ${e.itemId}`)} /> </TabsContent> {/* 2 — hand-assembled subset (month + agenda; week/day time-grid never pulled) */} <TabsContent value="composed" className="space-y-2"> <p className="text-xs text-muted-foreground"> Hand-assembled <code>EventCalendarRoot</code> + <code>CalendarToolbar</code>{" "} + month/agenda only — the week/day time-grid code never enters this bundle. </p> <EventCalendarRoot data={data} statusOptions={CALENDAR_STATUS_OPTIONS} priorityOptions={CALENDAR_PRIORITY_OPTIONS} statusColors={STATUS_COLORS} flagPriority={isHighPriority} now={today} defaultView="month" views={["month", "agenda"]} > <CalendarToolbar /> <ComposedBody /> </EventCalendarRoot> </TabsContent> {/* 3 — rich full-card hover tooltip (lazy task-card) */} <TabsContent value="fullcard" className="space-y-2"> <p className="text-xs text-muted-foreground"> Hover an event → the full <code>TaskCard</code> (lazy-loaded; the default tooltip is a native title). </p> <EventCalendar data={data} statusOptions={CALENDAR_STATUS_OPTIONS} priorityOptions={CALENDAR_PRIORITY_OPTIONS} statusColors={STATUS_COLORS} flagPriority={isHighPriority} now={today} defaultView="month" renderTooltip={(item) => ( <CalendarFullCardTooltip item={item} statusOptions={CALENDAR_STATUS_OPTIONS} /> )} /> </TabsContent> {/* 4 — empty + loading */} <TabsContent value="states" className="grid gap-4 sm:grid-cols-2"> <div className="space-y-1"> <p className="text-xs font-medium text-muted-foreground">Empty</p> <EventCalendar data={[]} statusOptions={CALENDAR_STATUS_OPTIONS} now={today} defaultView="agenda" /> </div> <div className="space-y-1"> <p className="text-xs font-medium text-muted-foreground">Loading</p> <div className="rounded-lg border border-border bg-card"> <CalendarSkeleton /> </div> </div> </TabsContent> </Tabs> );} Usage
When to use
Reach for EventCalendar when you already hold the canonical TaskItem[] (the data behind task-card, task-tree, kanban-board, and gantt-timeline) and want a date-grid surface — month, week, day, or agenda — with no adapter. It is the read-only display sibling of the gantt; editing lands in v0.2.
Basic example
import { EventCalendar } from "@/components/event-calendar"
export function Example({ tasks }) {
return (
<EventCalendar
data={tasks} // TaskItem[]
statusOptions={statusOptions}
defaultView="month" // "month" | "week" | "day" | "agenda"
now={serverNow} // SSR-stable "now"
showMiniNav
onTaskClick={(item) => openDetail(item)}
onRangeChange={({ start, end }) => fetchWindow(start, end)}
/>
)
}Editing (opt-in feature)
Editing (drag/resize/create, clipboard, keyboard mutations, permission-gated actions) ships as a separate feature slice — installing the base package alone keeps it out of your bundle entirely. Install @ilinxa/event-calendar-editing, import calendarEditing, and pass it alongside editable.
import { EventCalendar } from "@/components/event-calendar"
import { calendarEditing } from "@/components/event-calendar/features/editing"
<EventCalendar
editable
editing={calendarEditing} // wires drag/resize/create/clipboard/keyboard
data={tasks}
onChange={setTasks} // controlled — echo the mutated forest back
statusOptions={statusOptions}
permissions={permissions} // optional — the shared TaskPermissions matrix
/>editable without editing wired stays fully read-only (byte-identical to a base-only install) — the calendar logs one console.warn in development and never throws.
Lighter (hand-assembled subset)
import {
EventCalendarRoot, CalendarToolbar, CalendarMonthView,
} from "@/components/event-calendar"
// month-only — the week/day time-grid code never enters your bundle
<EventCalendarRoot data={tasks} statusOptions={statusOptions} views={["month"]}>
<CalendarToolbar />
<CalendarMonthView />
</EventCalendarRoot>All-day vs timed
- A
classifyEvent(item)predicate wins when it returns a kind. - Otherwise a date-only string (
"2026-06-22", noT) is an all-day event (parsed floating-local — no timezone off-by-one); a full timestamp is timed. - With no end and a full timestamp, the item is a milestone (a marker / dot).
Notes
- Fully controlled — no internal data state. Pass
nowfor an SSR-stable first paint. - Keyboard:
M/W/D/Aswitch views,←/→+PageUp/PageDownstep the period,Tjumps to today. - The default hover tooltip is a native title; pass
renderTooltip(e.g.CalendarFullCardTooltip) for a rich card.
Features
- Four views: Month (date cells with multi-day spanning bars + chips + '+N more' overflow), Week + Day (hour time-grids: all-day band + lane-packed timed blocks + now-line), Agenda (day-grouped chronological list)
- Consumes the canonical TaskItem[] directly — same data as task-card / task-tree / kanban-board / gantt-timeline; no adapter
- Opt-in editing (editable, default off → read-only): drag-to-reschedule + edge-resize + draw/double-click create + quick-composer + right-click menu (Edit/Rename/Status/Priority/Copy/Cut/Delete) + selected-event inspector + modal detail editor + inline rename; controlled-echo events + onChange, no internal data state, gated by the shared TaskPermissions matrix
- Full keyboard editing: M/W/D/A switch views, ←/→ step the period, T today; focus an event → ←/→ move, Shift+←/→ (+↑/↓ in the time grid) resize, Enter edit, F2 rename, Delete remove; focus a day → Enter quick-create
- Cross-surface copy / cut / paste: events copy as a portable TaskItem envelope through the OS clipboard (⌘/Ctrl+C·X·V), so a task copied here pastes into gantt / kanban / tree — paste-target decides all-day vs timed
- All-day / timed / milestone derived via a three-layer rule: consumer classifyEvent predicate → date-only strings (parsed as floating-local, no TZ off-by-one) → span heuristic; all-day⇄timed conversion via paste-target or drag onto the all-day band
- Status-driven event color (statusColors + colorBy, default 'status'; colorBy='urgency' restores the v0.1 deadline ramp) imported from task-card; per-item borderColor override; high-priority Flag; overdue + inactive treatments
- Cursor (view + focus date) controlled OR uncontrolled; period nav, view switch, optional jump-to-date mini-nav (shadcn calendar), onRangeChange for lazy windowed fetch; height-responsive month overflow (maxEventsPerCell overrides)
- SSR-safe first paint (now prop seeds; client interval refreshes); finite-date guards (unparseable dates render label-only, never throw); all-day floating-local round-trip (no off-by-one)
- Compound: EventCalendarRoot + flat view parts + edit overlays + Tier-C primitives + the EventCalendar assembly; each view its own module (tree-shakeable); a month-only subset drops the time-grid code; the detail editor lazy-loads task-card
- v0.5 barrel completeness: `CalendarBaseContextValue` — the return type of the exported `useCalendar()` hook — is now importable from the package root. Type-only, additive.