calendar-core
TypeScript icon, indicating that this package has built-in type declarations

0.3.0-beta • Public • Published

calendar-core

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.

npm version TypeScript License: MIT

Features

  • 📅 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

Installation

npm install calendar-core
# or
yarn add calendar-core
# or
pnpm add calendar-core

Quick Start

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
// }

Core Concepts

CalendarEvent

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
}

PositionedEvent

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
}

Event Store

InMemoryEventStore

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()

Layout Engine

DefaultLayoutEngine

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
})

Collision Resolvers

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)

Validation

Built-in Validators

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

Custom Validators

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)
  }
}

Interactions

Moving Events

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

Resizing Events

const result = calendar.resizeEvent(
  'event-id',
  'end', // or 'start'
  new Date('2025-09-01T16:00:00Z')
)

if (result.validation.valid) {
  store.upsert(result.updated)
}

Creating Events

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)

Conflict Detection

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))
}

Time Grid

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)

Utilities

Date Helpers

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)

Advanced Usage

Custom Event Store

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
}

All-Day Events

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'
})

Resource Views

// 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')

Performance Optimization

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())

TypeScript

Full TypeScript support with exported types:

import type {
  CalendarEvent,
  PositionedEvent,
  LayoutOptions,
  ValidationResult,
  ChangeResult,
  EventStore,
  LayoutEngine,
  RuleValidator
} from 'calendar-core'

Browser Support

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 14+
  • Node.js 16+

License

MIT © Andreas Biederbeck

Package Sidebar

Install

npm i calendar-core

Weekly Downloads

171

Version

0.3.0-beta

License

MIT

Unpacked Size

518 kB

Total Files

15

Last publish

Collaborators

  • biederbe