From 19414ea5e7aeaedb68ffa0e66e21f12daf8a505b Mon Sep 17 00:00:00 2001 From: Janus007 Date: Tue, 3 Feb 2026 00:08:23 +0100 Subject: [PATCH] Add ReadMe --- ReadMe.md | 620 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 ReadMe.md diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..5fecd34 --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,620 @@ +# 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