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] 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.

Installation

$npx uipkge-ng@latest add event-calendar-dashboard
Manifest:https://uipkge.dev/r/angular/event-calendar-dashboard.jsonInstalls to:src/app/components/blocks/event-calendar-dashboard/

Variants

Loading interactive previews…

Schema

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

GridDay
interface GridDay {
  date: Date
  key: DateKey
  inMonth: boolean
}
CalendarEvent
interface CalendarEvent {
  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[]
  /** Free-form status tag rendered next to the type label in the dialog. */
  status?: string
}
EventTypeMeta
interface EventTypeMeta {
  label: string
  /** Lucide icon for the stat tile + chip + dialog header. */
  icon: LucideIconData
  /** Tailwind classes for the colored chip on event rows + dialog header.
   *  Shape: `bg-X/10 text-X ring-X/20` is what the default theme uses. */
  chip: string
  /** Tailwind class for the vertical bar to the left of side-rail rows. */
  bar: string
  /** Tailwind class for the legend / stat-tile dot. */
  dot: string
  /** Tailwind class for the soft watermark glow behind stat tiles. */
  glow: string
  /** Tailwind class for the small text accent on "Next: ..." date labels. */
  text: string
  /** Tailwind class for the stat-tile hover ring. */
  ring: string
}

Files installed (4)

  • src/app/components/blocks/event-calendar-dashboard/event-calendar-dashboard.component.ts50.9 kB
    // EventCalendar -- a full month-view event calendar with stats strip, drag/shift range
    // select, side rail (selected day or range summary), upcoming list, and per-event/per-cell
    // context menus. Port of the Vue/React block 1:1 -- same month-grid + range-select behavior
    // (inlined here; Angular has no useMonthGrid composable), same stats math, same copy.
    //
    // Configurable surface:
    //   - [events] / (eventsChange)  CalendarEvent[]   the source of truth
    //   - [eventTypes]               Record<typeKey, EventTypeMeta>  visual config per type
    //   - [initialDate]              YYYY-MM-DD        which month opens first
    //   - [weekStartsOn]             0 | 1              Sunday vs Monday
    //   - [loading]                                   skeleton state during fetch
    //   - [title] / [description] / [show*] toggles per pane
    //
    // Emits: eventClick(event), eventCreate(dateKey), eventEdit / eventDuplicate /
    // eventDelete(event), rangeChange({ lo, hi }).
    import {
      ChangeDetectionStrategy,
      Component,
      ContentChild,
      DestroyRef,
      EventEmitter,
      Input,
      OnInit,
      Output,
      TemplateRef,
      booleanAttribute,
      computed,
      effect,
      inject,
      signal,
    } from '@angular/core'
    import { NgTemplateOutlet } from '@angular/common'
    import {
      ArrowRight,
      CalendarDays,
      CalendarPlus,
      Clock,
      Copy,
      CopyPlus,
      Eye,
      ListFilter,
      LucideAngularModule,
      MapPin,
      MousePointer2,
      Pencil,
      Plus,
      Search,
      Trash2,
      Users,
      X,
      ChevronLeft,
      ChevronRight,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiContextMenuComponent,
      UiContextMenuContentComponent,
      UiContextMenuItemComponent,
      UiContextMenuLabelComponent,
      UiContextMenuSeparatorComponent,
      UiContextMenuShortcutComponent,
      UiContextMenuTriggerComponent,
    } from '@/ui/context-menu/context-menu.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSkeletonComponent } from '@/ui/skeleton/skeleton.component'
    import type { CalendarEvent, EventTypeMeta } from './types'
    import { defaultEventTypes } from './defaults'
    
    export type { CalendarEvent, EventTypeMeta }
    
    export type DateKey = string // YYYY-MM-DD
    
    export function isoDate(d: Date): DateKey {
      const y = d.getFullYear()
      const m = String(d.getMonth() + 1).padStart(2, '0')
      const day = String(d.getDate()).padStart(2, '0')
      return `${y}-${m}-${day}`
    }
    
    export function dateFromKey(k: DateKey): Date {
      return new Date(k + 'T00:00:00')
    }
    
    export function dayDiff(a: DateKey, b: DateKey): number {
      return Math.round((dateFromKey(b).getTime() - dateFromKey(a).getTime()) / 86400000)
    }
    
    interface GridDay {
      date: Date
      key: DateKey
      inMonth: boolean
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-event-calendar-dashboard, [ui-event-calendar-dashboard]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        NgTemplateOutlet,
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiButtonComponent,
        UiContextMenuComponent,
        UiContextMenuContentComponent,
        UiContextMenuItemComponent,
        UiContextMenuLabelComponent,
        UiContextMenuSeparatorComponent,
        UiContextMenuShortcutComponent,
        UiContextMenuTriggerComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiSkeletonComponent,
      ],
      template: `
        <div data-slot="event-calendar" class="flex flex-col gap-5" (mouseup)="endDrag()">
          <!-- Header -->
          <header class="flex flex-wrap items-end justify-between gap-4">
            <div>
              <h1 class="text-2xl font-semibold tracking-tight">{{ title }}</h1>
              @if (description) {
                <p class="text-muted-foreground mt-1 text-sm">{{ description }}</p>
              }
            </div>
            <div class="flex items-center gap-2">
              @if (showSearch) {
                <div class="relative">
                  <lucide-icon
                    [img]="Search"
                    class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                  />
                  <ui-input
                    [value]="search()"
                    (valueChange)="search.set($event)"
                    placeholder="Search events"
                    class="h-8 w-56 pl-8 text-xs"
                  />
                </div>
              }
              @if (showViewSelect) {
                <ui-select [value]="view()" (valueChange)="setView($event)">
                  <button ui-select-trigger class="h-8 w-24 text-xs">
                    <ui-select-value />
                  </button>
                  <ui-select-content>
                    <ui-select-item value="month">Month</ui-select-item>
                    <ui-select-item value="week">Week</ui-select-item>
                    <ui-select-item value="day">Day</ui-select-item>
                  </ui-select-content>
                </ui-select>
              }
              @if (headerActionsTpl) {
                <ng-container [ngTemplateOutlet]="headerActionsTpl" />
              } @else {
                <button ui-button size="sm" class="gap-1.5" (click)="eventCreate.emit(rangeStart())">
                  <lucide-icon [img]="Plus" class="size-3.5" />
                  New event
                </button>
              }
            </div>
          </header>
    
          <!-- Stats strip -->
          @if (showStats) {
            <div class="grid grid-cols-1 gap-3" [class]="statsGridClass()">
              @for (key of typeKeys(); track key) {
                <button type="button" [class]="statTileClass(key)" (click)="selectCategoryFilter(key)">
                  <lucide-icon
                    [img]="meta(key).icon"
                    [class]="
                      cn(
                        'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]',
                        meta(key).text
                      )
                    "
                  />
    
                  <span class="flex items-center justify-between gap-2">
                    <span class="flex items-center gap-1.5">
                      <span [class]="cn('size-1.5 rounded-full', meta(key).dot)"></span>
                      <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                        {{ meta(key).label }}
                      </span>
                    </span>
                    <span
                      [class]="cn('flex size-7 items-center justify-center rounded-md ring-1 ring-inset', meta(key).chip)"
                    >
                      <lucide-icon [img]="meta(key).icon" class="size-3.5" />
                    </span>
                  </span>
    
                  <span class="mt-2 flex items-baseline gap-1.5">
                    <span class="text-3xl leading-none font-semibold tabular-nums">{{ monthCounts()[key] ?? 0 }}</span>
                    <span class="text-muted-foreground text-xs tracking-wider uppercase">this month</span>
                  </span>
    
                  <span class="mt-3 flex h-6 items-end gap-1">
                    @for (n of typeStats()[key]!.weeks.slice(0, 6); track $index) {
                      <span
                        [class]="weekBarClass(key, n)"
                        [style.height]="n > 0 ? Math.min(100, 30 + n * 35) + '%' : '20%'"
                        [title]="
                          'Week ' + ($index + 1) + ': ' + n + ' ' + meta(key).label.toLowerCase() + (n === 1 ? '' : 's')
                        "
                      ></span>
                    }
                  </span>
    
                  <span class="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2">
                    @if (typeStats()[key]!.next; as next) {
                      <span class="text-muted-foreground block text-xs tracking-wider uppercase">
                        Next <span [class]="meta(key).text">{{ fmtNextDate(next.date) }}</span>
                      </span>
                      <span class="mt-0.5 block truncate text-xs font-medium">{{ next.title }}</span>
                    } @else {
                      <span class="text-muted-foreground block text-xs tracking-wider uppercase">No upcoming</span>
                      <span class="text-muted-foreground/70 mt-0.5 block text-xs">All clear</span>
                    }
                  </span>
                </button>
              }
            </div>
          }
    
          <!-- Main: calendar + side rail -->
          <div [class]="cn('grid gap-4', showSideRail && 'lg:grid-cols-[minmax(0,1fr)_320px]')">
            <!-- Month grid -->
            <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">
                  <button
                    ui-button
                    aria-label="Previous month"
                    variant="outline"
                    size="icon"
                    class="size-7"
                    (click)="prevMonth()"
                  >
                    <lucide-icon [img]="ChevronLeft" class="size-4" />
                  </button>
                  <button
                    ui-button
                    aria-label="Next month"
                    variant="outline"
                    size="icon"
                    class="size-7"
                    (click)="nextMonth()"
                  >
                    <lucide-icon [img]="ChevronRight" class="size-4" />
                  </button>
                  <button ui-button variant="ghost" size="sm" class="h-7 text-xs" (click)="goToToday()">Today</button>
                  <h2 class="ml-2 text-sm font-semibold">{{ monthLabel() }}</h2>
                </div>
                <div class="text-muted-foreground flex items-center gap-3 text-xs">
                  @if (isRange()) {
                    <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"
                    >
                      <lucide-icon [img]="MousePointer2" class="size-3" />
                      <span>{{ rangeDayCount() }} days, {{ rangeEvents().length }} events</span>
                      <button class="hover:text-foreground ml-0.5" aria-label="Clear date range" (click)="clearRange()">
                        <lucide-icon [img]="X" class="size-3" />
                      </button>
                    </div>
                  }
                  <div class="flex items-center gap-1.5">
                    <lucide-icon [img]="CalendarDays" class="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">
                @for (w of weekdays(); track w) {
                  <div class="px-2 py-2 font-medium">{{ w }}</div>
                }
              </div>
    
              <div class="grid grid-cols-7 select-none">
                @if (loading) {
                  @for (i of skeletonCells; track i) {
                    <div class="h-24 border-r border-b p-1.5 last:border-r-0">
                      <ui-skeleton class="h-3 w-6" />
                      <ui-skeleton class="mt-2 h-3 w-full" />
                    </div>
                  }
                } @else {
                  @for (d of gridDays(); track d.key; let i = $index) {
                    <ui-context-menu>
                      <button
                        ui-context-menu-trigger
                        type="button"
                        [class]="cellClass(d, i)"
                        (mousedown)="onCellMouseDown(d.key, $event)"
                        (mouseenter)="onCellMouseEnter(d.key)"
                      >
                        <span class="flex items-center justify-between">
                          <span [class]="dayNumberClass(d)">{{ d.date.getDate() }}</span>
                          @if ((eventsByDate().get(d.key)?.length ?? 0) > 0) {
                            <span class="text-muted-foreground text-xs tabular-nums">
                              {{ eventsByDate().get(d.key)!.length }}
                            </span>
                          }
                        </span>
                        <span class="flex flex-col gap-0.5">
                          @for (e of (eventsByDate().get(d.key) ?? []).slice(0, 2); track e.id) {
                            <ui-context-menu>
                              <span
                                ui-context-menu-trigger
                                [class]="
                                  cn(
                                    'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-xs ring-1 ring-inset',
                                    meta(e.type).chip
                                  )
                                "
                                (mousedown)="$event.stopPropagation()"
                                (click)="openEvent(e); $event.stopPropagation()"
                                (contextmenu)="$event.stopPropagation()"
                              >
                                <span class="tabular-nums opacity-70">{{ e.start }}</span>
                                <span class="truncate">{{ e.title }}</span>
                              </span>
                              <ui-context-menu-content class="w-52">
                                <ui-context-menu-label class="text-muted-foreground flex items-center gap-1.5 text-xs">
                                  <span [class]="cn('size-1.5 rounded-full', meta(e.type).dot)"></span>
                                  <span class="truncate">{{ e.title }}</span>
                                </ui-context-menu-label>
                                <ui-context-menu-separator />
                                <ui-context-menu-item class="gap-2" (select)="openEvent(e)">
                                  <lucide-icon [img]="Eye" class="size-3.5" /> View details
                                  <ui-context-menu-shortcut>Enter</ui-context-menu-shortcut>
                                </ui-context-menu-item>
                                <ui-context-menu-item class="gap-2" (select)="eventEdit.emit(e)">
                                  <lucide-icon [img]="Pencil" class="size-3.5" /> Edit
                                </ui-context-menu-item>
                                <ui-context-menu-item class="gap-2" (select)="eventDuplicate.emit(e)">
                                  <lucide-icon [img]="CopyPlus" class="size-3.5" /> Duplicate
                                </ui-context-menu-item>
                                <ui-context-menu-item class="gap-2" (select)="copyDate(e.date)">
                                  <lucide-icon [img]="Copy" class="size-3.5" /> Copy date
                                </ui-context-menu-item>
                                <ui-context-menu-separator />
                                <ui-context-menu-item
                                  class="text-destructive focus:text-destructive gap-2"
                                  (select)="eventDelete.emit(e)"
                                >
                                  <lucide-icon [img]="Trash2" class="size-3.5" /> Cancel event
                                </ui-context-menu-item>
                              </ui-context-menu-content>
                            </ui-context-menu>
                          }
                          @if ((eventsByDate().get(d.key)?.length ?? 0) > 2) {
                            <span class="text-muted-foreground px-1 text-xs">
                              +{{ eventsByDate().get(d.key)!.length - 2 }} more
                            </span>
                          }
                        </span>
                      </button>
                      <ui-context-menu-content class="w-56">
                        <ui-context-menu-label class="text-muted-foreground text-xs">{{
                          fmtDayLong(d.key)
                        }}</ui-context-menu-label>
                        <ui-context-menu-separator />
                        <ui-context-menu-item class="gap-2" (select)="eventCreate.emit(d.key)">
                          <lucide-icon [img]="CalendarPlus" class="size-3.5" /> New event
                          <ui-context-menu-shortcut>N</ui-context-menu-shortcut>
                        </ui-context-menu-item>
                        <ui-context-menu-item class="gap-2" (select)="selectWeekOf(d.key)">
                          <lucide-icon [img]="CalendarDays" class="size-3.5" /> Select this week
                        </ui-context-menu-item>
                        <ui-context-menu-separator />
                        <ui-context-menu-item class="gap-2" (select)="copyDate(d.key)">
                          <lucide-icon [img]="Copy" class="size-3.5" /> Copy date
                          <ui-context-menu-shortcut class="tabular-nums">{{ d.key }}</ui-context-menu-shortcut>
                        </ui-context-menu-item>
                        <ui-context-menu-item class="gap-2" (select)="goToToday()">
                          <lucide-icon [img]="ArrowRight" class="size-3.5" /> Go to today
                        </ui-context-menu-item>
                        @if (isRange()) {
                          <ui-context-menu-item
                            class="text-destructive focus:text-destructive gap-2"
                            (select)="clearRange()"
                          >
                            <lucide-icon [img]="X" class="size-3.5" /> Clear range
                          </ui-context-menu-item>
                        }
                      </ui-context-menu-content>
                    </ui-context-menu>
                  }
                }
              </div>
    
              @if (showLegend) {
                <div class="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-xs">
                  <lucide-icon [img]="ListFilter" class="size-3" />
                  @for (key of typeKeys(); track key) {
                    <div class="flex items-center gap-1.5">
                      <span [class]="cn('size-2 rounded-full', meta(key).dot)"></span>
                      {{ meta(key).label }}
                    </div>
                  }
                  <span class="ml-auto text-xs">Tip: drag or shift-click to range. Right-click for actions.</span>
                </div>
              }
            </div>
    
            <!-- Side rail -->
            @if (showSideRail) {
              <aside class="flex flex-col gap-4">
                @if (!isRange()) {
                  <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">
                            {{ rangeStart() === todayKey() ? 'Today' : 'Selected' }}
                          </p>
                          <p class="mt-0.5 text-sm font-semibold">{{ fmtDayLong(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-[26.25rem] overflow-y-auto p-3">
                      @if (loading) {
                        @for (i of [0, 1, 2]; track i) {
                          <div class="mb-2 space-y-2 rounded-lg border p-3">
                            <ui-skeleton class="h-3 w-32" />
                            <ui-skeleton class="h-2 w-20" />
                          </div>
                        }
                      } @else if (selectedDayEvents().length === 0) {
                        @if (emptyDayTpl) {
                          <ng-container [ngTemplateOutlet]="emptyDayTpl" />
                        } @else {
                          <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">
                              <lucide-icon [img]="CalendarDays" class="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>
                            <button
                              ui-button
                              size="sm"
                              variant="outline"
                              class="mt-1 h-7 gap-1.5 text-xs"
                              (click)="eventCreate.emit(rangeStart())"
                            >
                              <lucide-icon [img]="Plus" class="size-3" /> New event
                            </button>
                          </div>
                        }
                      } @else {
                        @for (e of selectedDayEvents(); track e.id) {
                          <ui-context-menu>
                            <div
                              ui-context-menu-trigger
                              class="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors"
                              (click)="openEvent(e)"
                            >
                              <div [class]="cn('w-0.5 shrink-0 rounded-full', meta(e.type).bar)"></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]="
                                      cn(
                                        'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset',
                                        meta(e.type).chip
                                      )
                                    "
                                  >
                                    {{ meta(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">
                                    <lucide-icon [img]="Clock" class="size-3" />{{ e.start }}-{{ e.end }}
                                  </span>
                                  @if (e.location) {
                                    <span class="inline-flex items-center gap-1">
                                      <lucide-icon [img]="MapPin" class="size-3" />{{ e.location }}
                                    </span>
                                  }
                                </div>
                                @if (e.attendees?.length) {
                                  <div class="flex items-center -space-x-1.5 pt-0.5">
                                    @for (a of e.attendees!.slice(0, 4); track a) {
                                      <ui-avatar class="border-background size-5 border-2">
                                        <ui-avatar-fallback class="bg-primary/10 text-primary text-xs">
                                          {{ initials(a) }}
                                        </ui-avatar-fallback>
                                      </ui-avatar>
                                    }
                                    @if (e.attendees!.length > 4) {
                                      <span class="text-muted-foreground pl-2 text-xs">+{{ e.attendees!.length - 4 }}</span>
                                    }
                                  </div>
                                }
                              </div>
                            </div>
                            <ui-context-menu-content class="w-48">
                              <ui-context-menu-label class="text-muted-foreground truncate text-xs">
                                {{ e.title }}
                              </ui-context-menu-label>
                              <ui-context-menu-separator />
                              <ui-context-menu-item class="gap-2" (select)="openEvent(e)">
                                <lucide-icon [img]="Eye" class="size-3.5" /> View details
                              </ui-context-menu-item>
                              <ui-context-menu-item class="gap-2" (select)="eventEdit.emit(e)">
                                <lucide-icon [img]="Pencil" class="size-3.5" /> Edit
                              </ui-context-menu-item>
                              <ui-context-menu-item class="gap-2" (select)="eventDuplicate.emit(e)">
                                <lucide-icon [img]="CopyPlus" class="size-3.5" /> Duplicate
                              </ui-context-menu-item>
                              <ui-context-menu-separator />
                              <ui-context-menu-item
                                class="text-destructive focus:text-destructive gap-2"
                                (select)="eventDelete.emit(e)"
                              >
                                <lucide-icon [img]="Trash2" class="size-3.5" /> Cancel event
                              </ui-context-menu-item>
                            </ui-context-menu-content>
                          </ui-context-menu>
                        }
                      }
                    </div>
                  </div>
                } @else {
                  <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, {{ rangeDayCount() }} days
                          </p>
                          <p class="mt-0.5 truncate text-sm font-semibold">
                            {{ fmtDayShort(rangeBounds().lo) }} -&gt; {{ fmtDayShort(rangeBounds().hi) }}
                          </p>
                        </div>
                        <button
                          ui-button
                          variant="ghost"
                          size="icon"
                          class="size-7"
                          aria-label="Clear date range"
                          (click)="clearRange()"
                        >
                          <lucide-icon [img]="X" class="size-3.5" />
                        </button>
                      </div>
                      <div class="mt-3 grid gap-2" [class]="rangeCountsClass()">
                        @for (key of typeKeys(); track key) {
                          <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]="cn('size-1.5 rounded-full', meta(key).dot)"></span>
                              {{ meta(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-[26.25rem] overflow-y-auto">
                      @if (rangeEvents().length === 0) {
                        <p class="text-muted-foreground px-4 py-8 text-center text-xs">No events in range.</p>
                      } @else {
                        @for (e of rangeEvents(); track e.id) {
                          <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)="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">{{ monthShort(e.date) }}</span>
                              <span class="text-sm leading-none font-semibold tabular-nums">{{ dayNum(e.date) }}</span>
                            </div>
                            <div [class]="cn('w-0.5 shrink-0 self-stretch rounded-full', meta(e.type).bar)"></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 }}
                                @if (e.location) {
                                  {{ ' ' }}{{ e.location }}
                                }
                              </p>
                            </div>
                            <span
                              [class]="
                                cn(
                                  'shrink-0 rounded px-1.5 py-0.5 text-xs tracking-wider uppercase ring-1 ring-inset',
                                  meta(e.type).chip
                                )
                              "
                            >
                              {{ meta(e.type).label }}
                            </span>
                          </div>
                        }
                      }
                    </div>
                  </div>
                }
    
                @if (showUpcoming) {
                  <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">
                      @if (loading) {
                        @for (i of [0, 1, 2]; track i) {
                          <div class="flex items-center gap-3 p-3">
                            <ui-skeleton class="size-9 rounded-md" />
                            <div class="flex-1 space-y-1">
                              <ui-skeleton class="h-3 w-32" />
                              <ui-skeleton class="h-2 w-20" />
                            </div>
                          </div>
                        }
                      } @else if (upcoming().length === 0) {
                        <p class="text-muted-foreground px-4 py-6 text-center text-xs">Nothing on the horizon.</p>
                      } @else {
                        @for (e of upcoming(); track e.id) {
                          <button
                            type="button"
                            class="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors"
                            (click)="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">{{ monthShort(e.date) }}</span>
                              <span class="text-sm leading-none font-semibold tabular-nums">{{ dayNum(e.date) }}</span>
                            </div>
                            <div class="min-w-0 flex-1">
                              <div class="flex items-center gap-1.5">
                                <span [class]="cn('size-1.5 rounded-full', meta(e.type).dot)"></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 }}
                                @if (e.location) {
                                  {{ ' ' }}{{ e.location }}
                                }
                              </p>
                            </div>
                          </button>
                        }
                      }
                    </div>
                  </div>
                }
              </aside>
            }
          </div>
    
          <!-- Event Detail Dialog -->
          <ui-dialog [open]="eventOpen()" (openChange)="eventOpen.set($event)">
            @if (selectedEvent(); as ev) {
              <ui-dialog-content class="sm:max-w-md">
                @if (eventDialogTpl) {
                  <ng-container [ngTemplateOutlet]="eventDialogTpl" [ngTemplateOutletContext]="{ $implicit: ev }" />
                } @else {
                <div ui-dialog-header>
                  <div class="flex items-center gap-2">
                    <div
                      [class]="
                        cn('flex size-8 items-center justify-center rounded-md ring-1 ring-inset', meta(ev.type).chip)
                      "
                    >
                      <lucide-icon [img]="meta(ev.type).icon" class="size-4" />
                    </div>
                    <div>
                      <h2 ui-dialog-title class="text-base">{{ ev.title }}</h2>
                      <p ui-dialog-description class="text-xs">
                        {{ meta(ev.type).label }}
                        @if (ev.status) {
                          {{ ' ' }}{{ ev.status }}
                        }
                      </p>
                    </div>
                  </div>
                </div>
                <div class="space-y-3 py-2">
                  @if (ev.description) {
                    <p class="text-muted-foreground text-sm">{{ ev.description }}</p>
                  }
                  <ui-separator />
                  <div class="grid gap-2 text-sm">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Clock" class="text-muted-foreground size-4" />
                      <span>{{ ev.date }} {{ ' ' }} {{ ev.start }} - {{ ev.end }}</span>
                    </div>
                    @if (ev.location) {
                      <div class="flex items-center gap-2">
                        <lucide-icon [img]="MapPin" class="text-muted-foreground size-4" />
                        <span>{{ ev.location }}</span>
                      </div>
                    }
                    @if (ev.attendees?.length) {
                      <div class="flex items-start gap-2">
                        <lucide-icon [img]="Users" class="text-muted-foreground mt-0.5 size-4" />
                        <div class="flex flex-wrap items-center gap-1.5">
                          @for (a of ev.attendees; track a) {
                            <div class="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs">
                              <ui-avatar class="size-4">
                                <ui-avatar-fallback class="bg-primary/10 text-primary text-xs">
                                  {{ initials(a) }}
                                </ui-avatar-fallback>
                              </ui-avatar>
                              <span>{{ a }}</span>
                            </div>
                          }
                        </div>
                      </div>
                    }
                  </div>
                </div>
                <div ui-dialog-footer>
                  <button ui-button variant="outline" size="sm" (click)="handleEditSelected()">Edit</button>
                  <button ui-button size="sm" variant="destructive" (click)="handleDeleteSelected()">Delete</button>
                </div>
                }
              </ui-dialog-content>
            }
          </ui-dialog>
        </div>
      `,
    })
    export class UiEventCalendarDashboardComponent implements OnInit {
      protected readonly ArrowRight = ArrowRight
      protected readonly CalendarDays = CalendarDays
      protected readonly CalendarPlus = CalendarPlus
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly CopyPlus = CopyPlus
      protected readonly Eye = Eye
      protected readonly ListFilter = ListFilter
      protected readonly MapPin = MapPin
      protected readonly MousePointer2 = MousePointer2
      protected readonly Pencil = Pencil
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly Trash2 = Trash2
      protected readonly Users = Users
      protected readonly X = X
      protected readonly cn = cn
      protected readonly Math = Math
      protected readonly skeletonCells = Array.from({ length: 42 }, (_, i) => i)
    
      /** Source of truth for the rendered events. */
      @Input() set events(v: CalendarEvent[] | undefined) {
        this.eventsState.set([...(v ?? [])])
      }
      /** Visual + semantic theme per event type. Falls back to defaultEventTypes. */
      @Input() set eventTypes(v: Record<string, EventTypeMeta> | undefined) {
        this.eventTypesState.set(v ?? defaultEventTypes)
      }
      /** YYYY-MM-DD -- month to open first. Defaults to today. */
      @Input() initialDate?: string
      /** 0 = Sunday (default), 1 = Monday. */
      @Input() weekStartsOn: 0 | 1 = 0
      /** Show the skeleton placeholders instead of cells / rail rows. */
      @Input({ transform: booleanAttribute }) loading = false
      @Input() title = 'Calendar'
      @Input() description = 'Schedule, meetings, and deadlines. Drag or shift-click to select a range.'
      /** Default = true. Stats strip across the top. */
      @Input({ transform: booleanAttribute }) showStats = true
      /** Default = true. Side rail to the right of the grid. */
      @Input({ transform: booleanAttribute }) showSideRail = true
      /** Default = true. Search input in the header. */
      @Input({ transform: booleanAttribute }) showSearch = true
      /** Default = true. Month/Week/Day select. Week/Day not implemented yet -- visual only. */
      @Input({ transform: booleanAttribute }) showViewSelect = true
      /** Default = true. Legend strip under the grid. */
      @Input({ transform: booleanAttribute }) showLegend = true
      /** Default = true. "Up next" pane under the side rail. */
      @Input({ transform: booleanAttribute }) showUpcoming = true
      @Input('class') className?: string
    
      @Output() eventsChange = new EventEmitter<CalendarEvent[]>()
      @Output() eventClick = new EventEmitter<CalendarEvent>()
      @Output() eventCreate = new EventEmitter<string>()
      @Output() eventEdit = new EventEmitter<CalendarEvent>()
      @Output() eventDuplicate = new EventEmitter<CalendarEvent>()
      @Output() eventDelete = new EventEmitter<CalendarEvent>()
      @Output() rangeChange = new EventEmitter<{ lo: string; hi: string }>()
    
      /** Override for the header "New event" button. Project `<ng-template #headerActions>…</ng-template>`. */
      @ContentChild('headerActions') headerActionsTpl?: TemplateRef<unknown>
      /** Full override of the detail dialog body. Context: `{ $implicit: CalendarEvent }`. */
      @ContentChild('eventDialog') eventDialogTpl?: TemplateRef<{ $implicit: CalendarEvent }>
      /** Override for the empty selected-day state. Project `<ng-template #emptyDay>…</ng-template>`. */
      @ContentChild('emptyDay') emptyDayTpl?: TemplateRef<unknown>
    
      readonly eventsState = signal<CalendarEvent[]>([])
      readonly eventTypesState = signal<Record<string, EventTypeMeta>>(defaultEventTypes)
    
      readonly view = signal<'month' | 'week' | 'day'>('month')
      readonly eventOpen = signal(false)
      readonly selectedEvent = signal<CalendarEvent | null>(null)
      readonly search = signal('')
    
      // Month-grid state (port of the useMonthGrid headless composable).
      readonly todayKey = signal<DateKey>(isoDate(new Date()))
      private todayDate = new Date()
      readonly cursor = signal<Date>(new Date(new Date().getFullYear(), new Date().getMonth(), 1))
      readonly rangeAnchor = signal<DateKey>(this.todayKey())
      readonly rangeStart = signal<DateKey>(this.todayKey())
      readonly rangeEnd = signal<DateKey>(this.todayKey())
      readonly isDragging = signal(false)
    
      constructor() {
        const destroyRef = inject(DestroyRef)
        // Mirror the Vue block: the drag also ends when the mouse is released outside the grid.
        if (typeof window !== 'undefined') {
          window.addEventListener('mouseup', this.endDragBound)
          window.addEventListener('mouseleave', this.endDragBound)
          destroyRef.onDestroy(() => {
            window.removeEventListener('mouseup', this.endDragBound)
            window.removeEventListener('mouseleave', this.endDragBound)
          })
        }
        // Vue watches rangeBounds and emits range-change (no initial fire).
        let firstRange = true
        effect(() => {
          const bounds = this.rangeBounds()
          if (firstRange) {
            firstRange = false
            return
          }
          this.rangeChange.emit(bounds)
        })
      }
    
      private readonly endDragBound = (): void => this.endDrag()
    
      ngOnInit(): void {
        // initialDate is initial-only, like the Vue composable: seed cursor + selection once.
        if (this.initialDate) {
          const init = dateFromKey(this.initialDate)
          this.todayDate = new Date(init.getFullYear(), init.getMonth(), init.getDate())
          const key = isoDate(this.todayDate)
          this.todayKey.set(key)
          this.cursor.set(new Date(init.getFullYear(), init.getMonth(), 1))
          this.rangeAnchor.set(key)
          this.rangeStart.set(key)
          this.rangeEnd.set(key)
        }
      }
    
      readonly monthLabel = computed(() => this.cursor().toLocaleDateString('en-US', { month: 'long', year: 'numeric' }))
    
      readonly gridDays = computed<GridDay[]>(() => {
        const cursor = this.cursor()
        const weekStartsOn = this.weekStartsOn
        const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1)
        const offset = (first.getDay() - weekStartsOn + 7) % 7
        const start = new Date(first)
        start.setDate(first.getDate() - offset)
        const days: GridDay[] = []
        for (let i = 0; i < 42; i++) {
          const d = new Date(start)
          d.setDate(start.getDate() + i)
          days.push({ date: d, key: isoDate(d), inMonth: d.getMonth() === cursor.getMonth() })
        }
        return days
      })
    
      readonly weekdays = computed(() => {
        const base = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
        return [...base.slice(this.weekStartsOn), ...base.slice(0, this.weekStartsOn)]
      })
    
      readonly rangeBounds = computed(() => {
        const a = this.rangeStart()
        const b = this.rangeEnd()
        return a <= b ? { lo: a, hi: b } : { lo: b, hi: a }
      })
    
      readonly rangeDayCount = computed(() => dayDiff(this.rangeBounds().lo, this.rangeBounds().hi) + 1)
      readonly isRange = computed(() => this.rangeBounds().lo !== this.rangeBounds().hi)
    
      /** Fall through to a neutral style for unknown event types. */
      private readonly neutralMeta: EventTypeMeta = {
        label: 'Event',
        icon: CalendarDays,
        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',
      }
    
      meta(type: string): EventTypeMeta {
        return this.eventTypesState()[type] ?? this.neutralMeta
      }
    
      readonly typeKeys = computed(() => Object.keys(this.eventTypesState()))
    
      readonly filtered = computed(() => {
        const q = this.search().trim().toLowerCase()
        const events = this.eventsState()
        if (!q) return events
        return events.filter(
          (e) =>
            e.title.toLowerCase().includes(q) ||
            (e.description ?? '').toLowerCase().includes(q) ||
            (e.location ?? '').toLowerCase().includes(q),
        )
      })
    
      readonly eventsByDate = computed(() => {
        const map = new Map<string, CalendarEvent[]>()
        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
      })
    
      readonly monthCounts = computed(() => {
        const cursor = this.cursor()
        const y = cursor.getFullYear()
        const m = cursor.getMonth()
        const out: Record<string, number> & { total: number } = { total: 0 }
        for (const k of this.typeKeys()) out[k] = 0
        for (const e of this.eventsState()) {
          const d = new Date(e.date)
          if (d.getFullYear() === y && d.getMonth() === m) {
            if (out[e.type] !== undefined) out[e.type]!++
            out.total++
          }
        }
        return out
      })
    
      readonly typeStats = computed(() => {
        const cursor = this.cursor()
        const y = cursor.getFullYear()
        const m = cursor.getMonth()
        const keys = this.typeKeys()
        const out: Record<string, { weeks: number[]; next: CalendarEvent | null }> = {}
        for (const k of keys) out[k] = { weeks: [0, 0, 0, 0, 0, 0], next: null }
        const firstOffset = new Date(y, m, 1).getDay()
        for (const e of this.eventsState()) {
          const d = new Date(e.date)
          if (d.getFullYear() === y && d.getMonth() === m && out[e.type]) {
            const week = Math.floor((d.getDate() - 1 + firstOffset) / 7)
            out[e.type]!.weeks[week]!++
          }
        }
        for (const t of keys) {
          const next = this.eventsState()
            .filter((e) => e.type === t && e.date >= this.todayKey())
            .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0]
          out[t]!.next = next ?? null
        }
        return out
      })
    
      readonly rangeEvents = computed(() => {
        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))
      })
    
      readonly rangeTypeCounts = computed(() => {
        const init: Record<string, number> = {}
        for (const k of this.typeKeys()) init[k] = 0
        for (const e of this.rangeEvents()) {
          if (init[e.type] !== undefined) init[e.type]!++
        }
        return init
      })
    
      readonly selectedDayEvents = computed(() => this.eventsByDate().get(this.rangeStart()) ?? [])
    
      readonly upcoming = computed(() =>
        this.filtered()
          .filter((e) => e.date > this.todayKey())
          .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))
          .slice(0, 4),
      )
    
      setView(v: string): void {
        this.view.set(v as 'month' | 'week' | 'day')
      }
    
      openEvent(e: CalendarEvent): void {
        this.selectedEvent.set(e)
        this.eventOpen.set(true)
        this.eventClick.emit(e)
      }
    
      copyDate(key: string): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText(key).catch(() => {
            /* clipboard may be denied; nothing to do */
          })
        }
      }
    
      fmtNextDate(key: string): string {
        if (key === this.todayKey()) return 'Today'
        return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
      }
    
      fmtDayLong(key: string): string {
        return dateFromKey(key).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
      }
    
      fmtDayShort(key: string): string {
        return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
      }
    
      monthShort(key: string): string {
        return new Date(key).toLocaleDateString('en-US', { month: 'short' })
      }
    
      dayNum(key: string): number {
        return new Date(key).getDate()
      }
    
      initials(name: string): string {
        return name
          .split(' ')
          .map((n) => n[0])
          .join('')
          .slice(0, 2)
      }
    
      inRange(key: DateKey): boolean {
        const { lo, hi } = this.rangeBounds()
        return key >= lo && key <= hi
      }
    
      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
      }
    
      cellClass(d: GridDay, i: number): string {
        return cn(
          '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',
        )
      }
    
      dayNumberClass(d: GridDay): string {
        return cn(
          'inline-flex size-5 items-center justify-center rounded-full text-xs tabular-nums',
          d.key === this.todayKey() && 'bg-primary text-primary-foreground font-semibold',
          d.key !== this.todayKey() && d.inMonth && 'text-foreground',
          !d.inMonth && 'text-muted-foreground/60',
        )
      }
    
      statTileClass(key: string): string {
        return cn(
          '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]',
          this.meta(key).ring,
        )
      }
    
      weekBarClass(key: string, n: number): string {
        return cn(
          'flex-1 rounded-sm transition-colors',
          n > 0 ? this.meta(key).bar : 'bg-muted/40',
          n > 0 && 'opacity-70 group-hover:opacity-100',
        )
      }
    
      /** Static Tailwind-safe grid columns for the stats strip (Vue builds this string dynamically). */
      statsGridClass(): string {
        const n = Math.min(this.typeKeys().length, 4)
        if (n <= 1) return ''
        if (n === 2) return 'sm:grid-cols-2'
        if (n === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
        return 'sm:grid-cols-2 lg:grid-cols-4'
      }
    
      rangeCountsClass(): string {
        const n = Math.min(this.typeKeys().length, 4)
        if (n <= 1) return 'grid-cols-1'
        if (n === 2) return 'grid-cols-2'
        if (n === 3) return 'grid-cols-3'
        return 'grid-cols-4'
      }
    
      selectCategoryFilter(key: string): void {
        const label = this.eventTypesState()[key]?.label.toLowerCase() || ''
        this.search.set(label === this.search().toLowerCase() ? '' : label)
      }
    
      handleEditSelected(): void {
        if (this.selectedEvent()) this.eventEdit.emit(this.selectedEvent()!)
        this.eventOpen.set(false)
      }
    
      handleDeleteSelected(): void {
        if (this.selectedEvent()) this.eventDelete.emit(this.selectedEvent()!)
        this.eventOpen.set(false)
      }
    
      prevMonth(): void {
        const c = this.cursor()
        this.cursor.set(new Date(c.getFullYear(), c.getMonth() - 1, 1))
      }
    
      nextMonth(): void {
        const c = this.cursor()
        this.cursor.set(new Date(c.getFullYear(), c.getMonth() + 1, 1))
      }
    
      goToToday(): void {
        const t = this.todayDate
        this.cursor.set(new Date(t.getFullYear(), t.getMonth(), 1))
        const key = this.todayKey()
        this.rangeAnchor.set(key)
        this.rangeStart.set(key)
        this.rangeEnd.set(key)
      }
    
      selectDay(key: DateKey): void {
        this.rangeAnchor.set(key)
        this.rangeStart.set(key)
        this.rangeEnd.set(key)
      }
    
      selectWeekOf(key: DateKey): void {
        const weekStartsOn = this.weekStartsOn
        const d = dateFromKey(key)
        const dow = (d.getDay() - weekStartsOn + 7) % 7
        const wkStart = new Date(d)
        wkStart.setDate(d.getDate() - dow)
        const wkEnd = new Date(wkStart)
        wkEnd.setDate(wkStart.getDate() + 6)
        this.rangeAnchor.set(isoDate(wkStart))
        this.rangeStart.set(isoDate(wkStart))
        this.rangeEnd.set(isoDate(wkEnd))
      }
    
      clearRange(): void {
        const key = this.todayKey()
        this.rangeAnchor.set(key)
        this.rangeStart.set(key)
        this.rangeEnd.set(key)
      }
    
      onCellMouseDown(key: DateKey, ev: MouseEvent): void {
        if (ev.button !== 0) return // ignore right-click -- context menus handle it
        if (ev.shiftKey) {
          this.rangeEnd.set(key)
          return
        }
        this.rangeAnchor.set(key)
        this.rangeStart.set(key)
        this.rangeEnd.set(key)
        this.isDragging.set(true)
      }
    
      onCellMouseEnter(key: DateKey): void {
        if (!this.isDragging()) return
        this.rangeEnd.set(key)
        this.rangeStart.set(this.rangeAnchor())
      }
    
      endDrag(): void {
        if (this.isDragging()) this.isDragging.set(false)
      }
    }
    
  • src/app/components/blocks/event-calendar-dashboard/defaults.ts1.4 kB
  • src/app/components/blocks/event-calendar-dashboard/types.ts1.5 kB
  • src/app/components/blocks/event-calendar-dashboard/index.ts0.2 kB

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