Calendar
Professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities.
Features
- Multiple View Modes: Date-based (day/week/month) and resource-based (people, rooms) views
- Drag & Drop: Smooth event dragging with snap-to-grid, cross-column movement, and timed/all-day conversion
- Event Resizing: Intuitive resize handles for adjusting event duration
- Offline-First: IndexedDB storage with automatic background sync
- Event-Driven Architecture: Decoupled components via centralized EventBus
- Dependency Injection: Built on NovaDI for clean, testable architecture
- Extensions: Modular extensions for teams, departments, bookings, customers, schedules, and audit logging
Installation
npm install calendar
Quick Start (AI-Friendly Setup Guide)
Step 1: Create HTML Structure
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="node_modules/calendar/dist/css/calendar.css">
</head>
<body>
<div class="calendar-wrapper">
<swp-calendar-container>
<swp-time-axis>
<swp-header-spacer></swp-header-spacer>
<swp-time-axis-content id="time-axis"></swp-time-axis-content>
</swp-time-axis>
<swp-grid-container>
<swp-header-viewport>
<swp-header-track>
<swp-calendar-header></swp-calendar-header>
</swp-header-track>
<swp-header-drawer></swp-header-drawer>
</swp-header-viewport>
<swp-content-viewport>
<swp-content-track>
<swp-scrollable-content>
<swp-time-grid>
<swp-grid-lines></swp-grid-lines>
<swp-day-columns></swp-day-columns>
</swp-time-grid>
</swp-scrollable-content>
</swp-content-track>
</swp-content-viewport>
</swp-grid-container>
</swp-calendar-container>
</div>
<script type="module" src="dist/bundle.js"></script>
</body>
</html>
Step 2: Initialize Calendar
import { Container } from '@novadi/core';
import {
registerCoreServices,
CalendarApp,
IndexedDBContext,
SettingsService,
ViewConfigService,
EventService,
EventBus,
CalendarEvents
} from 'calendar';
async function init() {
// 1. Create DI container and register services
const container = new Container();
const builder = container.builder();
registerCoreServices(builder, {
dbConfig: { dbName: 'MyCalendarDB', dbVersion: 1 }
});
const app = builder.build();
// 2. Initialize IndexedDB
const dbContext = app.resolveType<IndexedDBContext>();
await dbContext.initialize();
// 3. Seed required settings (first time only)
const settingsService = app.resolveType<SettingsService>();
const viewConfigService = app.resolveType<ViewConfigService>();
await settingsService.save({
id: 'grid',
dayStartHour: 8,
dayEndHour: 17,
workStartHour: 9,
workEndHour: 16,
hourHeight: 64,
snapInterval: 15,
syncStatus: 'synced'
});
await settingsService.save({
id: 'workweek',
presets: {
standard: { id: 'standard', label: 'Standard', workDays: [1, 2, 3, 4, 5], periodDays: 7 }
},
defaultPreset: 'standard',
firstDayOfWeek: 1,
syncStatus: 'synced'
});
await viewConfigService.save({
id: 'simple',
groupings: [{ type: 'date', values: [], idProperty: 'date', derivedFrom: 'start' }],
syncStatus: 'synced'
});
// 4. Initialize CalendarApp
const calendarApp = app.resolveType<CalendarApp>();
const containerEl = document.querySelector('swp-calendar-container') as HTMLElement;
await calendarApp.init(containerEl);
// 5. Render a view
const eventBus = app.resolveType<EventBus>();
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
}
init().catch(console.error);
Step 3: Add Events
const eventService = app.resolveType<EventService>();
await eventService.save({
id: crypto.randomUUID(),
title: 'Meeting',
start: new Date('2024-01-15T09:00:00'),
end: new Date('2024-01-15T10:00:00'),
type: 'meeting',
allDay: false,
syncStatus: 'synced'
});
Architecture
Core Components
| Component |
Description |
CalendarApp |
Main application entry point |
CalendarOrchestrator |
Coordinates rendering pipeline |
EventBus |
Central event dispatcher for all inter-component communication |
DateService |
Date calculations and formatting |
IndexedDBContext |
Offline storage infrastructure |
Managers
| Manager |
Description |
DragDropManager |
Event drag-drop with smooth animations and snap-to-grid |
EdgeScrollManager |
Automatic scrolling at viewport edges during drag |
ResizeManager |
Event resizing with visual feedback |
ScrollManager |
Scroll behavior and position management |
HeaderDrawerManager |
All-day events drawer toggle |
EventPersistenceManager |
Saves drag/resize changes to storage |
Renderers
| Renderer |
Description |
DateRenderer |
Renders date-based column groupings |
ResourceRenderer |
Renders resource-based column groupings |
EventRenderer |
Renders timed events in columns |
ScheduleRenderer |
Renders working hours backgrounds |
HeaderDrawerRenderer |
Renders all-day events in header |
TimeAxisRenderer |
Renders time labels on the left axis |
Storage
| Service |
Description |
EventService / EventStore |
Calendar event CRUD |
ResourceService / ResourceStore |
Resource management |
SettingsService / SettingsStore |
Tenant settings |
ViewConfigService / ViewConfigStore |
View configurations |
Events Reference
Lifecycle Events
| Event |
Constant |
Payload |
Description |
core:initialized |
CoreEvents.INITIALIZED |
- |
Calendar core initialized |
core:ready |
CoreEvents.READY |
- |
Calendar ready for interaction |
core:destroyed |
CoreEvents.DESTROYED |
- |
Calendar destroyed |
View Events
| Event |
Constant |
Payload |
Description |
view:changed |
CoreEvents.VIEW_CHANGED |
{ viewId: string } |
View type changed |
view:rendered |
CoreEvents.VIEW_RENDERED |
- |
View finished rendering |
Navigation Events
| Event |
Constant |
Payload |
Description |
nav:date-changed |
CoreEvents.DATE_CHANGED |
{ date: Date } |
Current date changed |
nav:navigation-completed |
CoreEvents.NAVIGATION_COMPLETED |
- |
Navigation animation completed |
Data Events
| Event |
Constant |
Payload |
Description |
data:loading |
CoreEvents.DATA_LOADING |
- |
Data loading started |
data:loaded |
CoreEvents.DATA_LOADED |
- |
Data loading completed |
data:error |
CoreEvents.DATA_ERROR |
{ error: Error } |
Data loading error |
Grid Events
| Event |
Constant |
Payload |
Description |
grid:rendered |
CoreEvents.GRID_RENDERED |
- |
Grid finished rendering |
grid:clicked |
CoreEvents.GRID_CLICKED |
{ time: Date, columnKey: string } |
Grid area clicked |
Event Management
| Event |
Constant |
Payload |
Description |
event:created |
CoreEvents.EVENT_CREATED |
ICalendarEvent |
Event created |
event:updated |
CoreEvents.EVENT_UPDATED |
IEventUpdatedPayload |
Event updated |
event:deleted |
CoreEvents.EVENT_DELETED |
{ eventId: string } |
Event deleted |
event:selected |
CoreEvents.EVENT_SELECTED |
{ eventId: string } |
Event selected |
Drag-Drop Events
| Event |
Constant |
Payload |
Description |
event:drag-start |
CoreEvents.EVENT_DRAG_START |
IDragStartPayload |
Drag started |
event:drag-move |
CoreEvents.EVENT_DRAG_MOVE |
IDragMovePayload |
Dragging (throttled) |
event:drag-end |
CoreEvents.EVENT_DRAG_END |
IDragEndPayload |
Drag completed |
event:drag-cancel |
CoreEvents.EVENT_DRAG_CANCEL |
IDragCancelPayload |
Drag cancelled |
event:drag-column-change |
CoreEvents.EVENT_DRAG_COLUMN_CHANGE |
IDragColumnChangePayload |
Moved to different column |
| Event |
Constant |
Payload |
Description |
event:drag-enter-header |
CoreEvents.EVENT_DRAG_ENTER_HEADER |
IDragEnterHeaderPayload |
Entered header area |
event:drag-move-header |
CoreEvents.EVENT_DRAG_MOVE_HEADER |
IDragMoveHeaderPayload |
Moving in header area |
event:drag-leave-header |
CoreEvents.EVENT_DRAG_LEAVE_HEADER |
IDragLeaveHeaderPayload |
Left header area |
Resize Events
| Event |
Constant |
Payload |
Description |
event:resize-start |
CoreEvents.EVENT_RESIZE_START |
IResizeStartPayload |
Resize started |
event:resize-end |
CoreEvents.EVENT_RESIZE_END |
IResizeEndPayload |
Resize completed |
Edge Scroll Events
| Event |
Constant |
Payload |
Description |
edge-scroll:tick |
CoreEvents.EDGE_SCROLL_TICK |
{ deltaY: number } |
Scroll tick during edge scroll |
edge-scroll:started |
CoreEvents.EDGE_SCROLL_STARTED |
- |
Edge scrolling started |
edge-scroll:stopped |
CoreEvents.EDGE_SCROLL_STOPPED |
- |
Edge scrolling stopped |
Sync Events
| Event |
Constant |
Payload |
Description |
sync:started |
CoreEvents.SYNC_STARTED |
- |
Background sync started |
sync:completed |
CoreEvents.SYNC_COMPLETED |
- |
Background sync completed |
sync:failed |
CoreEvents.SYNC_FAILED |
{ error: Error } |
Background sync failed |
Entity Events
| Event |
Constant |
Payload |
Description |
entity:saved |
CoreEvents.ENTITY_SAVED |
IEntitySavedPayload |
Entity saved to storage |
entity:deleted |
CoreEvents.ENTITY_DELETED |
IEntityDeletedPayload |
Entity deleted from storage |
Audit Events
| Event |
Constant |
Payload |
Description |
audit:logged |
CoreEvents.AUDIT_LOGGED |
IAuditLoggedPayload |
Audit entry logged |
Rendering Events
| Event |
Constant |
Payload |
Description |
events:rendered |
CoreEvents.EVENTS_RENDERED |
- |
Events finished rendering |
System Events
| Event |
Constant |
Payload |
Description |
system:error |
CoreEvents.ERROR |
{ error: Error, context?: string } |
System error occurred |
Command Events (Host to Calendar)
Use these to control the calendar from your application:
import { EventBus, CalendarEvents } from 'calendar';
const eventBus = app.resolveType<EventBus>();
// Navigate
eventBus.emit(CalendarEvents.CMD_NAVIGATE_PREV);
eventBus.emit(CalendarEvents.CMD_NAVIGATE_NEXT);
// Render a view
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
// Toggle header drawer
eventBus.emit(CalendarEvents.CMD_DRAWER_TOGGLE);
// Change workweek preset
eventBus.emit(CalendarEvents.CMD_WORKWEEK_CHANGE, { presetId: 'standard' });
// Update view grouping
eventBus.emit(CalendarEvents.CMD_VIEW_UPDATE, { type: 'resource', values: ['r1', 'r2'] });
| Command |
Constant |
Payload |
Description |
| Navigate Previous |
CalendarEvents.CMD_NAVIGATE_PREV |
- |
Go to previous period |
| Navigate Next |
CalendarEvents.CMD_NAVIGATE_NEXT |
- |
Go to next period |
| Render View |
CalendarEvents.CMD_RENDER |
{ viewId: string } |
Render specified view |
| Toggle Drawer |
CalendarEvents.CMD_DRAWER_TOGGLE |
- |
Toggle all-day drawer |
| Change Workweek |
CalendarEvents.CMD_WORKWEEK_CHANGE |
{ presetId: string } |
Change workweek preset |
| Update View |
CalendarEvents.CMD_VIEW_UPDATE |
{ type: string, values: string[] } |
Update grouping values |
Types
Core Types
// Event types
type CalendarEventType = 'customer' | 'vacation' | 'break' | 'meeting' | 'blocked';
interface ICalendarEvent {
id: string;
title: string;
description?: string;
start: Date;
end: Date;
type: CalendarEventType;
allDay: boolean;
bookingId?: string;
resourceId?: string;
customerId?: string;
recurringId?: string;
syncStatus: SyncStatus;
metadata?: Record<string, unknown>;
}
// Resource types
type ResourceType = 'person' | 'room' | 'equipment' | 'vehicle' | 'custom';
interface IResource {
id: string;
name: string;
displayName: string;
type: ResourceType;
avatarUrl?: string;
color?: string;
isActive?: boolean;
defaultSchedule?: IWeekSchedule;
syncStatus: SyncStatus;
}
// Sync status
type SyncStatus = 'synced' | 'pending' | 'error';
Settings Types
interface IGridSettings {
id: 'grid';
dayStartHour: number;
dayEndHour: number;
workStartHour: number;
workEndHour: number;
hourHeight: number;
snapInterval: number;
}
interface IWorkweekPreset {
id: string;
workDays: number[]; // ISO weekdays: 1=Monday, 7=Sunday
label: string;
periodDays: number; // Navigation step (1=day, 7=week)
}
interface IWeekSchedule {
[day: number]: ITimeSlot | null; // null = off that day
}
interface ITimeSlot {
start: string; // "HH:mm"
end: string; // "HH:mm"
}
Drag-Drop Payloads
interface IDragStartPayload {
eventId: string;
element: HTMLElement;
ghostElement: HTMLElement;
startY: number;
mouseOffset: { x: number; y: number };
columnElement: HTMLElement;
}
interface IDragEndPayload {
swpEvent: SwpEvent;
sourceColumnKey: string;
target: 'grid' | 'header';
}
Extensions
Import extensions separately to keep bundle size minimal:
// Teams extension
import { registerTeams, TeamService, TeamStore, TeamRenderer } from 'calendar/teams';
// Departments extension
import { registerDepartments, DepartmentService, DepartmentStore } from 'calendar/departments';
// Bookings extension
import { registerBookings, BookingService, BookingStore } from 'calendar/bookings';
// Customers extension
import { registerCustomers, CustomerService, CustomerStore } from 'calendar/customers';
// Schedules extension (working hours)
import { registerSchedules, ResourceScheduleService, ScheduleOverrideService } from 'calendar/schedules';
// Audit extension
import { registerAudit, AuditService, AuditStore } from 'calendar/audit';
// Register with container builder
const builder = container.builder();
registerCoreServices(builder);
registerTeams(builder);
registerSchedules(builder);
// ... etc
Configuration
Calendar Options
interface ICalendarOptions {
timeConfig?: ITimeFormatConfig;
gridConfig?: IGridConfig;
dbConfig?: IDBConfig;
}
// Time format configuration
interface ITimeFormatConfig {
timezone: string; // e.g., 'Europe/Copenhagen'
use24HourFormat: boolean;
locale: string; // e.g., 'da-DK'
dateFormat: string;
showSeconds: boolean;
}
// Grid configuration
interface IGridConfig {
hourHeight: number; // Pixels per hour (default: 64)
dayStartHour: number; // Grid start hour (default: 6)
dayEndHour: number; // Grid end hour (default: 18)
snapInterval: number; // Minutes to snap to (default: 15)
gridStartThresholdMinutes: number;
}
// Database configuration
interface IDBConfig {
dbName: string; // IndexedDB database name
dbVersion: number; // Schema version
}
Default Configuration
import {
defaultTimeFormatConfig,
defaultGridConfig,
defaultDBConfig
} from 'calendar';
// Defaults:
// timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
// use24HourFormat: true
// locale: 'da-DK'
// hourHeight: 64
// dayStartHour: 6
// dayEndHour: 18
// snapInterval: 15
Utilities
Position Utilities
import {
calculateEventPosition,
minutesToPixels,
pixelsToMinutes,
snapToGrid
} from 'calendar';
// Convert time to pixels
const pixels = minutesToPixels(120, 64); // 120 mins at 64px/hour = 128px
// Snap to 15-minute grid
const snapped = snapToGrid(new Date(), 15);
Event Layout Engine
import { eventsOverlap, calculateColumnLayout } from 'calendar';
// Check if two events overlap
const overlap = eventsOverlap(event1, event2);
// Calculate layout for overlapping events
const layout = calculateColumnLayout(events);
Listening to Events
import { EventBus, CoreEvents } from 'calendar';
const eventBus = app.resolveType<EventBus>();
// Subscribe to event updates
eventBus.on(CoreEvents.EVENT_UPDATED, (e: Event) => {
const { eventId, sourceColumnKey, targetColumnKey } = (e as CustomEvent).detail;
console.log(`Event ${eventId} moved from ${sourceColumnKey} to ${targetColumnKey}`);
});
// Subscribe to drag events
eventBus.on(CoreEvents.EVENT_DRAG_END, (e: Event) => {
const { swpEvent, target } = (e as CustomEvent).detail;
console.log(`Dropped on ${target}:`, swpEvent);
});
// One-time listener
eventBus.once(CoreEvents.READY, () => {
console.log('Calendar is ready!');
});
CSS Customization
The calendar uses CSS custom properties for theming. Override these in your CSS:
:root {
--calendar-hour-height: 64px;
--calendar-header-height: 48px;
--calendar-column-min-width: 120px;
--calendar-event-border-radius: 4px;
}
Dependencies
@novadi/core - Dependency injection framework (peer dependency)
dayjs - Date manipulation and formatting
License
Proprietary - SWP