
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
event-calendaruiFull-featured event calendar primitive supporting month, week, day, work-week, and category/resource views, timed interval grids with collision clustering, all-day header, live current-time indicator, and customizable event templates.
Also available for React ->Calendar is the always-visible month grid. Date Picker is the form field with popover. Range Calendar manages multi-month spans, and Event Calendar is the multi-view scheduling engine.
| Component | Role | Use when |
|---|---|---|
| Calendar Embedded dashboard views or scheduling pages where the month grid stays continuously visible. | Grid Primitive | |
| RangeCalendar Contiguous multi-month booking ranges, check-in / check-out spans, and date windows. | Range Primitive | |
| DatePicker Forms, filter bars, and dialogs where space is tight and the calendar opens inside a popover. | Form Control | |
| EventCalendarcurrent Full calendar interfaces with month/week/day views, timed hourly intervals, and overlap collision lanes. | Scheduling Engine |
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/event-calendar.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/event-calendar.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/event-calendar.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/event-calendar.jsonnpx shadcn-vue@latest add @uipkge/event-calendarInstalls to:app/components/ui/event-calendar/Multi-view scheduling dashboard demonstrating month, week intervals, day view, side-by-side collision handling, and event creation.
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'default''primary''secondary''success''warning''destructive''info''purple''rose' | default | optional |
size | 'sm''default''lg' | default | optional |
modelValue | string | Date | () => new Date(), view: 'month', events: () => [], catego… | optional |
view | CalendarView | — | optional |
events | CalendarEvent[] | — | optional |
categories | (string | CalendarCategory)[] | — | optional |
weekStartsOn | 0 | 1 | — | optional |
firstInterval | number | — | optional |
intervalCount | number | — | optional |
intervalMinutes | number | — | optional |
intervalHeight | number | — | optional |
timeFormat | '12h''24h' | — | optional |
maxEventsPerDay | number | — | optional |
showNowIndicator | boolean | — | optional |
showHeader | boolean | — | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
ClusterItemtype ClusterItem { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number }
const clusters: ClusterItem[][] = []
let currentCluster: ClusterItem[] = []
let clusterEnd = -1
for (const item of timed) {
if (currentCluster.length === 0 || item.startMinutes < clusterEnd) {
currentCluster.push({ ...item, col: 0 })
clusterEnd = Math.max(clusterEnd, item.endMinutes)
} else {
clusters.push(currentCluster)
currentCluster = [{ ...item, col: 0 }]
clusterEnd = item.endMinutes
}
}
if (currentCluster.length > 0) {
clusters.push(currentCluster)
}
// For each cluster, assign columns
for (const cluster of clusters) {
const cols: number[] = [] // end time of each column
for (const item of cluster) {
let placedCol = -1
for (let c = 0; c < cols.length; c++) {
if (item.startMinutes >= cols[c]) {
placedCol = c
cols[c] = item.endMinutes
break
}
}
if (placedCol === -1) {
placedCol = cols.length
cols.push(item.endMinutes)
}
item.col = placedCol
}
const totalCols = Math.max(1, cols.length)
for (const item of cluster) {
const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100))
const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100))
const heightPct = Math.max(1.5, bottomPct - topPct)
const widthPct = 100 / totalCols
const leftPct = item.col * widthPct
positioned.push({
event: item.event,
top: topPct,
height: heightPct,
left: leftPct,
width: widthPct,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
})
}
}
return positioned
}CalendarCategoryinterface CalendarCategory {
id: string
name: string
color?: string
}CalendarEventinterface CalendarEvent {
id?: string
title: string
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
start: string | Date
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
end?: string | Date
allDay?: boolean
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'destructive'
| 'info'
| 'purple'
| 'rose'
| string
category?: string
description?: string
location?: string
[key: string]: any
}TimeClickPayloadinterface TimeClickPayload {
date: string
time: string
hour: number
minute: number
category?: string
}PositionedEventinterface PositionedEvent {
event: CalendarEvent
top: number
height: number
left: number
width: number
startMinutes: number
endMinutes: number
}CalendarDayCellinterface CalendarDayCell {
date: Date
dateKey: string
dayNumber: number
inMonth: boolean
isToday: boolean
events: CalendarEvent[]
allDayEvents: CalendarEvent[]
timedEvents: CalendarEvent[]
}<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import type { HTMLAttributes } from 'vue'
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MoreHorizontal } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { CalendarView, CalendarCategory, CalendarEvent, TimeClickPayload, PositionedEvent } from './types'
import {
parseDate,
formatDateKey,
formatTime,
formatHourLabel,
isSameDay,
isToday,
getMonthDays,
getWeekDays,
getWorkWeekDays,
calculateTimedEventPositions,
getCurrentTimePosition,
getEventMinutes,
} from './date-utils'
import { calendarEventVariants } from './event-calendar.variants'
interface Props {
modelValue?: string | Date
view?: CalendarView
events?: CalendarEvent[]
categories?: (string | CalendarCategory)[]
weekStartsOn?: 0 | 1
firstInterval?: number
intervalCount?: number
intervalMinutes?: number
intervalHeight?: number
timeFormat?: '12h' | '24h'
maxEventsPerDay?: number
showNowIndicator?: boolean
showHeader?: boolean
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => new Date(),
view: 'month',
events: () => [],
categories: () => [],
weekStartsOn: 0,
firstInterval: 0,
intervalCount: 24,
intervalMinutes: 60,
intervalHeight: 52,
timeFormat: '12h',
maxEventsPerDay: 3,
showNowIndicator: true,
showHeader: true,
})
const emit = defineEmits<{
'update:modelValue': [date: Date]
'update:view': [view: CalendarView]
'click:event': [event: CalendarEvent]
'click:date': [date: string]
'click:time': [payload: TimeClickPayload]
'click:more': [payload: { date: string; events: CalendarEvent[] }]
}>()
// Active date cursor
const activeDate = computed(() => parseDate(props.modelValue))
function setDate(d: Date) {
emit('update:modelValue', d)
}
function setView(v: CalendarView) {
emit('update:view', v)
}
// Normalized categories
const normalizedCategories = computed<CalendarCategory[]>(() => {
return props.categories.map((c, idx) => {
if (typeof c === 'string') {
return { id: c, name: c }
}
return { id: c.id || `cat-${idx}`, name: c.name || `Category ${idx + 1}`, color: c.color }
})
})
// Current time line update timer
const nowPosition = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
function updateNow() {
if (!props.showNowIndicator) {
nowPosition.value = null
return
}
nowPosition.value = getCurrentTimePosition(props.firstInterval, props.intervalCount, props.intervalMinutes)
}
onMounted(() => {
updateNow()
timer = setInterval(updateNow, 30000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
// Navigation controls
function handlePrev() {
const d = new Date(activeDate.value)
if (props.view === 'month') {
d.setMonth(d.getMonth() - 1)
} else if (props.view === 'week') {
d.setDate(d.getDate() - 7)
} else if (props.view === 'work-week') {
d.setDate(d.getDate() - 7)
} else if (props.view === 'day' || props.view === 'category') {
d.setDate(d.getDate() - 1)
}
setDate(d)
}
function handleNext() {
const d = new Date(activeDate.value)
if (props.view === 'month') {
d.setMonth(d.getMonth() + 1)
} else if (props.view === 'week') {
d.setDate(d.getDate() + 7)
} else if (props.view === 'work-week') {
d.setDate(d.getDate() + 7)
} else if (props.view === 'day' || props.view === 'category') {
d.setDate(d.getDate() + 1)
}
setDate(d)
}
function handleToday() {
setDate(new Date())
}
// Header title calculation
const formattedTitle = computed(() => {
const d = activeDate.value
if (props.view === 'month') {
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
}
if (props.view === 'week') {
const days = getWeekDays(d, props.weekStartsOn)
const first = days[0]
const last = days[6]
if (first.getMonth() === last.getMonth()) {
return `${first.toLocaleDateString('en-US', { month: 'short' })} ${first.getDate()} – ${last.getDate()}, ${first.getFullYear()}`
}
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${last.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
}
if (props.view === 'work-week') {
const days = getWorkWeekDays(d)
const first = days[0]
const last = days[4]
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${last.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
}
// day / category
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
})
// Month View Data
const monthDays = computed(() => getMonthDays(activeDate.value, props.weekStartsOn))
function getEventsForDay(dateKey: string): CalendarEvent[] {
return props.events.filter((e) => {
const startStr =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return startStr === dateKey
})
}
// Interval array for time grid
const intervals = computed(() => {
const list: { hour: number; label: string; time: string }[] = []
for (let i = 0; i < props.intervalCount; i++) {
const hour = (props.firstInterval + Math.floor((i * props.intervalMinutes) / 60)) % 24
const min = (i * props.intervalMinutes) % 60
const time = `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}`
const label = min === 0 ? formatHourLabel(hour, props.timeFormat) : ''
list.push({ hour, label, time })
}
return list
})
// Week Days
const weekDays = computed(() => getWeekDays(activeDate.value, props.weekStartsOn))
// Work Week Days
const workWeekDays = computed(() => getWorkWeekDays(activeDate.value))
// All day events for a day
function getAllDayEventsForDay(dayDate: Date): CalendarEvent[] {
const dayKey = formatDateKey(dayDate)
return props.events.filter((e) => {
if (!e.allDay) return false
const sKey =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return sKey === dayKey
})
}
// Day header formatters
const weekdayHeaderLabels = computed(() => {
if (props.weekStartsOn === 1) {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
}
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
})
function getEventVariant(event: CalendarEvent): any {
if (
event.color === 'primary' ||
event.color === 'secondary' ||
event.color === 'success' ||
event.color === 'warning' ||
event.color === 'destructive' ||
event.color === 'info' ||
event.color === 'purple' ||
event.color === 'rose'
) {
return event.color
}
return 'default'
}
function handleEventClick(event: CalendarEvent, evt: MouseEvent) {
evt.stopPropagation()
emit('click:event', event)
}
function handleDateClick(dateKey: string) {
emit('click:date', dateKey)
}
function handleTimeClick(dayDate: Date, hour: number, minute: number, category?: string) {
const dateStr = formatDateKey(dayDate)
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
emit('click:time', {
date: dateStr,
time: timeStr,
hour,
minute,
category,
})
}
function handleMoreClick(dateKey: string, events: CalendarEvent[], evt: MouseEvent) {
evt.stopPropagation()
emit('click:more', { date: dateKey, events })
}
</script>
<template>
<div
data-slot="event-calendar"
:class="
cn(
'border-border bg-card text-foreground flex w-full flex-col overflow-hidden rounded-xl border shadow-xs',
props.class,
)
"
>
<!-- Built-in Header Toolbar -->
<slot
v-if="showHeader"
name="header"
:current-date="activeDate"
:view="view"
:title="formattedTitle"
:prev="handlePrev"
:next="handleNext"
:today="handleToday"
:set-view="setView"
>
<header
class="border-border bg-card/60 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3 backdrop-blur-xs"
>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-8 px-2.5 text-xs font-medium" @click="handleToday">
Today
</Button>
<div class="flex items-center gap-0.5">
<Button variant="ghost" size="icon" class="size-8" aria-label="Previous period" @click="handlePrev">
<ChevronLeft class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8" aria-label="Next period" @click="handleNext">
<ChevronRight class="size-4" />
</Button>
</div>
<h2 class="text-foreground ml-1 text-base font-semibold tracking-tight sm:text-lg">
{{ formattedTitle }}
</h2>
</div>
<div class="flex items-center gap-1.5">
<slot name="header-actions" />
<div class="border-border bg-muted/40 flex items-center rounded-lg border p-0.5">
<button
type="button"
:class="
cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
view === 'month'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="setView('month')"
>
Month
</button>
<button
type="button"
:class="
cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
view === 'week'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="setView('week')"
>
Week
</button>
<button
type="button"
:class="
cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
view === 'work-week'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="setView('work-week')"
>
Work
</button>
<button
type="button"
:class="
cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
view === 'day'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="setView('day')"
>
Day
</button>
<button
v-if="normalizedCategories.length > 0"
type="button"
:class="
cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
view === 'category'
? 'bg-background text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="setView('category')"
>
Category
</button>
</div>
</div>
</header>
</slot>
<!-- VIEW 1: MONTH VIEW -->
<div v-if="view === 'month'" class="flex flex-1 flex-col">
<!-- Weekday headers -->
<div
class="border-border bg-muted/20 text-muted-foreground grid grid-cols-7 border-b text-center text-xs font-medium"
>
<div
v-for="(dayName, idx) in weekdayHeaderLabels"
:key="idx"
class="border-border/40 border-r py-2 last:border-r-0"
>
{{ dayName }}
</div>
</div>
<!-- 6-week month grid -->
<div class="divide-border/40 grid min-h-[580px] flex-1 grid-cols-7 grid-rows-6 divide-x divide-y">
<div
v-for="cell in monthDays"
:key="cell.dateKey"
:class="
cn(
'group relative flex min-h-[96px] cursor-pointer flex-col p-1.5 transition-colors',
cell.inMonth ? 'bg-card hover:bg-muted/15' : 'bg-muted/10 text-muted-foreground/50 hover:bg-muted/20',
)
"
@click="handleDateClick(cell.dateKey)"
>
<!-- Cell Header: Day Number -->
<div class="mb-1 flex items-center justify-between">
<span
:class="
cn(
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium transition-colors',
cell.isToday
? 'bg-primary text-primary-foreground font-semibold shadow-xs'
: cell.inMonth
? 'text-foreground/90'
: 'text-muted-foreground/60',
)
"
>
{{ cell.date.getDate() }}
</span>
</div>
<!-- Events in Day Cell -->
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
<template v-for="(evt, idx) in getEventsForDay(cell.dateKey)" :key="evt.id || idx">
<!-- If within maxEventsPerDay or second-to-last before +more -->
<template v-if="getEventsForDay(cell.dateKey).length <= maxEventsPerDay || idx < maxEventsPerDay - 1">
<slot name="event" :event="evt" :view="'month'" :is-all-day="Boolean(evt.allDay)">
<div
data-slot="event-card"
:class="cn(calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }), 'w-full truncate')"
@click="handleEventClick(evt, $event)"
>
<div class="flex items-center gap-1 truncate font-medium">
<span v-if="!evt.allDay" class="shrink-0 font-mono text-[10px] opacity-75">
{{ formatTime(getEventMinutes(evt.start, 540), timeFormat) }}
</span>
<span class="truncate">{{ evt.title }}</span>
</div>
</div>
</slot>
</template>
</template>
<!-- +N more button with popover -->
<div v-if="getEventsForDay(cell.dateKey).length > maxEventsPerDay" class="mt-auto pt-0.5">
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="text-primary hover:text-primary/80 hover:bg-primary/10 flex items-center gap-0.5 rounded-sm px-1 py-0.5 text-[11px] font-semibold transition-colors hover:underline"
@click="handleMoreClick(cell.dateKey, getEventsForDay(cell.dateKey), $event)"
>
+{{ getEventsForDay(cell.dateKey).length - (maxEventsPerDay - 1) }} more
</button>
</PopoverTrigger>
<PopoverContent class="w-64 p-2 shadow-lg" align="start">
<div
class="border-border mb-1.5 flex items-center justify-between border-b pb-1.5 text-xs font-semibold"
>
<span>{{
cell.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', weekday: 'short' })
}}</span>
<span class="text-muted-foreground text-[11px] font-normal">
{{ getEventsForDay(cell.dateKey).length }} events
</span>
</div>
<div class="flex max-h-48 flex-col gap-1 overflow-y-auto">
<div
v-for="(evt, idx) in getEventsForDay(cell.dateKey)"
:key="evt.id || idx"
:class="cn(calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }), 'w-full')"
@click="handleEventClick(evt, $event)"
>
<div class="flex items-center gap-1 truncate font-medium">
<span v-if="!evt.allDay" class="shrink-0 font-mono text-[10px] opacity-75">
{{ formatTime(getEventMinutes(evt.start, 540), timeFormat) }}
</span>
<span class="truncate">{{ evt.title }}</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
</div>
<!-- VIEW 2 & 3 & 4: WEEK, WORK-WEEK, DAY VIEWS -->
<div
v-else-if="view === 'week' || view === 'work-week' || view === 'day'"
class="flex flex-1 flex-col overflow-hidden"
>
<!-- Top Columns Header (Sticky) -->
<div class="border-border bg-muted/20 flex border-b select-none">
<!-- Time gutter header spacer -->
<div
class="border-border/50 text-muted-foreground w-16 shrink-0 border-r py-2.5 text-center text-xs font-medium"
>
<Clock class="mx-auto size-3.5 opacity-60" />
</div>
<!-- Columns (7 for week, 5 for work-week, 1 for day) -->
<div
class="divide-border/50 grid flex-1 divide-x"
:class="view === 'week' ? 'grid-cols-7' : view === 'work-week' ? 'grid-cols-5' : 'grid-cols-1'"
>
<div
v-for="d in view === 'week' ? weekDays : view === 'work-week' ? workWeekDays : [activeDate]"
:key="formatDateKey(d)"
:class="
cn(
'hover:bg-muted/30 flex cursor-pointer flex-col items-center justify-center py-2 transition-colors',
isToday(d) && 'bg-primary/5',
)
"
@click="handleDateClick(formatDateKey(d))"
>
<slot name="day-header" :date="d" :date-key="formatDateKey(d)" :is-today="isToday(d)" :view="view">
<span class="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
{{ d.toLocaleDateString('en-US', { weekday: view === 'day' ? 'long' : 'short' }) }}
</span>
<span
:class="
cn(
'mt-0.5 inline-flex size-7 items-center justify-center rounded-full text-xs font-semibold transition-all',
isToday(d) ? 'bg-primary text-primary-foreground shadow-xs' : 'text-foreground',
)
"
>
{{ d.getDate() }}
</span>
</slot>
</div>
</div>
</div>
<!-- Pinned All-Day Section (if all-day events exist) -->
<div class="border-border bg-muted/10 flex border-b text-xs">
<div
class="border-border/50 text-muted-foreground w-16 shrink-0 border-r p-2 text-right text-[10px] font-semibold tracking-wider uppercase"
>
All-day
</div>
<div
class="divide-border/50 grid flex-1 divide-x"
:class="view === 'week' ? 'grid-cols-7' : view === 'work-week' ? 'grid-cols-5' : 'grid-cols-1'"
>
<div
v-for="d in view === 'week' ? weekDays : view === 'work-week' ? workWeekDays : [activeDate]"
:key="formatDateKey(d)"
class="flex min-h-[32px] flex-col gap-1 p-1"
>
<slot name="all-day" :date="d" :date-key="formatDateKey(d)" :events="getAllDayEventsForDay(d)">
<div
v-for="evt in getAllDayEventsForDay(d)"
:key="evt.id || evt.title"
data-slot="event-card"
:class="
cn(calendarEventVariants({ variant: getEventVariant(evt), size: 'sm' }), 'w-full truncate py-0.5')
"
@click="handleEventClick(evt, $event)"
>
<span class="truncate font-medium">{{ evt.title }}</span>
</div>
</slot>
</div>
</div>
</div>
<!-- Scrollable Time Intervals Grid -->
<div class="relative flex max-h-[640px] min-h-[480px] flex-1 overflow-y-auto">
<!-- Time Gutter -->
<div class="border-border/50 bg-card w-16 shrink-0 border-r select-none">
<div
v-for="interval in intervals"
:key="interval.time"
:style="{ height: `${intervalHeight}px` }"
class="border-border/30 text-muted-foreground relative border-b pr-2.5 text-right text-[11px] font-medium"
>
<slot name="interval" :hour="interval.hour" :time="interval.time" :label="interval.label">
<span v-if="interval.label" class="relative -top-2 block">
{{ interval.label }}
</span>
</slot>
</div>
</div>
<!-- Day Columns Grid -->
<div
class="divide-border/50 relative grid flex-1 divide-x"
:class="view === 'week' ? 'grid-cols-7' : view === 'work-week' ? 'grid-cols-5' : 'grid-cols-1'"
>
<div
v-for="d in view === 'week' ? weekDays : view === 'work-week' ? workWeekDays : [activeDate]"
:key="formatDateKey(d)"
class="relative flex flex-col"
>
<!-- Interval Rows (Clickable for time click) -->
<div
v-for="interval in intervals"
:key="interval.time"
:style="{ height: `${intervalHeight}px` }"
class="border-border/30 hover:bg-muted/20 cursor-pointer border-b transition-colors"
@click="handleTimeClick(d, interval.hour, 0)"
/>
<!-- Timed Events Container (Absolute Overlay) -->
<div class="pointer-events-none absolute inset-0 p-0.5">
<div
v-for="item in calculateTimedEventPositions(events, d, firstInterval, intervalCount, intervalMinutes)"
:key="item.event.id || item.event.title"
:style="{
top: `${item.top}%`,
height: `${item.height}%`,
left: `calc(${item.left}% + 2px)`,
width: `calc(${item.width}% - 4px)`,
}"
class="pointer-events-auto absolute z-10"
>
<slot name="event" :event="item.event" :view="view" :is-all-day="false">
<div
data-slot="event-card"
:class="
cn(
calendarEventVariants({ variant: getEventVariant(item.event) }),
'flex h-full w-full flex-col justify-start overflow-hidden rounded-md p-1.5 leading-tight shadow-xs',
)
"
@click="handleEventClick(item.event, $event)"
>
<span class="truncate text-xs font-semibold">{{ item.event.title }}</span>
<span class="truncate font-mono text-[10px] opacity-80">
{{ formatTime(item.startMinutes, timeFormat) }} – {{ formatTime(item.endMinutes, timeFormat) }}
</span>
<span v-if="item.event.location" class="mt-auto truncate text-[10px] opacity-70">
📍 {{ item.event.location }}
</span>
</div>
</slot>
</div>
<!-- Live Current Time Indicator -->
<div
v-if="isToday(d) && nowPosition !== null"
:style="{ top: `${nowPosition}%` }"
class="pointer-events-none absolute right-0 left-0 z-20"
>
<div class="relative w-full border-t-2 border-red-500 dark:border-red-400">
<div
class="ring-background absolute -top-1 -left-1 size-2 rounded-full bg-red-500 ring-2 dark:bg-red-400"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- VIEW 5: CATEGORY / RESOURCE VIEW -->
<div v-else-if="view === 'category'" class="flex flex-1 flex-col overflow-hidden">
<!-- Category Columns Header -->
<div class="border-border bg-muted/20 flex border-b select-none">
<div
class="border-border/50 text-muted-foreground w-16 shrink-0 border-r py-2.5 text-center text-xs font-medium"
>
<Clock class="mx-auto size-3.5 opacity-60" />
</div>
<div
class="divide-border/50 grid flex-1 divide-x"
:style="{ gridTemplateColumns: `repeat(${Math.max(1, normalizedCategories.length)}, minmax(0, 1fr))` }"
>
<div
v-for="cat in normalizedCategories"
:key="cat.id"
class="text-foreground flex items-center justify-center gap-1.5 px-2 py-2.5 text-center text-xs font-semibold"
>
<span v-if="cat.color" class="size-2 shrink-0 rounded-full" :style="{ backgroundColor: cat.color }" />
<span class="truncate">{{ cat.name }}</span>
</div>
</div>
</div>
<!-- Category Intervals Grid -->
<div class="relative flex max-h-[640px] min-h-[480px] flex-1 overflow-y-auto">
<!-- Time Gutter -->
<div class="border-border/50 bg-card w-16 shrink-0 border-r select-none">
<div
v-for="interval in intervals"
:key="interval.time"
:style="{ height: `${intervalHeight}px` }"
class="border-border/30 text-muted-foreground relative border-b pr-2.5 text-right text-[11px] font-medium"
>
<span v-if="interval.label" class="relative -top-2 block">
{{ interval.label }}
</span>
</div>
</div>
<!-- Category Columns -->
<div
class="divide-border/50 relative grid flex-1 divide-x"
:style="{ gridTemplateColumns: `repeat(${Math.max(1, normalizedCategories.length)}, minmax(0, 1fr))` }"
>
<div v-for="cat in normalizedCategories" :key="cat.id" class="relative flex flex-col">
<!-- Interval Rows -->
<div
v-for="interval in intervals"
:key="interval.time"
:style="{ height: `${intervalHeight}px` }"
class="border-border/30 hover:bg-muted/20 cursor-pointer border-b transition-colors"
@click="handleTimeClick(activeDate, interval.hour, 0, cat.id)"
/>
<!-- Category Timed Events Container -->
<div class="pointer-events-none absolute inset-0 p-0.5">
<div
v-for="item in calculateTimedEventPositions(
events.filter((e) => e.category === cat.id || e.category === cat.name),
activeDate,
firstInterval,
intervalCount,
intervalMinutes,
)"
:key="item.event.id || item.event.title"
:style="{
top: `${item.top}%`,
height: `${item.height}%`,
left: `calc(${item.left}% + 2px)`,
width: `calc(${item.width}% - 4px)`,
}"
class="pointer-events-auto absolute z-10"
>
<slot name="event" :event="item.event" :view="'category'" :is-all-day="false">
<div
data-slot="event-card"
:class="
cn(
calendarEventVariants({ variant: getEventVariant(item.event) }),
'flex h-full w-full flex-col justify-start overflow-hidden rounded-md p-1.5 leading-tight shadow-xs',
)
"
@click="handleEventClick(item.event, $event)"
>
<span class="truncate text-xs font-semibold">{{ item.event.title }}</span>
<span class="truncate font-mono text-[10px] opacity-80">
{{ formatTime(item.startMinutes, timeFormat) }} – {{ formatTime(item.endMinutes, timeFormat) }}
</span>
</div>
</slot>
</div>
<!-- Live Current Time Indicator -->
<div
v-if="isToday(activeDate) && nowPosition !== null"
:style="{ top: `${nowPosition}%` }"
class="pointer-events-none absolute right-0 left-0 z-20"
>
<div class="relative w-full border-t-2 border-red-500 dark:border-red-400">
<div
class="ring-background absolute -top-1 -left-1 size-2 rounded-full bg-red-500 ring-2 dark:bg-red-400"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
import { cva, type VariantProps } from 'class-variance-authority'
export const calendarEventVariants = cva(
'group relative flex cursor-pointer flex-col overflow-hidden text-left transition-colors select-none border font-normal focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring',
{
variants: {
variant: {
default: 'bg-secondary/80 text-secondary-foreground border-border/50 hover:bg-secondary',
primary: 'bg-primary/10 text-primary border-primary/25 hover:bg-primary/20',
secondary: 'bg-muted/90 text-foreground border-border/60 hover:bg-muted',
success:
'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-800 dark:text-amber-400 border-amber-500/30 hover:bg-amber-500/20',
destructive: 'bg-destructive/10 text-destructive border-destructive/30 hover:bg-destructive/20',
info: 'bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 hover:bg-sky-500/20',
purple: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/30 hover:bg-purple-500/20',
rose: 'bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 hover:bg-rose-500/20',
},
size: {
sm: 'rounded-sm px-1.5 py-0.5 text-[11px] leading-tight',
default: 'rounded-md px-2 py-1 text-xs leading-normal',
lg: 'rounded-md px-2.5 py-1.5 text-sm leading-snug',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type CalendarEventVariants = VariantProps<typeof calendarEventVariants>
import type { CalendarEvent, PositionedEvent } from './types'
export function parseDate(d: string | Date | undefined | null): Date {
if (!d) return new Date()
if (d instanceof Date) return new Date(d.getTime())
// Format: YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) {
const [y, m, day] = d.split('-').map(Number)
return new Date(y, m - 1, day, 0, 0, 0)
}
// Format: YYYY-MM-DD HH:mm or YYYY-MM-DDTHH:mm
const matchTime = d.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/)
if (matchTime) {
const [, y, m, day, h, min] = matchTime.map(Number)
return new Date(y, m - 1, day, h, min, 0)
}
const parsed = new Date(d)
return isNaN(parsed.getTime()) ? new Date() : parsed
}
export function formatDateKey(d: Date): string {
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 isSameDay(d1: Date, d2: Date): boolean {
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate()
}
export function isToday(d: Date): boolean {
return isSameDay(d, new Date())
}
export function formatTime(minutes: number, format: '12h' | '24h' = '12h'): string {
const totalMin = Math.max(0, Math.min(1439, Math.round(minutes)))
const hour = Math.floor(totalMin / 60)
const min = totalMin % 60
if (format === '24h') {
return `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}`
}
const ampm = hour >= 12 ? 'PM' : 'AM'
const h12 = hour % 12 || 12
return `${h12}:${String(min).padStart(2, '0')} ${ampm}`
}
export function formatHourLabel(hour: number, format: '12h' | '24h' = '12h'): string {
const normalizedHour = ((hour % 24) + 24) % 24
if (format === '24h') {
return `${String(normalizedHour).padStart(2, '0')}:00`
}
if (normalizedHour === 0) return '12 AM'
if (normalizedHour === 12) return '12 PM'
return normalizedHour > 12 ? `${normalizedHour - 12} PM` : `${normalizedHour} AM`
}
export function getEventMinutes(dateInput: string | Date | undefined, defaultMinutes: number = 0): number {
if (!dateInput) return defaultMinutes
if (dateInput instanceof Date) {
return dateInput.getHours() * 60 + dateInput.getMinutes()
}
if (typeof dateInput === 'string') {
// HH:mm
if (/^\d{1,2}:\d{2}$/.test(dateInput)) {
const [h, m] = dateInput.split(':').map(Number)
return h * 60 + m
}
// Check if contains time
const parsed = parseDate(dateInput)
// If input had no time specified (just YYYY-MM-DD), default to defaultMinutes
if (/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
return defaultMinutes
}
return parsed.getHours() * 60 + parsed.getMinutes()
}
return defaultMinutes
}
export function getWeekDays(baseDate: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const date = parseDate(baseDate)
const day = date.getDay()
const diff = (day - weekStartsOn + 7) % 7
const startOfWeek = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff)
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const d = new Date(startOfWeek)
d.setDate(startOfWeek.getDate() + i)
days.push(d)
}
return days
}
export function getWorkWeekDays(baseDate: Date): Date[] {
// Always Monday through Friday
const date = parseDate(baseDate)
const day = date.getDay()
const diff = (day - 1 + 7) % 7 // Monday offset
const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - diff)
const days: Date[] = []
for (let i = 0; i < 5; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
days.push(d)
}
return days
}
export function getMonthDays(
cursor: Date,
weekStartsOn: 0 | 1 = 0,
): { date: Date; dateKey: string; inMonth: boolean }[] {
const y = cursor.getFullYear()
const m = cursor.getMonth()
const firstDay = new Date(y, m, 1)
const offset = (firstDay.getDay() - weekStartsOn + 7) % 7
const startDate = new Date(y, m, 1 - offset)
const days: { date: Date; dateKey: string; inMonth: boolean }[] = []
for (let i = 0; i < 42; i++) {
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i)
days.push({
date: d,
dateKey: formatDateKey(d),
inMonth: d.getMonth() === m,
})
}
return days
}
export function calculateTimedEventPositions(
events: CalendarEvent[],
dayDate: Date,
firstInterval: number = 0,
intervalCount: number = 24,
intervalMinutes: number = 60,
): PositionedEvent[] {
const dayKey = formatDateKey(dayDate)
const startDayMinutes = firstInterval * 60
const totalMinutes = intervalCount * intervalMinutes
// Filter events belonging to this day that are timed
const timed = events
.filter((e) => {
if (e.allDay) return false
const sKey =
typeof e.start === 'string' && /^\d{4}-\d{2}-\d{2}/.test(e.start)
? e.start.slice(0, 10)
: formatDateKey(parseDate(e.start))
return sKey === dayKey
})
.map((event) => {
const sMin = getEventMinutes(event.start, 9 * 60)
let eMin = getEventMinutes(event.end, sMin + 60)
if (eMin <= sMin) eMin = sMin + 30 // minimum 30 min duration
return {
event,
startMinutes: sMin,
endMinutes: eMin,
}
})
.sort((a, b) => a.startMinutes - b.startMinutes || b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes))
if (timed.length === 0) return []
// Assign columns for overlapping groups
const positioned: PositionedEvent[] = []
// Cluster overlapping events
type ClusterItem = { event: CalendarEvent; startMinutes: number; endMinutes: number; col: number }
const clusters: ClusterItem[][] = []
let currentCluster: ClusterItem[] = []
let clusterEnd = -1
for (const item of timed) {
if (currentCluster.length === 0 || item.startMinutes < clusterEnd) {
currentCluster.push({ ...item, col: 0 })
clusterEnd = Math.max(clusterEnd, item.endMinutes)
} else {
clusters.push(currentCluster)
currentCluster = [{ ...item, col: 0 }]
clusterEnd = item.endMinutes
}
}
if (currentCluster.length > 0) {
clusters.push(currentCluster)
}
// For each cluster, assign columns
for (const cluster of clusters) {
const cols: number[] = [] // end time of each column
for (const item of cluster) {
let placedCol = -1
for (let c = 0; c < cols.length; c++) {
if (item.startMinutes >= cols[c]) {
placedCol = c
cols[c] = item.endMinutes
break
}
}
if (placedCol === -1) {
placedCol = cols.length
cols.push(item.endMinutes)
}
item.col = placedCol
}
const totalCols = Math.max(1, cols.length)
for (const item of cluster) {
const topPct = Math.max(0, Math.min(100, ((item.startMinutes - startDayMinutes) / totalMinutes) * 100))
const bottomPct = Math.max(0, Math.min(100, ((item.endMinutes - startDayMinutes) / totalMinutes) * 100))
const heightPct = Math.max(1.5, bottomPct - topPct)
const widthPct = 100 / totalCols
const leftPct = item.col * widthPct
positioned.push({
event: item.event,
top: topPct,
height: heightPct,
left: leftPct,
width: widthPct,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes,
})
}
}
return positioned
}
export function getCurrentTimePosition(
firstInterval: number = 0,
intervalCount: number = 24,
intervalMinutes: number = 60,
): number | null {
const now = new Date()
const curMinutes = now.getHours() * 60 + now.getMinutes()
const startMinutes = firstInterval * 60
const totalMinutes = intervalCount * intervalMinutes
if (curMinutes < startMinutes || curMinutes > startMinutes + totalMinutes) {
return null
}
return ((curMinutes - startMinutes) / totalMinutes) * 100
}
export type CalendarView = 'month' | 'week' | 'day' | 'work-week' | 'category'
export interface CalendarCategory {
id: string
name: string
color?: string
}
export interface CalendarEvent {
id?: string
title: string
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
start: string | Date
/** YYYY-MM-DD or YYYY-MM-DD HH:mm or ISO string or Date */
end?: string | Date
allDay?: boolean
color?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'destructive'
| 'info'
| 'purple'
| 'rose'
| string
category?: string
description?: string
location?: string
[key: string]: any
}
export interface TimeClickPayload {
date: string
time: string
hour: number
minute: number
category?: string
}
export interface PositionedEvent {
event: CalendarEvent
top: number
height: number
left: number
width: number
startMinutes: number
endMinutes: number
}
export interface CalendarDayCell {
date: Date
dateKey: string
dayNumber: number
inMonth: boolean
isToday: boolean
events: CalendarEvent[]
allDayEvents: CalendarEvent[]
timedEvents: CalendarEvent[]
}
export { default as EventCalendar } from './EventCalendar.vue'
export * from './types'
export * from './event-calendar.variants'
export * from './date-utils'
Raw manifest:https://uipkge.dev/r/vue/event-calendar.json