Menu

Change language

GitHub repo

Event Calendar Dashboard

blockproductivity

Month-view event calendar with stats strip, drag/shift range select, side rail (selected day or range summary), upcoming list, and per-cell/per-event context menus. events / onEventsChange binds the source of truth; eventTypes config maps each type key to its label/icon/color theme so swapping the visual palette is one prop. Built on the use-month-grid headless hook.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/event-calendar-dashboard.json
Manifest:https://uipkge.dev/r/lit/event-calendar-dashboard.jsonInstalls to:src/uip/blocks/event-calendar-dashboard/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
initial-datestring—optional
week-starts-on0 | 10optional
loadingbooleanfalseoptional
headingstringCalendaroptional
descriptionstringSchedule, meetings, and deadlines. Click or shift-click to select a range.optional
show-statsbooleantrueoptional
show-side-railbooleantrueoptional
show-searchbooleantrueoptional
show-view-selectbooleantrueoptional
show-legendbooleantrueoptional
show-upcomingbooleantrueoptional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DashboardCalendarEvent
interface DashboardCalendarEvent {
  id: string
  title: string
  /** YYYY-MM-DD */
  date: string
  /** HH:mm */
  start: string
  /** HH:mm */
  end: string
  type: string
  description?: string
  location?: string
  attendees?: string[]
  status?: string
}
DashboardEventTypeMeta
interface DashboardEventTypeMeta {
  label: string
  icon: string
  chip: string
  bar: string
  dot: string
  glow: string
  text: string
  ring: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-event-calendar-dashboard': UipEventCalendarDashboard
  }
}

Files installed (1)

  • src/uip/blocks/event-calendar-dashboard/event-calendar-dashboard.ts46.8 kB
    import { LitElement, css, html, nothing } from 'lit'
    import {
      AlertCircle,
      CalendarDays,
      CheckCircle2,
      ChevronLeft,
      ChevronRight,
      Clock,
      GraduationCap,
      Headphones,
      ListFilter,
      MapPin,
      MousePointer2,
      Pencil,
      Plane,
      Plus,
      Search,
      Trash2,
      Users,
      Video,
      Wrench,
      X,
      type IconNode,
    } from 'lucide'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/button/button'
    import '../../components/dialog/dialog'
    import '../../components/input/input'
    import '../../components/select/select'
    import '../../components/separator/separator'
    import '../../components/skeleton/skeleton'
    import type { UipInput } from '../../components/input/input'
    import type { UipSelect } from '../../components/select/select'
    
    export interface DashboardCalendarEvent {
      id: string
      title: string
      /** YYYY-MM-DD */
      date: string
      /** HH:mm */
      start: string
      /** HH:mm */
      end: string
      type: string
      description?: string
      location?: string
      attendees?: string[]
      status?: string
    }
    
    /**
     * Visual + semantic config per event type. Mirrors React's EventTypeMeta,
     * except `icon` is a kebab-case icon name (resolved to lucide inside the
     * shadow root) instead of a Lucide component — components can't cross the
     * custom-element boundary from plain HTML.
     */
    export interface DashboardEventTypeMeta {
      label: string
      icon: string
      chip: string
      bar: string
      dot: string
      glow: string
      text: string
      ring: string
    }
    
    export const defaultDashboardEventTypes: Record<string, DashboardEventTypeMeta> = {
      meeting: {
        label: 'Meeting',
        icon: 'video',
        chip: 'bg-chart-1/10 text-chart-1 ring-chart-1/20',
        bar: 'bg-chart-1',
        dot: 'bg-chart-1',
        glow: 'bg-chart-1/10',
        text: 'text-chart-1',
        ring: 'hover:ring-chart-1/30',
      },
      task: {
        label: 'Task',
        icon: 'circle-check',
        chip: 'bg-success/10 text-success ring-success/20',
        bar: 'bg-success',
        dot: 'bg-success',
        glow: 'bg-success/10',
        text: 'text-success',
        ring: 'hover:ring-success/30',
      },
      reminder: {
        label: 'Reminder',
        icon: 'circle-alert',
        chip: 'bg-warning/10 text-warning ring-warning/20',
        bar: 'bg-warning',
        dot: 'bg-warning',
        glow: 'bg-warning/10',
        text: 'text-warning',
        ring: 'hover:ring-warning/30',
      },
      travel: {
        label: 'Travel',
        icon: 'plane',
        chip: 'bg-chart-3/10 text-chart-3 ring-chart-3/20',
        bar: 'bg-chart-3',
        dot: 'bg-chart-3',
        glow: 'bg-chart-3/10',
        text: 'text-chart-3',
        ring: 'hover:ring-chart-3/30',
      },
    }
    
    const NEUTRAL_META: DashboardEventTypeMeta = {
      label: 'Event',
      icon: 'calendar-days',
      chip: 'bg-muted text-muted-foreground ring-muted-foreground/20',
      bar: 'bg-muted-foreground',
      dot: 'bg-muted-foreground',
      glow: 'bg-muted-foreground/10',
      text: 'text-muted-foreground',
      ring: 'hover:ring-muted-foreground/30',
    }
    
    function typeIcon(name: string, cls: string) {
      const map: Record<string, [IconNode, string]> = {
        video: [Video, 'video'],
        'circle-check': [CheckCircle2, 'circle-check-big'],
        'circle-alert': [AlertCircle, 'circle-alert'],
        plane: [Plane, 'plane'],
        headphones: [Headphones, 'headphones'],
        wrench: [Wrench, 'wrench'],
        'graduation-cap': [GraduationCap, 'graduation-cap'],
        'calendar-days': [CalendarDays, 'calendar-days'],
      }
      const [node, kebab] = map[name] ?? [CalendarDays, 'calendar-days']
      return icon(node, kebab, cls)
    }
    
    function pad(n: number): string {
      return String(n).padStart(2, '0')
    }
    
    function dateKey(d: Date): string {
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
    }
    
    function dateFromKey(key: string): Date {
      const [y, m, d] = key.split('-').map(Number)
      return new Date(y!, m! - 1, d)
    }
    
    /**
     * <uip-event-calendar-dashboard> — the registry EventCalendar block as a web component.
     *
     * A faithful month-view port of React's EventCalendar: stats strip, day
     * click / shift-click range select, side rail (selected day or range
     * summary), upcoming list, search, skeleton loading, show* toggles, and the
     * event detail dialog. `events` / `eventTypes` are settable properties
     * (arrays/objects cross the boundary as properties, not attributes).
     *
     * Deviations from React (documented): no drag-to-range (click /
     * shift-click only — pointer drag across shadow-DOM cells is unreliable),
     * no per-cell/per-event context menus (uip-context-menu trigger-wrapping
     * per cell is left to consumers), no headerActions / eventDialog / emptyDay
     * render-prop slots (use light-DOM composition instead), and
     * `eventTypes[].icon` is a kebab icon-name string rather than a Lucide
     * component. Callbacks become kebab CustomEvents: `event-click`,
     * `event-create` (dateKey), `event-edit` / `event-duplicate` /
     * `event-delete` (event), `range-change` ({ lo, hi }).
     */
    export class UipEventCalendarDashboard extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        events: { attribute: false },
        eventTypes: { attribute: false },
        initialDate: { attribute: 'initial-date' },
        weekStartsOn: { type: Number, attribute: 'week-starts-on' },
        loading: { type: Boolean },
        heading: {},
        description: {},
        showStats: { type: Boolean, attribute: 'show-stats' },
        showSideRail: { type: Boolean, attribute: 'show-side-rail' },
        showSearch: { type: Boolean, attribute: 'show-search' },
        showViewSelect: { type: Boolean, attribute: 'show-view-select' },
        showLegend: { type: Boolean, attribute: 'show-legend' },
        showUpcoming: { type: Boolean, attribute: 'show-upcoming' },
        view: { state: true },
        search: { state: true },
        cursorYear: { state: true },
        cursorMonth: { state: true },
        rangeStart: { state: true },
        rangeEnd: { state: true },
        dialogOpen: { state: true },
        selectedEvent: { state: true },
      }
    
      events: DashboardCalendarEvent[] = []
      eventTypes: Record<string, DashboardEventTypeMeta> = defaultDashboardEventTypes
      initialDate?: string
      weekStartsOn: 0 | 1 = 0
      loading = false
      /** React's `title` (`title` is taken by HTMLElement). */
      heading = 'Calendar'
      description = 'Schedule, meetings, and deadlines. Click or shift-click to select a range.'
      showStats = true
      showSideRail = true
      showSearch = true
      showViewSelect = true
      showLegend = true
      showUpcoming = true
    
      private view: 'month' | 'week' | 'day' = 'month'
      private search = ''
      private cursorYear = 0
      private cursorMonth = 0
      private rangeStart = ''
      private rangeEnd = ''
      private dialogOpen = false
      private selectedEvent: DashboardCalendarEvent | null = null
    
      constructor() {
        super()
        new ThemeController(this)
        const now = new Date()
        this.cursorYear = now.getFullYear()
        this.cursorMonth = now.getMonth()
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'event-calendar-dashboard')
        const base = this.initialDate ? dateFromKey(this.initialDate) : new Date()
        this.cursorYear = base.getFullYear()
        this.cursorMonth = base.getMonth()
        const today = dateKey(new Date())
        this.rangeStart = today
        this.rangeEnd = today
      }
    
      private get todayKey(): string {
        return dateKey(new Date())
      }
    
      private get monthLabel(): string {
        return new Date(this.cursorYear, this.cursorMonth, 1).toLocaleDateString('en-US', {
          month: 'long',
          year: 'numeric',
        })
      }
    
      private get weekdays(): string[] {
        const base = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
        return this.weekStartsOn === 1 ? [...base.slice(1), base[0]!] : base
      }
    
      private get gridDays(): Array<{ key: string; date: Date; inMonth: boolean }> {
        const first = new Date(this.cursorYear, this.cursorMonth, 1)
        let offset = first.getDay()
        if (this.weekStartsOn === 1) offset = (offset + 6) % 7
        const start = new Date(this.cursorYear, this.cursorMonth, 1 - offset)
        return Array.from({ length: 42 }, (_, i) => {
          const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
          return { key: dateKey(date), date, inMonth: date.getMonth() === this.cursorMonth }
        })
      }
    
      private get rangeBounds(): { lo: string; hi: string } {
        return this.rangeStart <= this.rangeEnd
          ? { lo: this.rangeStart, hi: this.rangeEnd }
          : { lo: this.rangeEnd, hi: this.rangeStart }
      }
    
      private get isRange(): boolean {
        return this.rangeBounds.lo !== this.rangeBounds.hi
      }
    
      private get rangeDayCount(): number {
        const { lo, hi } = this.rangeBounds
        return Math.round((dateFromKey(hi).getTime() - dateFromKey(lo).getTime()) / 86_400_000) + 1
      }
    
      private inRange(key: string): boolean {
        const { lo, hi } = this.rangeBounds
        return key >= lo && key <= hi
      }
    
      private metaFor(type: string): DashboardEventTypeMeta {
        return this.eventTypes[type] ?? NEUTRAL_META
      }
    
      private get typeKeys(): string[] {
        return Object.keys(this.eventTypes)
      }
    
      private get filtered(): DashboardCalendarEvent[] {
        const q = this.search.trim().toLowerCase()
        if (!q) return this.events
        return this.events.filter(
          (e) =>
            e.title.toLowerCase().includes(q) ||
            (e.description ?? '').toLowerCase().includes(q) ||
            (e.location ?? '').toLowerCase().includes(q),
        )
      }
    
      private get eventsByDate(): Map<string, DashboardCalendarEvent[]> {
        const map = new Map<string, DashboardCalendarEvent[]>()
        for (const e of this.filtered) {
          if (!map.has(e.date)) map.set(e.date, [])
          map.get(e.date)!.push(e)
        }
        for (const arr of map.values()) arr.sort((a, b) => a.start.localeCompare(b.start))
        return map
      }
    
      private get monthCounts(): Record<string, number> & { total: number } {
        const out: Record<string, number> & { total: number } = { total: 0 }
        for (const k of this.typeKeys) out[k] = 0
        for (const e of this.events) {
          const d = new Date(`${e.date}T00:00:00`)
          if (d.getFullYear() === this.cursorYear && d.getMonth() === this.cursorMonth) {
            if (out[e.type] !== undefined) out[e.type]!++
            out.total++
          }
        }
        return out
      }
    
      private get typeStats(): Record<string, { weeks: number[]; next: DashboardCalendarEvent | null }> {
        const out: Record<string, { weeks: number[]; next: DashboardCalendarEvent | null }> = {}
        for (const k of this.typeKeys) out[k] = { weeks: [0, 0, 0, 0, 0, 0], next: null }
        const firstOffset = new Date(this.cursorYear, this.cursorMonth, 1).getDay()
        for (const e of this.events) {
          const d = new Date(`${e.date}T00:00:00`)
          if (d.getFullYear() === this.cursorYear && d.getMonth() === this.cursorMonth && out[e.type]) {
            const week = Math.floor((d.getDate() - 1 + firstOffset) / 7)
            out[e.type]!.weeks[week]!++
          }
        }
        for (const t of this.typeKeys) {
          out[t]!.next =
            this.events
              .filter((e) => e.type === t && e.date >= this.todayKey)
              .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0] ?? null
        }
        return out
      }
    
      private fmtNextDate(key: string): string {
        if (key === this.todayKey) return 'Today'
        return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
      }
    
      private get rangeEvents(): DashboardCalendarEvent[] {
        const { lo, hi } = this.rangeBounds
        return this.filtered
          .filter((e) => e.date >= lo && e.date <= hi)
          .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))
      }
    
      private get selectedDayEvents(): DashboardCalendarEvent[] {
        return this.eventsByDate.get(this.rangeStart) ?? []
      }
    
      private get upcoming(): DashboardCalendarEvent[] {
        return this.filtered
          .filter((e) => e.date > this.todayKey)
          .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))
          .slice(0, 4)
      }
    
      private openEvent(e: DashboardCalendarEvent) {
        this.selectedEvent = e
        this.dialogOpen = true
        this.dispatchEvent(new CustomEvent('event-click', { detail: e, bubbles: true, composed: true }))
      }
    
      private prevMonth() {
        const d = new Date(this.cursorYear, this.cursorMonth - 1, 1)
        this.cursorYear = d.getFullYear()
        this.cursorMonth = d.getMonth()
      }
    
      private nextMonth() {
        const d = new Date(this.cursorYear, this.cursorMonth + 1, 1)
        this.cursorYear = d.getFullYear()
        this.cursorMonth = d.getMonth()
      }
    
      private goToToday() {
        const now = new Date()
        this.cursorYear = now.getFullYear()
        this.cursorMonth = now.getMonth()
        const key = dateKey(now)
        this.rangeStart = key
        this.rangeEnd = key
        this.emitRange()
      }
    
      private clearRange() {
        this.rangeEnd = this.rangeStart
        this.emitRange()
      }
    
      private onCellClick(key: string, e: MouseEvent) {
        if (e.shiftKey) {
          this.rangeEnd = key
        } else {
          this.rangeStart = key
          this.rangeEnd = key
        }
        this.emitRange()
      }
    
      private emitRange() {
        this.dispatchEvent(
          new CustomEvent('range-change', { detail: this.rangeBounds, bubbles: true, composed: true }),
        )
      }
    
      private fmtDayLong(key: string): string {
        return dateFromKey(key).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
      }
    
      private fmtDayShort(key: string): string {
        return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
      }
    
      private initials(name: string): string {
        return name
          .split(' ')
          .map((n) => n[0])
          .join('')
          .slice(0, 2)
      }
    
      private cellRangeClass(key: string, inMonth: boolean): string {
        const { lo, hi } = this.rangeBounds
        if (!this.inRange(key)) return inMonth ? 'bg-background hover:bg-accent/40' : 'bg-muted/20 hover:bg-muted/30'
        if (key === lo && key === hi) return 'bg-accent/30 ring-1 ring-inset ring-primary/60'
        let cls = 'bg-primary/10 hover:bg-primary/15'
        if (key === lo) cls += ' ring-1 ring-inset ring-primary/60'
        if (key === hi) cls += ' ring-1 ring-inset ring-primary/60'
        return cls
      }
    
      render() {
        const monthCounts = this.monthCounts
        const typeStats = this.typeStats
        const gridDays = this.gridDays
        const eventsByDate = this.eventsByDate
        const todayKey = this.todayKey
        const rangeEvents = this.rangeEvents
        const selectedDayEvents = this.selectedDayEvents
        const upcoming = this.upcoming
        const rangeTypeCounts: Record<string, number> = {}
        for (const k of this.typeKeys) rangeTypeCounts[k] = 0
        for (const e of rangeEvents) {
          if (rangeTypeCounts[e.type] !== undefined) rangeTypeCounts[e.type]!++
        }
        return html`
          <div data-slot="event-calendar-dashboard" class="flex flex-col gap-5">
            <header class="flex flex-wrap items-end justify-between gap-4">
              <div>
                <h1 class="text-2xl font-semibold tracking-tight">${this.heading}</h1>
                ${this.description ? html`<p class="text-muted-foreground mt-1 text-sm">${this.description}</p>` : nothing}
              </div>
              <div class="flex items-center gap-2">
                ${this.showSearch
                  ? html`<div class="relative">
                      <uip-input
                        .value=${this.search}
                        placeholder="Search events"
                        class="h-8 w-56 text-xs"
                        @input=${(e: Event) => {
                          this.search = (e.target as UipInput).value
                        }}
                      >
                        <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span>
                      </uip-input>
                    </div>`
                  : nothing}
                ${this.showViewSelect
                  ? html`<uip-select
                      value=${this.view}
                      class="h-8 w-24 text-xs"
                      @change=${(e: Event) => {
                        this.view = (e.target as UipSelect).value as 'month' | 'week' | 'day'
                      }}
                    >
                      <option value="month">Month</option>
                      <option value="week">Week</option>
                      <option value="day">Day</option>
                    </uip-select>`
                  : nothing}
                <uip-button
                  size="sm"
                  class="gap-1.5"
                  @click=${() =>
                    this.dispatchEvent(new CustomEvent('event-create', { detail: this.rangeStart, bubbles: true, composed: true }))}
                >
                  ${icon(Plus, 'plus', 'size-3.5')} New event
                </uip-button>
              </div>
            </header>
    
            ${this.showStats
              ? html`
                  <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
                    ${this.typeKeys.map((key) => {
                      const meta = this.eventTypes[key]!
                      const searched = meta.label.toLowerCase() === this.search.toLowerCase()
                      return html`
                        <button
                          type="button"
                          class=${[
                            'group bg-card hover:bg-muted/40 relative isolate flex flex-col overflow-hidden rounded-xl border p-4 text-left ring-1 ring-transparent transition-[colors,box-shadow,transform] duration-150 ring-inset hover:shadow-xs active:scale-[0.98]',
                            meta.ring,
                          ].join(' ')}
                          @click=${() => {
                            this.search = searched ? '' : meta.label.toLowerCase()
                          }}
                        >
                          ${typeIcon(
                            meta.icon,
                            [
                              'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]',
                              meta.text,
                            ].join(' '),
                          )}
                          <div class="flex items-center justify-between gap-2">
                            <div class="flex items-center gap-1.5">
                              <span class=${['size-1.5 rounded-full', meta.dot].join(' ')}></span>
                              <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">${meta.label}</p>
                            </div>
                            <div
                              class=${['flex size-7 items-center justify-center rounded-md ring-1 ring-inset', meta.chip].join(' ')}
                            >
                              ${typeIcon(meta.icon, 'size-3.5')}
                            </div>
                          </div>
                          <div class="mt-2 flex items-baseline gap-1.5">
                            <p class="text-3xl leading-none font-semibold tabular-nums">${monthCounts[key] ?? 0}</p>
                            <p class="text-muted-foreground text-xs tracking-wider uppercase">this month</p>
                          </div>
                          <div class="mt-3 flex h-6 items-end gap-1">
                            ${typeStats[key]!.weeks.slice(0, 6).map(
                              (n, i) => html`
                                <span
                                  class=${[
                                    'flex-1 rounded-sm transition-colors',
                                    n > 0 ? meta.bar : 'bg-muted/40',
                                    n > 0 ? 'opacity-70 group-hover:opacity-100' : '',
                                  ].join(' ')}
                                  style="height: ${n > 0 ? Math.min(100, 30 + n * 35) : 20}%"
                                  title="Week ${i + 1}: ${n} ${meta.label.toLowerCase()}${n === 1 ? '' : 's'}"
                                ></span>
                              `,
                            )}
                          </div>
                          <div class="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2">
                            ${typeStats[key]!.next
                              ? html`
                                  <p class="text-muted-foreground text-xs tracking-wider uppercase">
                                    Next <span class=${meta.text}>${this.fmtNextDate(typeStats[key]!.next!.date)}</span>
                                  </p>
                                  <p class="mt-0.5 truncate text-xs font-medium">${typeStats[key]!.next!.title}</p>
                                `
                              : html`
                                  <p class="text-muted-foreground text-xs tracking-wider uppercase">No upcoming</p>
                                  <p class="text-muted-foreground/70 mt-0.5 text-xs">All clear</p>
                                `}
                          </div>
                        </button>
                      `
                    })}
                  </div>
                `
              : nothing}
    
            <div class=${['grid gap-4', this.showSideRail ? 'lg:grid-cols-[minmax(0,1fr)_320px]' : ''].join(' ')}>
              <div class="bg-card/40 overflow-hidden rounded-xl border">
                <div class="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5">
                  <div class="flex items-center gap-2">
                    <uip-button variant="outline" size="icon" class="size-7" aria-label="Previous month" @click=${this.prevMonth}>
                      ${icon(ChevronLeft, 'chevron-left', 'size-4')}
                    </uip-button>
                    <uip-button variant="outline" size="icon" class="size-7" aria-label="Next month" @click=${this.nextMonth}>
                      ${icon(ChevronRight, 'chevron-right', 'size-4')}
                    </uip-button>
                    <uip-button variant="ghost" size="sm" class="h-7 text-xs" @click=${this.goToToday}>Today</uip-button>
                    <h2 class="ml-2 text-sm font-semibold">${this.monthLabel}</h2>
                  </div>
                  <div class="text-muted-foreground flex items-center gap-3 text-xs">
                    ${this.isRange
                      ? html`
                          <div
                            class="bg-primary/10 text-primary ring-primary/20 flex items-center gap-1.5 rounded-full px-2 py-0.5 ring-1 ring-inset"
                          >
                            ${icon(MousePointer2, 'mouse-pointer-2', 'size-3')}
                            <span> ${this.rangeDayCount} days, ${rangeEvents.length} events </span>
                            <button class="hover:text-foreground ml-0.5" aria-label="Clear date range" @click=${this.clearRange}>
                              ${icon(X, 'x', 'size-3')}
                            </button>
                          </div>
                        `
                      : nothing}
                    <div class="flex items-center gap-1.5">
                      ${icon(CalendarDays, 'calendar-days', 'size-3')}
                      <span> ${monthCounts.total} event${monthCounts.total === 1 ? '' : 's'} this month </span>
                    </div>
                  </div>
                </div>
    
                <div class="bg-muted/10 text-muted-foreground grid grid-cols-7 border-b text-xs tracking-wider uppercase">
                  ${this.weekdays.map((w) => html`<div class="px-2 py-2 font-medium">${w}</div>`)}
                </div>
    
                <div class="grid grid-cols-7 select-none">
                  ${this.loading
                    ? Array.from({ length: 42 }).map(
                        () => html`
                          <div class="h-24 border-r border-b p-1.5 last:border-r-0">
                            <uip-skeleton class="h-3 w-6"></uip-skeleton>
                            <uip-skeleton class="mt-2 h-3 w-full"></uip-skeleton>
                          </div>
                        `,
                      )
                    : gridDays.map(
                        (d, i) => html`
                          <button
                            type="button"
                            class=${[
                              'group focus-visible:ring-ring relative flex h-24 flex-col gap-1 border-r border-b p-1.5 text-left transition-colors focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none',
                              (i + 1) % 7 === 0 ? 'border-r-0' : '',
                              i >= 35 ? 'border-b-0' : '',
                              this.cellRangeClass(d.key, d.inMonth),
                              !d.inMonth && !this.inRange(d.key) ? 'text-muted-foreground/60' : '',
                            ].join(' ')}
                            @click=${(e: MouseEvent) => this.onCellClick(d.key, e)}
                          >
                            <div class="flex items-center justify-between">
                              <span
                                class=${[
                                  'inline-flex size-5 items-center justify-center rounded-full text-xs tabular-nums',
                                  d.key === todayKey ? 'bg-primary text-primary-foreground font-semibold' : '',
                                  d.key !== todayKey && d.inMonth ? 'text-foreground' : '',
                                  !d.inMonth ? 'text-muted-foreground/60' : '',
                                ].join(' ')}
                              >
                                ${d.date.getDate()}
                              </span>
                              ${(eventsByDate.get(d.key)?.length ?? 0) > 0
                                ? html`<span class="text-muted-foreground text-xs tabular-nums"
                                    >${eventsByDate.get(d.key)!.length}</span
                                  >`
                                : nothing}
                            </div>
                            <div class="flex flex-col gap-0.5">
                              ${(eventsByDate.get(d.key) ?? []).slice(0, 2).map(
                                (e) => html`
                                  <span
                                    class=${[
                                      'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-xs ring-1 ring-inset',
                                      this.metaFor(e.type).chip,
                                    ].join(' ')}
                                    @click=${(ev: Event) => {
                                      ev.stopPropagation()
                                      this.openEvent(e)
                                    }}
                                  >
                                    <span class="tabular-nums opacity-70">${e.start}</span>
                                    <span class="truncate">${e.title}</span>
                                  </span>
                                `,
                              )}
                              ${(eventsByDate.get(d.key)?.length ?? 0) > 2
                                ? html`<span class="text-muted-foreground px-1 text-xs"
                                    >+${eventsByDate.get(d.key)!.length - 2} more</span
                                  >`
                                : nothing}
                            </div>
                          </button>
                        `,
                      )}
                </div>
    
                ${this.showLegend
                  ? html`
                      <div class="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-xs">
                        ${icon(ListFilter, 'list-filter', 'size-3')}
                        ${this.typeKeys.map(
                          (key) => html`
                            <div class="flex items-center gap-1.5">
                              <span class=${['size-2 rounded-full', this.eventTypes[key]!.dot].join(' ')}></span>
                              ${this.eventTypes[key]!.label}
                            </div>
                          `,
                        )}
                        <span class="ml-auto text-xs">Tip: click or shift-click to range. Click an event for details.</span>
                      </div>
                    `
                  : nothing}
              </div>
    
              ${this.showSideRail
                ? html`
                    <aside class="flex flex-col gap-4">
                      ${!this.isRange
                        ? html`
                            <div class="bg-card/40 overflow-hidden rounded-xl border">
                              <div class="bg-muted/30 border-b px-4 py-3">
                                <div class="flex items-center justify-between">
                                  <div>
                                    <p class="text-muted-foreground text-xs tracking-wider uppercase">
                                      ${this.rangeStart === todayKey ? 'Today' : 'Selected'}
                                    </p>
                                    <p class="mt-0.5 text-sm font-semibold">${this.fmtDayLong(this.rangeStart)}</p>
                                  </div>
                                  <div class="text-right">
                                    <p class="text-muted-foreground text-xs tracking-wider uppercase">Events</p>
                                    <p class="text-sm font-semibold tabular-nums">${selectedDayEvents.length}</p>
                                  </div>
                                </div>
                              </div>
                              <div class="max-h-[420px] overflow-y-auto p-3">
                                ${this.loading
                                  ? Array.from({ length: 3 }).map(
                                      () => html`
                                        <div class="mb-2 space-y-2 rounded-lg border p-3">
                                          <uip-skeleton class="h-3 w-32"></uip-skeleton>
                                          <uip-skeleton class="h-2 w-20"></uip-skeleton>
                                        </div>
                                      `,
                                    )
                                  : selectedDayEvents.length === 0
                                    ? html`
                                        <div class="flex flex-col items-center justify-center gap-2 py-8 text-center">
                                          <div class="bg-muted flex size-10 items-center justify-center rounded-full">
                                            ${icon(CalendarDays, 'calendar-days', 'text-muted-foreground size-5')}
                                          </div>
                                          <p class="text-sm font-medium">Nothing scheduled</p>
                                          <p class="text-muted-foreground text-xs">Click a date or add a new event.</p>
                                          <uip-button
                                            size="sm"
                                            variant="outline"
                                            class="mt-1 h-7 gap-1.5 text-xs"
                                            @click=${() =>
                                              this.dispatchEvent(
                                                new CustomEvent('event-create', { detail: this.rangeStart, bubbles: true, composed: true }),
                                              )}
                                          >
                                            ${icon(Plus, 'plus', 'size-3')} New event
                                          </uip-button>
                                        </div>
                                      `
                                    : selectedDayEvents.map(
                                        (e) => html`
                                          <div
                                            class="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors"
                                            @click=${() => this.openEvent(e)}
                                          >
                                            <div class=${['w-0.5 shrink-0 rounded-full', this.metaFor(e.type).bar].join(' ')}></div>
                                            <div class="min-w-0 flex-1 space-y-1">
                                              <div class="flex items-start justify-between gap-2">
                                                <p class="text-sm leading-tight font-medium">${e.title}</p>
                                                <span
                                                  class=${[
                                                    'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset',
                                                    this.metaFor(e.type).chip,
                                                  ].join(' ')}
                                                >
                                                  ${this.metaFor(e.type).label}
                                                </span>
                                              </div>
                                              <div class="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
                                                <span class="inline-flex items-center gap-1">
                                                  ${icon(Clock, 'clock', 'size-3')} ${e.start}-${e.end}
                                                </span>
                                                ${e.location
                                                  ? html`<span class="inline-flex items-center gap-1">
                                                      ${icon(MapPin, 'map-pin', 'size-3')} ${e.location}
                                                    </span>`
                                                  : nothing}
                                              </div>
                                              ${e.attendees?.length
                                                ? html`<div class="flex items-center -space-x-1.5 pt-0.5">
                                                    ${e.attendees.slice(0, 4).map(
                                                      (a) => html`
                                                        <uip-avatar class="border-background size-5 border-2">
                                                          <uip-avatar-fallback class="bg-primary/10 text-primary text-xs">
                                                            ${this.initials(a)}
                                                          </uip-avatar-fallback>
                                                        </uip-avatar>
                                                      `,
                                                    )}
                                                    ${e.attendees.length > 4
                                                      ? html`<span class="text-muted-foreground pl-2 text-xs">
                                                          +${e.attendees.length - 4}
                                                        </span>`
                                                      : nothing}
                                                  </div>`
                                                : nothing}
                                            </div>
                                          </div>
                                        `,
                                      )}
                              </div>
                            </div>
                          `
                        : html`
                            <div class="bg-card/40 overflow-hidden rounded-xl border">
                              <div class="from-primary/10 border-b bg-gradient-to-r to-transparent px-4 py-3">
                                <div class="flex items-center justify-between gap-3">
                                  <div class="min-w-0">
                                    <p class="text-muted-foreground text-xs tracking-wider uppercase">
                                      Range, ${this.rangeDayCount} days
                                    </p>
                                    <p class="mt-0.5 truncate text-sm font-semibold">
                                      ${this.fmtDayShort(this.rangeBounds.lo)} -&gt; ${this.fmtDayShort(this.rangeBounds.hi)}
                                    </p>
                                  </div>
                                  <uip-button
                                    variant="ghost"
                                    size="icon"
                                    class="size-7"
                                    aria-label="Clear date range"
                                    @click=${this.clearRange}
                                  >
                                    ${icon(X, 'x', 'size-3.5')}
                                  </uip-button>
                                </div>
                                <div class="mt-3 grid gap-2 grid-cols-4">
                                  ${this.typeKeys.map(
                                    (key) => html`
                                      <div class="bg-background/40 rounded-md border px-2 py-1.5">
                                        <div class="text-muted-foreground flex items-center gap-1 text-xs tracking-wider uppercase">
                                          <span class=${['size-1.5 rounded-full', this.eventTypes[key]!.dot].join(' ')}></span>
                                          ${this.eventTypes[key]!.label}
                                        </div>
                                        <p class="mt-0.5 text-sm font-semibold tabular-nums">${rangeTypeCounts[key] ?? 0}</p>
                                      </div>
                                    `,
                                  )}
                                </div>
                              </div>
                              <div class="max-h-[420px] overflow-y-auto">
                                ${rangeEvents.length === 0
                                  ? html`<p class="text-muted-foreground px-4 py-8 text-center text-xs">
                                      No events in range.
                                    </p>`
                                  : rangeEvents.map(
                                      (e) => html`
                                        <div
                                          class="hover:bg-accent/40 flex cursor-pointer items-start gap-3 border-b px-3 py-2.5 transition-colors last:border-b-0"
                                          @click=${() => this.openEvent(e)}
                                        >
                                          <div
                                            class="bg-background/60 flex w-10 shrink-0 flex-col items-center rounded-md border px-1 py-1 text-center"
                                          >
                                            <span class="text-muted-foreground text-xs uppercase">
                                              ${new Date(`${e.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' })}
                                            </span>
                                            <span class="text-sm leading-none font-semibold tabular-nums">
                                              ${new Date(`${e.date}T00:00:00`).getDate()}
                                            </span>
                                          </div>
                                          <div
                                            class=${['w-0.5 shrink-0 self-stretch rounded-full', this.metaFor(e.type).bar].join(' ')}
                                          ></div>
                                          <div class="min-w-0 flex-1">
                                            <p class="truncate text-xs font-medium">${e.title}</p>
                                            <p class="text-muted-foreground mt-0.5 text-xs tabular-nums">
                                              ${e.start}-${e.end}${e.location ? html`<span> ${e.location}</span>` : nothing}
                                            </p>
                                          </div>
                                          <span
                                            class=${[
                                              'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset',
                                              this.metaFor(e.type).chip,
                                            ].join(' ')}
                                          >
                                            ${this.metaFor(e.type).label}
                                          </span>
                                        </div>
                                      `,
                                    )}
                              </div>
                            </div>
                          `}
                      ${this.showUpcoming
                        ? html`
                            <div class="bg-card/40 overflow-hidden rounded-xl border">
                              <div class="bg-muted/30 border-b px-4 py-2.5">
                                <p class="text-sm font-semibold">Up next</p>
                                <p class="text-muted-foreground text-xs">After today</p>
                              </div>
                              <div class="divide-y">
                                ${this.loading
                                  ? Array.from({ length: 3 }).map(
                                      () => html`
                                        <div class="flex items-center gap-3 p-3">
                                          <uip-skeleton class="size-9 rounded-md"></uip-skeleton>
                                          <div class="flex-1 space-y-1">
                                            <uip-skeleton class="h-3 w-32"></uip-skeleton>
                                            <uip-skeleton class="h-2 w-20"></uip-skeleton>
                                          </div>
                                        </div>
                                      `,
                                    )
                                  : upcoming.length === 0
                                    ? html`<p class="text-muted-foreground px-4 py-6 text-center text-xs">
                                        Nothing on the horizon.
                                      </p>`
                                    : upcoming.map(
                                        (e) => html`
                                          <button
                                            type="button"
                                            class="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors"
                                            @click=${() => this.openEvent(e)}
                                          >
                                            <div
                                              class="bg-background flex size-10 shrink-0 flex-col items-center justify-center rounded-md border text-center"
                                            >
                                              <span class="text-muted-foreground text-xs uppercase">
                                                ${new Date(`${e.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short' })}
                                              </span>
                                              <span class="text-sm leading-none font-semibold tabular-nums">
                                                ${new Date(`${e.date}T00:00:00`).getDate()}
                                              </span>
                                            </div>
                                            <div class="min-w-0 flex-1">
                                              <div class="flex items-center gap-1.5">
                                                <span class=${['size-1.5 rounded-full', this.metaFor(e.type).dot].join(' ')}></span>
                                                <p class="truncate text-xs font-medium">${e.title}</p>
                                              </div>
                                              <p class="text-muted-foreground mt-0.5 text-xs tabular-nums">
                                                ${e.start}-${e.end}${e.location ? html`<span> ${e.location}</span>` : nothing}
                                              </p>
                                            </div>
                                          </button>
                                        `,
                                      )}
                              </div>
                            </div>
                          `
                        : nothing}
                    </aside>
                  `
                : nothing}
            </div>
    
            <uip-dialog
              ?open=${this.dialogOpen}
              heading=${this.selectedEvent?.title ?? ''}
              description=${this.selectedEvent ? `${this.metaFor(this.selectedEvent.type).label}${this.selectedEvent.status ? ` ${this.selectedEvent.status}` : ''}` : ''}
              @open-change=${(e: CustomEvent<{ open: boolean }>) => {
                this.dialogOpen = e.detail.open
              }}
            >
              ${this.selectedEvent
                ? html`
                    <div class="space-y-3 py-2">
                      ${this.selectedEvent.description
                        ? html`<p class="text-muted-foreground text-sm">${this.selectedEvent.description}</p>`
                        : nothing}
                      <uip-separator></uip-separator>
                      <div class="grid gap-2 text-sm">
                        <div class="flex items-center gap-2">
                          ${icon(Clock, 'clock', 'text-muted-foreground size-4')}
                          <span> ${this.selectedEvent.date} ${this.selectedEvent.start} - ${this.selectedEvent.end} </span>
                        </div>
                        ${this.selectedEvent.location
                          ? html`<div class="flex items-center gap-2">
                              ${icon(MapPin, 'map-pin', 'text-muted-foreground size-4')}
                              <span>${this.selectedEvent.location}</span>
                            </div>`
                          : nothing}
                        ${this.selectedEvent.attendees?.length
                          ? html`<div class="flex items-start gap-2">
                              ${icon(Users, 'users', 'text-muted-foreground mt-0.5 size-4')}
                              <div class="flex flex-wrap items-center gap-1.5">
                                ${this.selectedEvent.attendees.map(
                                  (a) => html`
                                    <div class="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs">
                                      <uip-avatar class="size-4">
                                        <uip-avatar-fallback class="bg-primary/10 text-primary text-xs">
                                          ${this.initials(a)}
                                        </uip-avatar-fallback>
                                      </uip-avatar>
                                      <span>${a}</span>
                                    </div>
                                  `,
                                )}
                              </div>
                            </div>`
                          : nothing}
                      </div>
                    </div>
                    <div slot="footer">
                      <uip-button
                        variant="outline"
                        size="sm"
                        @click=${() => {
                          this.dispatchEvent(
                            new CustomEvent('event-edit', { detail: this.selectedEvent, bubbles: true, composed: true }),
                          )
                          this.dialogOpen = false
                        }}
                      >
                        ${icon(Pencil, 'pencil', 'size-3.5')} Edit
                      </uip-button>
                      <uip-button
                        size="sm"
                        variant="destructive"
                        @click=${() => {
                          this.dispatchEvent(
                            new CustomEvent('event-delete', { detail: this.selectedEvent, bubbles: true, composed: true }),
                          )
                          this.dialogOpen = false
                        }}
                      >
                        ${icon(Trash2, 'trash-2', 'size-3.5')} Cancel event
                      </uip-button>
                    </div>
                  `
                : nothing}
            </uip-dialog>
          </div>
        `
      }
    }
    
    customElements.get('uip-event-calendar-dashboard') ||
      customElements.define('uip-event-calendar-dashboard', UipEventCalendarDashboard)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-event-calendar-dashboard': UipEventCalendarDashboard
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/event-calendar-dashboard.json