Calendar/packages/calendar/README.md
Janus C. H. Knudsen 7db22245e2 Adds comprehensive README for calendar package
Introduces detailed documentation for calendar library, covering:
- Core features and architecture
- Installation and setup guide
- Event system reference
- Types and configuration options
- Extensibility and utilities

Prepares for initial library release with thorough documentation
2026-02-02 23:34:17 +01:00

620 lines
18 KiB
Markdown

# 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
<!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
```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<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
```typescript
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 |
### 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<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
```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<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
```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<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:
```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