Event Calendar Dashboard
blockproductivityMonth-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
$pnpm dlx uipkge-ng@latest add event-calendar-dashboard$npx uipkge-ng@latest add event-calendar-dashboard$yarn dlx uipkge-ng@latest add event-calendar-dashboard$bunx uipkge-ng@latest add event-calendar-dashboardManifest:
https://uipkge.dev/r/angular/event-calendar-dashboard.jsonInstalls to:src/app/components/blocks/event-calendar-dashboard/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
GridDayinterface GridDay {
date: Date
key: DateKey
inMonth: boolean
}CalendarEventinterface 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
}EventTypeMetainterface 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
}npm dependencies
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) }} -> {{ 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
import { AlertCircle, CheckCircle2, Plane, Video } from 'lucide-angular' import type { EventTypeMeta } from './types' /** * Default event-type theme. Mirrors the boilerplate dashboard but routes * every color through OKLCH tokens (chart-1..4, success, warning) so it * follows light/dark mode and any consumer theme override. * * Override per-type or replace the whole map via `[eventTypes]="..."`. */ export const defaultEventTypes: Record<string, EventTypeMeta> = { 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: CheckCircle2, 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: AlertCircle, 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', }, }src/app/components/blocks/event-calendar-dashboard/types.ts1.5 kB
import type { LucideIconData } from 'lucide-angular' /** * Single event on the calendar. `type` is a free-form string (e.g. `'meeting'`, * `'task'`, anything) -- the matching key must exist in the `eventTypes` map * passed to <ui-event-calendar-dashboard>. Unknown types fall through to a neutral style. */ export 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 } /** * Visual + semantic config per event type. The default theme uses OKLCH * tokens (chart-1..4, success, warning) so consumers get sensible colors * out of the box, but you can override per-type to wire any palette. */ export 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 }src/app/components/blocks/event-calendar-dashboard/index.ts0.2 kB
export { UiEventCalendarDashboardComponent, type DateKey } from './event-calendar-dashboard.component' export { defaultEventTypes } from './defaults' export type { CalendarEvent, EventTypeMeta } from './types'
Raw manifest:https://uipkge.dev/r/angular/event-calendar-dashboard.json