# Specification: Calendar Command Event System ## 1. Overview ### 1.1 Purpose Gør kalenderen til en genbrugelig komponent der: 1. Initialiseres med **én linje kode** 2. Modtager **ViewConfig** via command event 3. Håndterer al intern setup (scroll, drag-drop, resize, etc.) ### 1.2 Design Principles | Principle | Description | |-----------|-------------| | **Encapsulated** | Al init pakket ind i CalendarApp | | **ViewConfig-driven** | Host sender ViewConfig - kalender renderer | | **Single command** | Kun `calendar:cmd:render` | | **Zero knowledge** | Kalenderen ved ikke hvad den renderer | ### 1.3 Ansvarsfordeling ``` ┌─────────────────────────────────────────────────────────────────┐ │ HOST APPLICATION │ │ │ │ // Init (én gang) │ │ const calendar = await CalendarApp.create(container, { │ │ hourHeight: 64, │ │ dayStartHour: 6, │ │ dayEndHour: 18 │ │ }); │ │ │ │ // Render (når som helst) │ │ document.dispatchEvent(new CustomEvent('calendar:cmd:render', │ │ { detail: { viewConfig, animation: 'left' } } │ │ )); │ │ │ │ Ansvar: │ │ - Bygge ViewConfig │ │ - Sende render command │ │ - Lytte på status events │ └─────────────────────────────────────────────────────────────────┘ │ calendar:cmd:render + ViewConfig │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ CALENDAR APP (Black Box) │ │ │ │ Pakker ind: │ │ - IndexedDB init │ │ - DataSeeder │ │ - ScrollManager │ │ - DragDropManager │ │ - EdgeScrollManager │ │ - ResizeManager │ │ - HeaderDrawerManager │ │ - TimeAxisRenderer │ │ - NavigationAnimator │ │ - CalendarOrchestrator │ │ - Command event listeners │ │ │ │ Host behøver IKKE vide om disse │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## 2. ViewConfig (Eksisterende - Uændret) Vi genbruger den eksisterende ViewConfig fra `src/v2/core/ViewConfig.ts`: ```typescript // EKSISTERENDE - INGEN ÆNDRINGER export interface ViewConfig { templateId: string; groupings: GroupingConfig[]; } export interface GroupingConfig { type: string; values: string[]; idProperty?: string; derivedFrom?: string; belongsTo?: string; hideHeader?: boolean; } ``` **Eksempel ViewConfig (som DemoApp allerede bygger):** ```typescript { templateId: 'team', groupings: [ { type: 'team', values: ['team1', 'team2'] }, { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' }, { type: 'date', values: ['2025-12-08', '2025-12-09', ...], idProperty: 'date', derivedFrom: 'start' } ] } ``` --- ## 3. Event Contract ### 3.1 Command Events (Inbound) | Event Name | Payload | Beskrivelse | |------------|---------|-------------| | `calendar:cmd:render` | `{ viewConfig, animation? }` | Render med ViewConfig | **Payload interface:** ```typescript interface IRenderCommandPayload { viewConfig: ViewConfig; animation?: 'left' | 'right'; // Optional slide animation } ``` ### 3.2 Status Events (Outbound) | Event Name | Payload | Beskrivelse | |------------|---------|-------------| | `calendar:status:ready` | `{}` | Kalender klar til commands | | `calendar:status:rendered` | `{ templateId }` | Rendering færdig | | `calendar:status:error` | `{ message, code }` | Fejl | ### 3.3 Error Codes ```typescript type CommandErrorCode = | 'INVALID_PAYLOAD' // ViewConfig mangler eller ugyldig | 'ANIMATION_IN_PROGRESS' // Render afvist pga. igangværende animation | 'RENDER_FAILED'; // CalendarOrchestrator fejlede ``` --- ## 4. Sequence Diagrams ### 4.1 Render Flow (med animation) ``` ┌──────────┐ ┌──────────┐ ┌─────────────────┐ ┌────────────┐ ┌──────────────┐ │ Host App │ │ document │ │ CalendarApp │ │ Animator │ │ Orchestrator │ └────┬─────┘ └────┬─────┘ └───────┬─────────┘ └─────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ // Host bygger │ │ │ │ │ // ViewConfig │ │ │ │ │ weekOffset++; │ │ │ │ │ viewConfig = │ │ │ │ │ buildViewConfig()│ │ │ │ │ │ │ │ │ │ dispatchEvent │ │ │ │ │ ('calendar:cmd: │ │ │ │ │ render', { │ │ │ │ │ viewConfig, │ │ │ │ │ animation:'left'})│ │ │ │ │────────────────────>│ │ │ │ │ │ │ │ │ │ │ CustomEvent │ │ │ │ │───────────────────────>│ │ │ │ │ │ │ │ │ │ │ validate payload │ │ │ │ │──────────┐ │ │ │ │ │<─────────┘ │ │ │ │ │ │ │ │ │ │ slide('left', () => { │ │ │ │ │─────────────────────────>│ │ │ │ │ │ │ │ │ │ │ animateOut() │ │ │ │ │─────────┐ │ │ │ │ │<────────┘ │ │ │ │ │ │ │ │ │ renderCallback() │ │ │ │ │<─────────────────────────│ │ │ │ │ │ │ │ │ │ render(viewConfig) │ │ │ │ │─────────────────────────────────────────────────>│ │ │ │ │ │ │ │ │ │ │ DOM │ │ │ │ │─────┐ │ │ │ │ │<────┘ │ │ │ │ │ │ │ │ │ animateIn() │ │ │ │ │─────────┐ │ │ │ │ │<────────┘ │ │ │ │ │ │ │ 'calendar:status: │ │ │ │ │ rendered' │ │ │ │ │<────────────────────│────────────────────────│ │ │ │ │ │ │ │ ``` ### 4.2 Render Flow (uden animation) ``` ┌──────────┐ ┌──────────┐ ┌─────────────────┐ ┌──────────────┐ │ Host App │ │ document │ │ CalendarApp │ │ Orchestrator │ └────┬─────┘ └────┬─────┘ └───────┬─────────┘ └──────┬───────┘ │ │ │ │ │ dispatchEvent │ │ │ │ ('calendar:cmd: │ │ │ │ render', { │ │ │ │ viewConfig }) │ │ │ │────────────────────>│ │ │ │ │ │ │ │ │ CustomEvent │ │ │ │───────────────────────>│ │ │ │ │ │ │ │ │ render(viewConfig) │ │ │ │──────────────────────────>│ │ │ │ │ │ │ │ │ DOM │ │ │ │─────┐ │ │ │ │<────┘ │ │ │ │ │ 'calendar:status: │ │ │ │ rendered' │ │ │ │<────────────────────│───────────────────────│ │ │ │ │ │ ``` ### 4.3 Error Flow ``` ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ Host App │ │ document │ │ CalendarApp │ └────┬─────┘ └────┬─────┘ └───────┬─────────┘ │ │ │ │ dispatchEvent │ │ │ ('calendar:cmd: │ │ │ render', {}) │ │ │ // Missing │ │ │ // viewConfig │ │ │────────────────────>│ │ │ │ │ │ │ CustomEvent │ │ │───────────────────────>│ │ │ │ │ │ │ validate: no viewConfig │ │ │──────────┐ │ │ │<─────────┘ │ │ │ │ 'calendar:status: │ │ │ error' │ │ │ { code: │ │ │ 'INVALID_PAYLOAD'}│ │ │<────────────────────│────────────────────────│ │ │ │ ``` --- ## 5. Type Definitions ### 5.1 CommandTypes.ts ```typescript import { ViewConfig } from '../core/ViewConfig'; // ───────────────────────────────────────────────────────────── // COMMAND PAYLOAD // ───────────────────────────────────────────────────────────── export interface IRenderCommandPayload { viewConfig: ViewConfig; animation?: 'left' | 'right'; } // ───────────────────────────────────────────────────────────── // STATUS PAYLOADS // ───────────────────────────────────────────────────────────── export interface IReadyStatusPayload { // Empty - just signals ready } export interface IRenderedStatusPayload { templateId: string; } export interface IErrorStatusPayload { message: string; code: CommandErrorCode; } export type CommandErrorCode = | 'INVALID_PAYLOAD' | 'ANIMATION_IN_PROGRESS' | 'RENDER_FAILED'; ``` ### 5.2 CommandEvents.ts ```typescript export const CommandEvents = { // Command (inbound) RENDER: 'calendar:cmd:render', // Status (outbound) READY: 'calendar:status:ready', RENDERED: 'calendar:status:rendered', ERROR: 'calendar:status:error', } as const; ``` --- ## 6. Implementation ### 6.1 New Files | File | Description | |------|-------------| | `src/v2/CalendarApp.ts` | **Hovedentry** - pakker alt init ind | | `src/v2/types/CommandTypes.ts` | Payload interfaces | | `src/v2/constants/CommandEvents.ts` | Event constants | ### 6.2 Modified Files | File | Changes | |------|---------| | `src/v2/demo/DemoApp.ts` | Simplificeret - bruger CalendarApp + emit events | ### 6.3 CalendarAppOptions Interface ```typescript export interface ICalendarAppOptions { // Grid settings hourHeight?: number; // Default: 64 dayStartHour?: number; // Default: 6 dayEndHour?: number; // Default: 18 snapInterval?: number; // Default: 15 // Features (toggle on/off) enableDragDrop?: boolean; // Default: true enableResize?: boolean; // Default: true enableHeaderDrawer?: boolean; // Default: true } ``` ### 6.4 CalendarApp Class ```typescript import { createV2Container } from './V2CompositionRoot'; import { CommandEvents } from './constants/CommandEvents'; import { ViewConfig } from './core/ViewConfig'; /** * CalendarApp - Single entry point for calendar component * * Pakker al init ind så host-app kun skal: * 1. CalendarApp.create(container, options) * 2. dispatchEvent('calendar:cmd:render', { viewConfig }) */ export class CalendarApp { private isAnimating = false; private constructor( private container: HTMLElement, private orchestrator: CalendarOrchestrator, private animator: NavigationAnimator, private eventBus: IEventBus ) { this.setupCommandListeners(); } /** * Factory method - async init */ static async create( container: HTMLElement, options: ICalendarAppOptions = {} ): Promise { // Create DI container const diContainer = createV2Container(); // Resolve dependencies const indexedDB = diContainer.resolve(); const dataSeeder = diContainer.resolve(); const orchestrator = diContainer.resolve(); const eventBus = diContainer.resolve(); // Initialize IndexedDB await indexedDB.initialize(); await dataSeeder.seedIfEmpty(); // Initialize managers const scrollManager = diContainer.resolve(); const dragDropManager = diContainer.resolve(); const edgeScrollManager = diContainer.resolve(); const resizeManager = diContainer.resolve(); const headerDrawerManager = diContainer.resolve(); const timeAxisRenderer = diContainer.resolve(); scrollManager.init(container); if (options.enableDragDrop !== false) dragDropManager.init(container); if (options.enableResize !== false) resizeManager.init(container); if (options.enableHeaderDrawer !== false) headerDrawerManager.init(container); // Render time axis const startHour = options.dayStartHour ?? 6; const endHour = options.dayEndHour ?? 18; const timeAxisEl = container.querySelector('#time-axis') as HTMLElement; if (timeAxisEl) timeAxisRenderer.render(timeAxisEl, startHour, endHour); // Edge scroll const scrollableContent = container.querySelector('swp-scrollable-content') as HTMLElement; if (scrollableContent) edgeScrollManager.init(scrollableContent); // Create animator const headerTrack = document.querySelector('swp-header-track') as HTMLElement; const contentTrack = document.querySelector('swp-content-track') as HTMLElement; const animator = new NavigationAnimator(headerTrack, contentTrack); // Create app instance const app = new CalendarApp(container, orchestrator, animator, eventBus); // Emit ready eventBus.emit(CommandEvents.READY, {}); return app; } /** * Setup command event listeners */ private setupCommandListeners(): void { this.eventBus.on(CommandEvents.RENDER, this.handleRender); } /** * Handle render command */ private handleRender = async (e: Event): Promise => { const { viewConfig, animation } = (e as CustomEvent).detail || {}; if (!viewConfig) { this.eventBus.emit(CommandEvents.ERROR, { message: 'viewConfig is required', code: 'INVALID_PAYLOAD' }); return; } if (this.isAnimating) { this.eventBus.emit(CommandEvents.ERROR, { message: 'Animation in progress', code: 'ANIMATION_IN_PROGRESS' }); return; } try { if (animation) { this.isAnimating = true; await this.animator.slide(animation, async () => { await this.orchestrator.render(viewConfig, this.container); }); this.isAnimating = false; } else { await this.orchestrator.render(viewConfig, this.container); } this.eventBus.emit(CommandEvents.RENDERED, { templateId: viewConfig.templateId }); } catch (err) { this.eventBus.emit(CommandEvents.ERROR, { message: (err as Error).message, code: 'RENDER_FAILED' }); } }; } ``` --- ## 7. Usage Examples ### 7.1 Minimal Host Application ```typescript // ═══════════════════════════════════════════════════════════════ // INIT - Én gang // ═══════════════════════════════════════════════════════════════ const container = document.querySelector('swp-calendar-container') as HTMLElement; const calendar = await CalendarApp.create(container, { hourHeight: 64, dayStartHour: 6, dayEndHour: 18 }); // ═══════════════════════════════════════════════════════════════ // RENDER - Når som helst // ═══════════════════════════════════════════════════════════════ document.dispatchEvent(new CustomEvent('calendar:cmd:render', { detail: { viewConfig: { templateId: 'simple', groupings: [ { type: 'date', values: ['2025-12-08', '2025-12-09', '2025-12-10'], idProperty: 'date', derivedFrom: 'start' } ] } } })); ``` ### 7.2 DemoApp (Simplificeret) ```typescript /** * DemoApp - Nu kun ansvarlig for: * - State (weekOffset, currentView, selectedResources) * - UI event handlers * - Bygge ViewConfig * - Emit render commands */ export class DemoApp { private weekOffset = 0; private currentView = 'simple'; private selectedResourceIds: string[] = []; async init(): Promise { const container = document.querySelector('swp-calendar-container') as HTMLElement; // Init kalender - ALT pakket ind await CalendarApp.create(container, { dayStartHour: 6, dayEndHour: 18 }); // Setup UI this.setupNavigation(); this.setupViewSwitching(); // Initial render this.render(); } private render(animation?: 'left' | 'right'): void { const viewConfig = this.buildViewConfig(); document.dispatchEvent(new CustomEvent('calendar:cmd:render', { detail: { viewConfig, animation } })); } private setupNavigation(): void { document.getElementById('btn-prev')!.onclick = () => { this.weekOffset--; this.render('right'); }; document.getElementById('btn-next')!.onclick = () => { this.weekOffset++; this.render('left'); }; } private setupViewSwitching(): void { document.querySelectorAll('.view-chip').forEach(chip => { chip.addEventListener('click', () => { this.currentView = (chip as HTMLElement).dataset.view!; this.render(); // Ingen animation ved view change }); }); } // buildViewConfig() - Uændret fra nuværende implementation private buildViewConfig(): ViewConfig { const dates = this.dateService.getWorkWeekDates(this.weekOffset, [1,2,3,4,5]); switch (this.currentView) { case 'simple': return { templateId: 'simple', groupings: [ { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } ] }; // ... andre views ... } } } ``` ### 7.3 Ekstern App (uden DemoApp) ```typescript // Enhver ekstern app kan styre kalenderen import { CalendarApp } from '@swp/calendar'; // Init const calendar = await CalendarApp.create( document.getElementById('my-calendar'), { hourHeight: 80 } ); // Listen for ready document.addEventListener('calendar:status:ready', () => { console.log('Calendar ready!'); }); // Render team view document.dispatchEvent(new CustomEvent('calendar:cmd:render', { detail: { viewConfig: { templateId: 'team', groupings: [ { type: 'team', values: ['sales', 'support'] }, { type: 'resource', values: ['john', 'jane', 'bob'], idProperty: 'resourceId', belongsTo: 'team.members' }, { type: 'date', values: ['2025-12-15', '2025-12-16'], idProperty: 'date', derivedFrom: 'start' } ] }, animation: 'left' } })); ``` --- ## 8. Implementation Plan | Phase | Tasks | Files | |-------|-------|-------| | **1** | Create types | `src/v2/types/CommandTypes.ts` | | **2** | Create events | `src/v2/constants/CommandEvents.ts` | | **3** | Create CalendarApp | `src/v2/CalendarApp.ts` | | **4** | Simplify DemoApp | `src/v2/demo/DemoApp.ts` | | **5** | Test | Manual testing | --- ## 9. Summary ### Før (nuværende DemoApp) ```typescript // DemoApp.init() - 100+ linjer setup await indexedDB.initialize(); await dataSeeder.seedIfEmpty(); scrollManager.init(container); dragDropManager.init(container); resizeManager.init(container); headerDrawerManager.init(container); timeAxisRenderer.render(...); edgeScrollManager.init(...); // ... og mere // Render await orchestrator.render(viewConfig, container); ``` ### Efter (med CalendarApp) ```typescript // Init - én linje await CalendarApp.create(container, { dayStartHour: 6, dayEndHour: 18 }); // Render - command event document.dispatchEvent(new CustomEvent('calendar:cmd:render', { detail: { viewConfig, animation: 'left' } })); ``` ### Fordele | Aspekt | Før | Efter | |--------|-----|-------| | Init kompleksitet | 100+ linjer | 1 linje | | Kalender viden | Host kender alle managers | Host kender kun ViewConfig | | Genbrugelighed | Svær - tæt koblet | Nem - løs koblet | | ViewConfig | Uændret | Uændret | | Ekstern kontrol | Ikke mulig | Via events |