Skip to contentVibraUI
Part of the Projects dashboardinstalls at /projects/calendar

Team schedule

A month grid built from the calendar entries, an agenda for the dates you pick, and a panel that inspects any entry — attendees, location and reference included.

Open the live page

Every entry is a db.events row and every attendee is the db.members row that entry points at, so the grid and the agenda are two windows onto one list. The month the page opens on is the month REFERENCE_DATE falls in and "today" is that day — no clock is read. The whole page is read in UTC, day boundaries and labels alike, and says so in its meta: a grid that took its days from the viewer's zone and its labels from another would move an entry to the day before for everyone west of Greenwich. DateRangePicker is the one seam: its calendar is the reader's own zone, so it hands back the local midnight of the box that was pressed and prints it with formatDate — a picker value is therefore read for the day its calendar fields say (utcDayOf) and the range it opens on is seeded in the same shape (pickerDay), so the trigger's label and the agenda's first heading always name one day. Clearing the range is a real state: the agenda says to pick dates rather than quietly falling back to a window the trigger is no longer showing. The month is a real grid — rows and cells carry the roles, and every chip names its own day, because out of the grid's shape a chip called "Retro" says nothing about when it is. The grid is the block's own: whole weeks so it stays rectangular, a chip per entry tinted by kind with the kind also said in words, and a "+N more" line once a box is full; an entry spanning days appears on each day it covers. DateRangePicker drives the agenda with its backward-looking presets dropped, because a schedule looks forward, and the arrows step the month independently. Clicking a chip or an agenda row opens the InspectorPanel over the page. Composes AppShell, PageHeader, DateRangePicker, DataList, EmptyState, InspectorPanel, DescriptionList and AvatarGroup.

Preview

Install

npx shadcn@latest add @vibra/projects-calendar

Needs the @vibra registry in your components.json — set it up once.

Source

app/projects/calendar/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { ScheduleView } from "./components/schedule-view"
import { calendarEvents, currentUser, monthAt, monthTotal, shellNotifications, TODAY } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"

/**
 * The team's schedule. The page is a server component; the month on show, the
 * agenda window and the entry being inspected are one client island, because
 * all three are the same piece of state over the same list of entries — which
 * the page reads and hands it, so the store stays on the server.
 */
export default function CalendarPage() {
  const thisMonth = monthAt(0)

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.calendar}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Calendar"
        description="Meetings, deadlines, releases and time off across the team."
        meta={`${monthTotal(thisMonth)} entries this month · times in UTC`}
      />

      <ScheduleView events={calendarEvents()} today={TODAY} />
    </AppShell>
  )
}
app/projects/calendar/data.ts
/**
 * What this page reads. Every entry is a `db.events` row and every attendee is
 * the `db.members` row that row points at; the month grid and the agenda are
 * two windows onto the same list. "Now" is `REFERENCE_DATE`, read in UTC.
 *
 * The arithmetic over the entries — the weeks a month spans, the agenda over
 * a window, the labels — is `calendar.ts`, which reads no `db`. It is bound
 * here to the store's entries for the page's own lines; the page hands the
 * same entries to the schedule island as props, and the island binds it
 * again, so the store never follows the island into the browser.
 */
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Event, type Member } from "@/lib/sample-data"

import { calendarOf, utcDay, type AgendaDay, type CalendarEvent } from "./calendar"

function toCalendarEvent(event: Event, members: Map<string, Member>): CalendarEvent {
  return {
    id: event.id,
    title: event.title,
    kind: event.kind,
    startsAt: event.startsAt,
    endsAt: event.endsAt,
    allDay: event.allDay === true,
    location: event.location,
    attendees: event.attendees.map((id) => {
      const member = members.get(id)
      return { name: member?.name ?? "Former teammate", src: member?.avatarUrl }
    }),
  }
}

/** The day "now" falls on, in UTC. Every judgement on this page is made against it. */
export const TODAY = utcDay(REFERENCE_DATE)

/**
 * Every entry, earliest first — what the schedule island is handed. Read per
 * call, never held at module scope: the attendees are the members as they are
 * now, a rename included.
 */
export function calendarEvents(): CalendarEvent[] {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return db.events
    .all()
    .map((event) => toCalendarEvent(event, members))
    .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
}

/** The month `offset` months from the one "now" falls in. */
export function monthAt(offset: number): Date {
  return calendarOf([], TODAY).monthAt(offset)
}

/** How many entries fall in `anchor`'s month — the line under the month name. */
export function monthTotal(anchor: Date): number {
  return calendarOf(calendarEvents(), TODAY).monthTotal(anchor)
}

/** The agenda over a window of picker days: one group per day that has anything on it. */
export function agenda(from: Date, to: Date): AgendaDay[] {
  return calendarOf(calendarEvents(), TODAY).agenda(from, to)
}

/** The window the agenda opens on: today and the week ahead, as the picker holds them. */
export const DEFAULT_RANGE = calendarOf([], TODAY).defaultRange

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/projects/calendar/calendar.ts
/**
 * The calendar's arithmetic and its labels, over whatever entries it is
 * handed: which entries cover a day, the whole weeks a month spans, the
 * agenda over a window, and the words every date on the page is read with.
 * Pure — no `db`, no clock — so it sits beside `data.ts` without ever reaching
 * the store: `data.ts` binds it to the store's entries on the server, and the
 * schedule island binds it to the same entries handed down as props.
 *
 * The whole page is read in **UTC** — the day a cell stands for, the day an
 * entry falls on, and every label. A calendar that took its day boundaries
 * from the viewer's zone and its labels from another would move an entry to
 * the day before for everyone west of Greenwich, and `formatDate` has no
 * `timeZone` to pin it with; one zone, stated in the page's own meta, is what
 * keeps the grid, the agenda and the panel agreeing with each other.
 */
import type { Event } from "@/lib/sample-data"

/** One entry, with its attendees resolved from ids to the people they name. */
export type CalendarEvent = {
  id: string
  title: string
  kind: Event["kind"]
  startsAt: Date
  endsAt: Date
  allDay: boolean
  location?: string
  attendees: { name: string; src?: string }[]
}

/** One box in the month grid. */
export type DayCell = {
  date: Date
  inMonth: boolean
  isToday: boolean
  events: CalendarEvent[]
}

/** One day of the agenda, with the entries that cover it. */
export type AgendaDay = { date: Date; events: CalendarEvent[] }

const DAY_MS = 86_400_000

/** UTC midnight on the day `date` falls on. */
export function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

/** UTC midnight `days` after `date` — safe arithmetic, because UTC has no DST. */
function addDays(date: Date, days: number): Date {
  return new Date(date.getTime() + days * DAY_MS)
}

/** UTC midnight on the first of `date`'s month. */
function startOfMonth(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
}

/** The Sunday on or before `date`. */
function startOfWeek(date: Date): Date {
  return addDays(date, -date.getUTCDay())
}

/** Whether an entry covers `day` — a three-day absence shows on all three. */
function covers(event: CalendarEvent, day: Date): boolean {
  const end = addDays(day, 1)
  return event.startsAt < end && event.endsAt >= day
}

/** UTC midnight on the first of the month `offset` months from `anchor`'s. */
function monthAtOf(anchor: Date, offset: number): Date {
  return new Date(Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + offset, 1))
}

/**
 * The one seam between this page's UTC and `DateRangePicker`'s calendar, which
 * is the reader's own zone: the picker hands back the *local* midnight of the
 * box that was pressed and labels it with `formatDate`, which has no
 * `timeZone`. Its instant therefore means nothing here, but its calendar
 * fields are exactly the day the reader chose — so a picker value is read for
 * the day its fields say, in any zone.
 */
export function utcDayOf(value: Date): Date {
  return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()))
}

/**
 * The other half of that seam: a UTC day in the shape the picker emits and
 * prints, so a range seeded from this page's own days is labelled with the
 * same dates it drives.
 */
export function pickerDay(day: Date): Date {
  return new Date(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())
}

/** The schedule over one list of entries, read against one "today". */
export type Calendar = {
  /** The month `offset` months from the one today falls in. */
  monthAt: (offset: number) => Date
  /**
   * Whole weeks covering `anchor`'s month, Sunday to Saturday, so the grid is
   * always rectangular and the days either side of the month are there to be
   * greyed rather than left blank.
   */
  monthDays: (anchor: Date) => DayCell[]
  /** How many entries fall in `anchor`'s month — the line under the month name. */
  monthTotal: (anchor: Date) => number
  /**
   * The agenda: one group per day that has anything on it. `from` and `to` are
   * picker values — calendar days, read through `utcDayOf`.
   */
  agenda: (from: Date, to: Date) => AgendaDay[]
  /** One entry by id, for the panel that inspects it. */
  eventById: (id: string) => CalendarEvent | undefined
  /** The window the agenda opens on: today and the week ahead, as the picker holds them. */
  defaultRange: { from: Date; to: Date }
}

/** The schedule over `events`, earliest first, with `today` — UTC midnight — as the day every judgement is made against. */
export function calendarOf(events: CalendarEvent[], today: Date): Calendar {
  return {
    monthAt: (offset) => monthAtOf(today, offset),

    monthDays: (anchor) => {
      const first = startOfWeek(startOfMonth(anchor))
      const nextMonth = monthAtOf(anchor, 1)
      const cells: DayCell[] = []
      // Whole weeks: keep adding days until the month is covered and the last
      // week is finished.
      for (let day = first; day < nextMonth || day.getUTCDay() !== 0; day = addDays(day, 1)) {
        cells.push({
          date: day,
          inMonth: day.getUTCMonth() === anchor.getUTCMonth(),
          isToday: day.getTime() === today.getTime(),
          events: events.filter((event) => covers(event, day)),
        })
      }
      return cells
    },

    monthTotal: (anchor) => {
      const start = startOfMonth(anchor)
      const end = monthAtOf(anchor, 1)
      return events.filter((event) => event.startsAt < end && event.endsAt >= start).length
    },

    agenda: (from, to) => {
      const groups: AgendaDay[] = []
      const end = utcDayOf(to)
      for (let day = utcDayOf(from); day <= end; day = addDays(day, 1)) {
        const on = events.filter((event) => covers(event, day))
        if (on.length) groups.push({ date: day, events: on })
      }
      return groups
    },

    eventById: (id) => events.find((event) => event.id === id),

    defaultRange: { from: pickerDay(today), to: pickerDay(addDays(today, 7)) },
  }
}

/**
 * The labels every part of this page reads its dates with. They are exported
 * from here rather than written per component because the zone is one
 * decision: the same UTC that decides which day a cell stands for has to name
 * it, or the grid and its labels disagree by a day.
 */
export const monthLabel = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
  timeZone: "UTC",
})

export const dayLabel = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
})

export const longDayLabel = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
})

export const timeLabel = new Intl.DateTimeFormat("en-US", {
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})
app/projects/calendar/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/projects/calendar/components/schedule-view.tsx
"use client"

import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import { DateRangePicker, type DateRange } from "@/components/ui/date-range-picker"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"

import { calendarOf, longDayLabel, monthLabel, timeLabel, type CalendarEvent } from "../calendar"
import { AgendaList } from "./agenda-list"
import { KIND_LABEL, MonthGrid } from "./month-grid"

export type ScheduleViewProps = {
  /** Every entry, earliest first, read on the server page. */
  events: CalendarEvent[]
  /** UTC midnight on the day "now" falls on — the month the grid opens on. */
  today: Date
}

/**
 * The schedule. One island: the month on show, the agenda window and the entry
 * being inspected are the same piece of state, and all three read the same
 * list of entries — the list the server page hands down, so the store never
 * follows the island into the browser.
 */
export function ScheduleView({ events, today }: ScheduleViewProps) {
  const calendar = React.useMemo(() => calendarOf(events, today), [events, today])
  const [offset, setOffset] = React.useState(0)
  const [range, setRange] = React.useState<DateRange | undefined>(calendar.defaultRange)
  const [selectedId, setSelectedId] = React.useState<string | null>(null)

  // Keyed on the offset, not on the anchor: monthAt returns a fresh Date every
  // render, so a memo over it would never hit.
  const anchor = React.useMemo(() => calendar.monthAt(offset), [calendar, offset])
  const days = React.useMemo(() => calendar.monthDays(anchor), [calendar, anchor])
  // Clearing the range is a real state, and the agenda says so rather than
  // quietly falling back to a window the trigger is no longer showing.
  const groups = React.useMemo(
    () => (range?.from ? calendar.agenda(range.from, range.to ?? range.from) : []),
    [calendar, range]
  )
  const selected = selectedId ? calendar.eventById(selectedId) : undefined

  return (
    <>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div className="flex items-center gap-2">
          <Button
            type="button"
            variant="outline"
            size="icon-sm"
            aria-label="Previous month"
            onClick={() => setOffset((value) => value - 1)}
          >
            <ChevronLeftIcon aria-hidden="true" />
          </Button>
          <Button
            type="button"
            variant="outline"
            size="icon-sm"
            aria-label="Next month"
            onClick={() => setOffset((value) => value + 1)}
          >
            <ChevronRightIcon aria-hidden="true" />
          </Button>
          <div className="flex min-w-0 flex-col">
            <h2 className="text-sm leading-tight font-medium">{monthLabel.format(anchor)}</h2>
            <p className="text-xs tabular-nums text-muted-foreground">
              {calendar.monthTotal(anchor)} entries
            </p>
          </div>
          {offset === 0 ? null : (
            <Button type="button" variant="ghost" size="sm" onClick={() => setOffset(0)}>
              Today
            </Button>
          )}
        </div>

        {/* The picker's trigger is w-full by default, which would stretch it
            across the toolbar; it sizes to its own content here. */}
        <DateRangePicker
          size="sm"
          align="end"
          presets={false}
          value={range}
          onValueChange={setRange}
          aria-label="Agenda dates"
          placeholder="Pick agenda dates"
          className="w-auto"
        />
      </div>

      <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
        <MonthGrid
          days={days}
          label={monthLabel.format(anchor)}
          selectedId={selectedId}
          onSelect={setSelectedId}
        />
        <AgendaList
          groups={groups}
          emptyDescription={
            range?.from
              ? "No entries fall in the dates you picked."
              : "Pick dates above to fill the agenda."
          }
          selectedId={selectedId}
          onSelect={setSelectedId}
        />
      </div>

      <InspectorPanel
        open={Boolean(selected)}
        onOpenChange={(open) => {
          if (!open) setSelectedId(null)
        }}
        title={selected?.title ?? ""}
        description={selected ? KIND_LABEL[selected.kind] : undefined}
      >
        {selected ? (
          <div className="flex flex-col gap-4">
            <DescriptionList
              items={[
                { term: "Date", description: longDayLabel.format(selected.startsAt) },
                {
                  term: "Time",
                  description: selected.allDay
                    ? "All day"
                    : `${timeLabel.format(selected.startsAt)} – ${timeLabel.format(selected.endsAt)} UTC`,
                },
                { term: "Kind", description: KIND_LABEL[selected.kind] },
                ...(selected.location
                  ? [{ term: "Location", description: selected.location }]
                  : []),
                {
                  term: "Reference",
                  description: <span className="font-mono text-xs">{selected.id}</span>,
                },
              ]}
            />

            <div className="flex flex-col gap-2">
              <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
                Attendees
              </h3>
              <AvatarGroup size="sm" max={6} users={selected.attendees} />
              <p className="text-xs text-muted-foreground">
                {selected.attendees.map((attendee) => attendee.name).join(", ")}
              </p>
            </div>
          </div>
        ) : null}
      </InspectorPanel>
    </>
  )
}
app/projects/calendar/components/month-grid.tsx
"use client"

import { cn } from "@/lib/utils"

import { dayLabel, timeLabel, type CalendarEvent, type DayCell } from "../calendar"

/** How many chips fit in a box before the rest become a "+N more" line. */
const VISIBLE = 3

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

// Kind is the only thing colour says here, and each tone is paired with the
// word itself in the chip's accessible name, so nothing reads by hue alone.
const KIND_CLASS: Record<CalendarEvent["kind"], string> = {
  meeting: "bg-info-muted text-info",
  deadline: "bg-danger-muted text-danger",
  release: "bg-success-muted text-success",
  ooo: "bg-surface text-muted-foreground",
}

const KIND_LABEL: Record<CalendarEvent["kind"], string> = {
  meeting: "Meeting",
  deadline: "Deadline",
  release: "Release",
  ooo: "Out of office",
}

/** The cells cut into weeks, so each one can be a row of its own. */
function weeksOf(days: DayCell[]): DayCell[][] {
  const weeks: DayCell[][] = []
  for (let start = 0; start < days.length; start += 7) {
    weeks.push(days.slice(start, start + 7))
  }
  return weeks
}

/**
 * The month as a rectangle of days, each holding the entries that cover it.
 * A real grid: rows and cells carry the roles, so the whole month can be
 * walked a week at a time rather than read as one flat run of buttons — and
 * every chip names its own day, because out of the grid's shape a chip called
 * "Retro" says nothing about when it is.
 */
export function MonthGrid({
  days,
  label,
  selectedId,
  onSelect,
}: {
  days: DayCell[]
  /** Names the grid — the month it is showing. */
  label: string
  selectedId: string | null
  onSelect: (id: string) => void
}) {
  return (
    <div role="grid" aria-label={label} className="overflow-hidden panel">
      <div role="row" className="grid grid-cols-7 border-b bg-surface">
        {WEEKDAYS.map((day) => (
          <div
            key={day}
            role="columnheader"
            className="px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
          >
            <span aria-hidden="true" className="sm:hidden">
              {day.slice(0, 1)}
            </span>
            <span className="max-sm:sr-only">{day}</span>
          </div>
        ))}
      </div>

      {weeksOf(days).map((week) => (
        <div role="row" key={week[0].date.toISOString()} className="grid grid-cols-7">
          {week.map((cell) => {
            const hidden = cell.events.length - VISIBLE
            const day = dayLabel.format(cell.date)

            return (
              <div
                role="gridcell"
                key={cell.date.toISOString()}
                data-in-month={cell.inMonth || undefined}
                className={cn(
                  "flex min-h-24 flex-col gap-1 border-b border-r p-1 last:border-r-0",
                  !cell.inMonth && "bg-surface/60"
                )}
              >
                <div className="flex items-center justify-between px-1">
                  <span
                    aria-current={cell.isToday ? "date" : undefined}
                    className={cn(
                      "text-xs tabular-nums",
                      // The date is what tells a reader the row starting "30, 31"
                      // is still August, so it stays readable: the muting is the cell
                      // behind it, not the number itself.
                      cell.inMonth ? "text-foreground" : "text-muted-foreground",
                      cell.isToday &&
                        "flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
                    )}
                  >
                    <span aria-hidden="true">{cell.date.getUTCDate()}</span>
                    <span className="sr-only">{cell.isToday ? `${day}, today` : day}</span>
                  </span>
                </div>

                {cell.events.slice(0, VISIBLE).map((event) => (
                  <button
                    key={`${cell.date.toISOString()}-${event.id}`}
                    type="button"
                    data-selected={event.id === selectedId || undefined}
                    aria-current={event.id === selectedId ? "true" : undefined}
                    onClick={() => onSelect(event.id)}
                    className={cn(
                      "flex w-full items-center gap-1 truncate rounded-sm px-1 py-0.5 text-left text-2xs leading-4 transition-colors hover:brightness-95 focus-ring data-[selected=true]:bg-brand-muted data-[selected=true]:font-medium data-[selected=true]:text-foreground data-[selected=true]:shadow-[inset_2px_0_0_var(--brand)] rtl:data-[selected=true]:shadow-[inset_-2px_0_0_var(--brand)] dark:hover:brightness-125",
                      KIND_CLASS[event.kind]
                    )}
                  >
                    {!event.allDay ? (
                      // On a phone a seven-column month has barely room for a
                      // word, so the chip spends it on the title and leaves the
                      // time to the agenda underneath.
                      <span className="shrink-0 tabular-nums opacity-70 max-sm:hidden">
                        {timeLabel.format(event.startsAt)}
                      </span>
                    ) : null}
                    <span className="truncate">{event.title}</span>
                    <span className="sr-only">
                      , {KIND_LABEL[event.kind]}, {day}
                    </span>
                  </button>
                ))}

                {hidden > 0 ? (
                  <span className="px-1 text-2xs text-muted-foreground">
                    +{hidden} more
                  </span>
                ) : null}
              </div>
            )
          })}
        </div>
      ))}
    </div>
  )
}

export { KIND_LABEL }
app/projects/calendar/components/agenda-list.tsx
"use client"

import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"

import { dayLabel, timeLabel, type CalendarEvent } from "../calendar"
import { KIND_LABEL } from "./month-grid"

/** When an entry runs: the hours, or the word for a day that has none. */
function when(event: CalendarEvent): string {
  if (event.allDay) return "All day"
  const start = timeLabel.format(event.startsAt)
  const end = timeLabel.format(event.endsAt)
  return start === end ? start : `${start} – ${end}`
}

/** The picked window, one group per day that has anything on it. */
export function AgendaList({
  groups,
  emptyDescription,
  selectedId,
  onSelect,
}: {
  groups: { date: Date; events: CalendarEvent[] }[]
  /** Why the agenda is empty: no dates picked, or nothing in the ones that are. */
  emptyDescription: string
  selectedId: string | null
  onSelect: (id: string) => void
}) {
  return (
    <section aria-label="Agenda" className="flex flex-col gap-3 panel p-3">
      <h2 className="px-1 text-sm font-medium">Agenda</h2>

      {groups.length === 0 ? (
        <EmptyState size="sm" title="Nothing scheduled" description={emptyDescription} />
      ) : (
        <div className="flex flex-col gap-4">
          {groups.map((group) => (
            <div key={group.date.toISOString()} className="flex flex-col gap-1">
              <h3 className="px-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
                {dayLabel.format(group.date)}
              </h3>
              <DataList divided>
                {group.events.map((event) => (
                  <DataListItem
                    key={event.id}
                    selected={event.id === selectedId}
                    onClick={() => onSelect(event.id)}
                    className="px-2"
                    title={event.title}
                    description={`${KIND_LABEL[event.kind]}${event.location ? ` · ${event.location}` : ""}`}
                    meta={when(event)}
                  />
                ))}
              </DataList>
            </div>
          ))}
        </div>
      )}
    </section>
  )
}