A framework-agnostic TypeScript library for calendar event management, layout calculations, and scheduling logic. Build calendar UIs in React, Vue, Angular, or vanilla JavaScript with a solid, tested foundation.
- 📅 Framework-agnostic - Use with React, Vue, Angular, or vanilla JS
- 🎯 Type-safe - Full TypeScript support with comprehensive types
- 📐 Smart Layout Engine - Automatic positioning and collision detection
- ✅ Validation Rules - Work hours, conflicts, custom rules
- 🔄 Event Store - Reactive data management with subscriptions
- 🌍 Internationalization - Timezone and locale support
- ⚡ Performance - Optimized for thousands of events
- 🧪 Well-tested - 100% test coverage
npm install calendar-core
# or
yarn add calendar-core
# or
pnpm add calendar-core
import {
CalendarCore,
InMemoryEventStore,
DefaultLayoutEngine,
AllowAllValidator
} from 'calendar-core'
// Create core instance
const store = new InMemoryEventStore()
const engine = new DefaultLayoutEngine()
const validator = new AllowAllValidator()
const calendar = new CalendarCore({
store,
engine,
validator
})
// Add events
store.upsert({
id: '1',
title: 'Team Meeting',
start: new Date('2025-09-01T10:00:00Z'),
end: new Date('2025-09-01T11:00:00Z')
})
// Calculate layout
const positioned = calendar.layout({
viewStart: new Date('2025-09-01T00:00:00Z'),
viewEnd: new Date('2025-09-07T23:59:59Z'),
dayStartHour: 8,
dayEndHour: 18,
timeStepMinutes: 30
})
// positioned[0] = {
// id: '1',
// dayIndex: 0,
// top: 20, // 20% from day start
// height: 10, // 10% of day height
// column: 0, // First column
// columnsTotal: 1 // No overlaps
// }
The basic event structure:
interface CalendarEvent {
id: string
start: Date
end: Date
title?: string
allDay?: boolean
color?: string
resourceId?: string // For resource views
readonly?: boolean // Prevent modifications
recurrence?: RecurrenceRule
meta?: Record<string, unknown> // Custom data
}
Events with calculated layout information:
interface PositionedEvent {
// Original data
id: string
originalStart: Date
originalEnd: Date
// Clipped to viewport
clippedStart: Date
clippedEnd: Date
// Layout
dayIndex: number // 0-based day in viewport
top: number // Percentage from day start
height: number // Percentage of day height
column: number // Column for overlaps
columnsTotal: number // Total columns needed
// Flags
continuesBefore: boolean // Extends before viewport
continuesAfter: boolean // Extends after viewport
allDay: boolean
}
Reactive event storage with subscriptions:
const store = new InMemoryEventStore()
// Subscribe to changes
const unsubscribe = store.subscribe((snapshot) => {
console.log('Events changed:', snapshot.events)
console.log('Version:', snapshot.version)
})
// CRUD operations
store.upsert(event)
store.upsertMany([event1, event2])
store.remove(eventId)
store.clear()
// Queries
const event = store.getById('event-1')
const events = store.getByDateRange(startDate, endDate)
const roomEvents = store.getByResourceId('room-a')
// Cleanup
unsubscribe()
Calculates event positions with collision detection:
const engine = new DefaultLayoutEngine()
const positioned = engine.layout(events, {
viewStart: new Date('2025-09-01T00:00:00Z'),
viewEnd: new Date('2025-09-07T23:59:59Z'),
dayStartHour: 8,
dayEndHour: 18,
timeStepMinutes: 30,
heightStrategy: 'percent', // or 'pixels'
pixelsPerMinute: 2 // if using pixels
})
Two strategies for handling overlapping events:
import { SimpleCollisionResolver, PackingCollisionResolver } from 'calendar-core'
// Simple: Fast, left-to-right assignment
const simple = new SimpleCollisionResolver()
// Packing: Optimized grouping for better space usage
const packing = new PackingCollisionResolver()
const engine = new DefaultLayoutEngine(packing)
import {
AllowAllValidator,
WorkHoursValidator,
ConflictValidator,
CompositeValidator
} from 'calendar-core'
// Allow all events (default)
const allowAll = new AllowAllValidator()
// Enforce work hours
const workHours = new WorkHoursValidator(
9, // Start hour
17, // End hour
[1, 2, 3, 4, 5] // Mon-Fri (0=Sun, 6=Sat)
)
// Prevent conflicts
const conflicts = new ConflictValidator(false) // false = disallow conflicts
// Combine multiple rules
const validator = new CompositeValidator([
workHours,
conflicts
])
import { RuleValidator, CalendarEvent, ValidationResult } from 'calendar-core'
class MaxDurationValidator implements RuleValidator {
constructor(private maxHours: number) {}
validatePlacement(event: CalendarEvent): ValidationResult {
const hours = (event.end.getTime() - event.start.getTime()) / 3600000
if (hours > this.maxHours) {
return {
valid: false,
reason: `Event cannot exceed ${this.maxHours} hours`
}
}
return { valid: true }
}
validateMove(original: CalendarEvent, updated: CalendarEvent): ValidationResult {
return this.validatePlacement(updated)
}
validateResize(original: CalendarEvent, updated: CalendarEvent): ValidationResult {
return this.validatePlacement(updated)
}
}
const result = calendar.moveEvent(
'event-id',
new Date('2025-09-01T14:00:00Z'), // New start
new Date('2025-09-01T15:00:00Z') // New end
)
if (result.validation.valid) {
// Apply the change
store.upsert(result.updated)
} else {
console.error(result.validation.reason)
}
// Result includes delta information
console.log(result.delta.minutesShift) // 240
console.log(result.delta.daysShift) // 0
const result = calendar.resizeEvent(
'event-id',
'end', // or 'start'
new Date('2025-09-01T16:00:00Z')
)
if (result.validation.valid) {
store.upsert(result.updated)
}
const newEvent = calendar.createEvent({
title: 'New Meeting',
start: new Date('2025-09-01T10:00:00Z'),
end: new Date('2025-09-01T11:00:00Z'),
color: '#3B82F6'
})
// ID is auto-generated if not provided
store.upsert(newEvent)
const conflicts = calendar.getConflicts({
id: 'new-event',
start: new Date('2025-09-01T10:00:00Z'),
end: new Date('2025-09-01T12:00:00Z')
})
if (conflicts.length > 0) {
console.log('Conflicts with:', conflicts.map(e => e.title))
}
Advanced time calculations and snapping:
import { TimeGrid } from 'calendar-core'
const grid = new TimeGrid({
viewStart: new Date('2025-09-01T00:00:00Z'),
viewEnd: new Date('2025-09-07T23:59:59Z'),
dayStartHour: 8,
dayEndHour: 18,
timeStepMinutes: 15
})
// Get day index (0-based)
const dayIndex = grid.getDayIndex(new Date('2025-09-03T10:00:00Z')) // 2
// Get day boundaries
const { dayStart, dayEnd } = grid.getDayBounds(0)
// Snap to grid
const snapped = grid.snapToGrid(new Date('2025-09-01T10:37:00Z'))
// Returns: 2025-09-01T10:45:00Z (nearest 15-min increment)
// Calculate position
const fraction = grid.fractionWithinDay(
new Date('2025-09-01T13:00:00Z'),
0
) // 0.5 (50% through the day)
import {
startOfUTCDay,
endOfUTCDay,
addDays,
addMinutes,
diffMinutes,
diffDays,
isSameDay,
isWithinRange,
clampDate
} from 'calendar-core'
// Examples
const dayStart = startOfUTCDay(new Date())
const tomorrow = addDays(new Date(), 1)
const minutesDiff = diffMinutes(start, end)
const clamped = clampDate(date, min, max)
class RemoteEventStore implements EventStore {
async getSnapshot(): Promise<EventStoreSnapshot> {
const response = await fetch('/api/events')
const events = await response.json()
return { events }
}
async upsert(event: CalendarEvent): Promise<void> {
await fetch(`/api/events/${event.id}`, {
method: 'PUT',
body: JSON.stringify(event)
})
}
// ... other methods
}
store.upsert({
id: 'holiday',
title: 'Public Holiday',
start: new Date('2025-09-01T00:00:00Z'),
end: new Date('2025-09-02T00:00:00Z'),
allDay: true,
color: '#EF4444'
})
// Events with resources
store.upsertMany([
{ id: '1', title: 'Meeting', resourceId: 'room-a', ... },
{ id: '2', title: 'Workshop', resourceId: 'room-b', ... }
])
// Query by resource
const roomAEvents = store.getByResourceId('room-a')
For large numbers of events:
// Use pixel-based positioning for virtual scrolling
const positioned = engine.layout(events, {
heightStrategy: 'pixels',
pixelsPerMinute: 2,
// Only layout visible days
viewStart: visibleStart,
viewEnd: visibleEnd
})
// Use packing resolver for better space utilization
import { PackingCollisionResolver } from 'calendar-core'
const engine = new DefaultLayoutEngine(new PackingCollisionResolver())
Full TypeScript support with exported types:
import type {
CalendarEvent,
PositionedEvent,
LayoutOptions,
ValidationResult,
ChangeResult,
EventStore,
LayoutEngine,
RuleValidator
} from 'calendar-core'
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Node.js 16+
MIT © Andreas Biederbeck