# 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 ```bash npm install calendar ``` ## Quick Start (AI-Friendly Setup Guide) ### Step 1: Create HTML Structure ```html
``` ### Step 2: Initialize Calendar ```typescript 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(); await dbContext.initialize(); // 3. Seed required settings (first time only) const settingsService = app.resolveType(); const viewConfigService = app.resolveType(); 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(); const containerEl = document.querySelector('swp-calendar-container') as HTMLElement; await calendarApp.init(containerEl); // 5. Render a view const eventBus = app.resolveType(); eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' }); } init().catch(console.error); ``` ### Step 3: Add Events ```typescript const eventService = app.resolveType(); 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 | ### Header Drag Events (Timed to All-Day Conversion) | 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: ```typescript import { EventBus, CalendarEvents } from 'calendar'; const eventBus = app.resolveType(); // 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 ```typescript // 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; } // 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 ```typescript 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 ```typescript 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: ```typescript // 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript import { EventBus, CoreEvents } from 'calendar'; const eventBus = app.resolveType(); // 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: ```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