Calendar/docs/calendar-command-system-spec.md
Janus C. H. Knudsen 6a56396721 Introduces CalendarApp with centralized event-driven rendering
Refactors calendar component initialization to a single, encapsulated entry point

Simplifies host application integration by:
- Centralizing complex setup in CalendarApp
- Implementing command-driven rendering via custom events
- Providing flexible, zero-knowledge calendar component
- Maintaining existing ViewConfig contract
2025-12-16 07:35:29 +01:00

29 KiB

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:

// 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):

{
  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:

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

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

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

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

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

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<CalendarApp> {
    // Create DI container
    const diContainer = createV2Container();

    // Resolve dependencies
    const indexedDB = diContainer.resolve<IndexedDBContext>();
    const dataSeeder = diContainer.resolve<DataSeeder>();
    const orchestrator = diContainer.resolve<CalendarOrchestrator>();
    const eventBus = diContainer.resolve<IEventBus>();

    // Initialize IndexedDB
    await indexedDB.initialize();
    await dataSeeder.seedIfEmpty();

    // Initialize managers
    const scrollManager = diContainer.resolve<ScrollManager>();
    const dragDropManager = diContainer.resolve<DragDropManager>();
    const edgeScrollManager = diContainer.resolve<EdgeScrollManager>();
    const resizeManager = diContainer.resolve<ResizeManager>();
    const headerDrawerManager = diContainer.resolve<HeaderDrawerManager>();
    const timeAxisRenderer = diContainer.resolve<TimeAxisRenderer>();

    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<void> => {
    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

// ═══════════════════════════════════════════════════════════════
// 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)

/**
 * 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<void> {
    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)

// 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)

// 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)

// 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