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
This commit is contained in:
parent
2c1af56718
commit
6a56396721
5 changed files with 815 additions and 63 deletions
664
docs/calendar-command-system-spec.md
Normal file
664
docs/calendar-command-system-spec.md
Normal file
|
|
@ -0,0 +1,664 @@
|
||||||
|
# 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<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
|
||||||
|
```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<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)
|
||||||
|
```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 |
|
||||||
91
src/v2/core/BaseGroupingRenderer.ts
Normal file
91
src/v2/core/BaseGroupingRenderer.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { IRenderer, IRenderContext } from './IGroupingRenderer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity must have id
|
||||||
|
*/
|
||||||
|
export interface IGroupingEntity {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a grouping renderer
|
||||||
|
*/
|
||||||
|
export interface IGroupingRendererConfig {
|
||||||
|
elementTag: string; // e.g., 'swp-team-header'
|
||||||
|
idAttribute: string; // e.g., 'teamId' -> data-team-id
|
||||||
|
colspanVar: string; // e.g., '--team-cols'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for grouping renderers
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Fetching entities by IDs
|
||||||
|
* - Calculating colspan from parentChildMap
|
||||||
|
* - Creating header elements
|
||||||
|
* - Appending to container
|
||||||
|
*
|
||||||
|
* Subclasses override:
|
||||||
|
* - renderHeader() for custom content
|
||||||
|
* - getDisplayName() for entity display text
|
||||||
|
*/
|
||||||
|
export abstract class BaseGroupingRenderer<T extends IGroupingEntity> implements IRenderer {
|
||||||
|
abstract readonly type: string;
|
||||||
|
protected abstract readonly config: IGroupingRendererConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch entities from service
|
||||||
|
*/
|
||||||
|
protected abstract getEntities(ids: string[]): Promise<T[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for entity
|
||||||
|
*/
|
||||||
|
protected abstract getDisplayName(entity: T): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main render method - handles common logic
|
||||||
|
*/
|
||||||
|
async render(context: IRenderContext): Promise<void> {
|
||||||
|
const allowedIds = context.filter[this.type] || [];
|
||||||
|
if (allowedIds.length === 0) return;
|
||||||
|
|
||||||
|
const entities = await this.getEntities(allowedIds);
|
||||||
|
const dateCount = context.filter['date']?.length || 1;
|
||||||
|
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const entityChildIds = context.parentChildMap?.[entity.id] || [];
|
||||||
|
const childCount = entityChildIds.filter(id => childIds.includes(id)).length;
|
||||||
|
const colspan = childCount * dateCount;
|
||||||
|
|
||||||
|
const header = document.createElement(this.config.elementTag);
|
||||||
|
header.dataset[this.config.idAttribute] = entity.id;
|
||||||
|
header.style.setProperty(this.config.colspanVar, String(colspan));
|
||||||
|
|
||||||
|
// Allow subclass to customize header content
|
||||||
|
this.renderHeader(entity, header, context);
|
||||||
|
|
||||||
|
context.headerContainer.appendChild(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override this method for custom header rendering
|
||||||
|
* Default: just sets textContent to display name
|
||||||
|
*/
|
||||||
|
protected renderHeader(entity: T, header: HTMLElement, _context: IRenderContext): void {
|
||||||
|
header.textContent = this.getDisplayName(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to render a single entity header.
|
||||||
|
* Can be used by subclasses that override render() but want consistent header creation.
|
||||||
|
*/
|
||||||
|
protected createHeader(entity: T, context: IRenderContext): HTMLElement {
|
||||||
|
const header = document.createElement(this.config.elementTag);
|
||||||
|
header.dataset[this.config.idAttribute] = entity.id;
|
||||||
|
this.renderHeader(entity, header, context);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,25 @@
|
||||||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||||
import { DepartmentService } from '../../storage/departments/DepartmentService';
|
import { DepartmentService } from '../../storage/departments/DepartmentService';
|
||||||
|
import { IDepartment } from '../../types/CalendarTypes';
|
||||||
|
|
||||||
export class DepartmentRenderer implements IRenderer {
|
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
|
||||||
readonly type = 'department';
|
readonly type = 'department';
|
||||||
|
|
||||||
constructor(private departmentService: DepartmentService) {}
|
protected readonly config: IGroupingRendererConfig = {
|
||||||
|
elementTag: 'swp-department-header',
|
||||||
|
idAttribute: 'departmentId',
|
||||||
|
colspanVar: '--department-cols'
|
||||||
|
};
|
||||||
|
|
||||||
async render(context: IRenderContext): Promise<void> {
|
constructor(private departmentService: DepartmentService) {
|
||||||
const allowedIds = context.filter[this.type] || [];
|
super();
|
||||||
if (allowedIds.length === 0) return;
|
|
||||||
|
|
||||||
// Fetch departments from IndexedDB (only for name display)
|
|
||||||
const departments = await this.departmentService.getByIds(allowedIds);
|
|
||||||
|
|
||||||
const dateCount = context.filter['date']?.length || 1;
|
|
||||||
|
|
||||||
// Get child filter values using childType from context (not hardcoded)
|
|
||||||
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
|
||||||
|
|
||||||
// Render department headers
|
|
||||||
for (const dept of departments) {
|
|
||||||
// Get children from parentChildMap (resolved from belongsTo config)
|
|
||||||
const deptChildIds = context.parentChildMap?.[dept.id] || [];
|
|
||||||
|
|
||||||
// Count children that belong to this department AND are in the filter
|
|
||||||
const childCount = deptChildIds.filter(id => childIds.includes(id)).length;
|
|
||||||
const colspan = childCount * dateCount;
|
|
||||||
|
|
||||||
const header = document.createElement('swp-department-header');
|
|
||||||
header.dataset.departmentId = dept.id;
|
|
||||||
header.textContent = dept.name;
|
|
||||||
header.style.setProperty('--department-cols', String(colspan));
|
|
||||||
context.headerContainer.appendChild(header);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getEntities(ids: string[]): Promise<IDepartment[]> {
|
||||||
|
return this.departmentService.getByIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDisplayName(entity: IDepartment): string {
|
||||||
|
return entity.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,34 @@
|
||||||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
import { IRenderContext } from '../../core/IGroupingRenderer';
|
||||||
|
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||||
import { ResourceService } from '../../storage/resources/ResourceService';
|
import { ResourceService } from '../../storage/resources/ResourceService';
|
||||||
|
import { IResource } from '../../types/CalendarTypes';
|
||||||
|
|
||||||
export class ResourceRenderer implements IRenderer {
|
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
|
||||||
readonly type = 'resource';
|
readonly type = 'resource';
|
||||||
|
|
||||||
constructor(private resourceService: ResourceService) {}
|
protected readonly config: IGroupingRendererConfig = {
|
||||||
|
elementTag: 'swp-resource-header',
|
||||||
|
idAttribute: 'resourceId',
|
||||||
|
colspanVar: '--resource-cols'
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private resourceService: ResourceService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getEntities(ids: string[]): Promise<IResource[]> {
|
||||||
|
return this.resourceService.getByIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDisplayName(entity: IResource): string {
|
||||||
|
return entity.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override render to handle:
|
||||||
|
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
|
||||||
|
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
|
||||||
|
*/
|
||||||
async render(context: IRenderContext): Promise<void> {
|
async render(context: IRenderContext): Promise<void> {
|
||||||
const resourceIds = context.filter['resource'] || [];
|
const resourceIds = context.filter['resource'] || [];
|
||||||
const dateCount = context.filter['date']?.length || 1;
|
const dateCount = context.filter['date']?.length || 1;
|
||||||
|
|
@ -29,7 +52,7 @@ export class ResourceRenderer implements IRenderer {
|
||||||
orderedResourceIds = resourceIds;
|
orderedResourceIds = resourceIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resources = await this.resourceService.getByIds(orderedResourceIds);
|
const resources = await this.getEntities(orderedResourceIds);
|
||||||
|
|
||||||
// Create a map for quick lookup to preserve order
|
// Create a map for quick lookup to preserve order
|
||||||
const resourceMap = new Map(resources.map(r => [r.id, r]));
|
const resourceMap = new Map(resources.map(r => [r.id, r]));
|
||||||
|
|
@ -38,9 +61,7 @@ export class ResourceRenderer implements IRenderer {
|
||||||
const resource = resourceMap.get(resourceId);
|
const resource = resourceMap.get(resourceId);
|
||||||
if (!resource) continue;
|
if (!resource) continue;
|
||||||
|
|
||||||
const header = document.createElement('swp-resource-header');
|
const header = this.createHeader(resource, context);
|
||||||
header.dataset.resourceId = resource.id;
|
|
||||||
header.textContent = resource.displayName;
|
|
||||||
header.style.gridColumn = `span ${dateCount}`;
|
header.style.gridColumn = `span ${dateCount}`;
|
||||||
context.headerContainer.appendChild(header);
|
context.headerContainer.appendChild(header);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,25 @@
|
||||||
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
|
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
|
||||||
import { TeamService } from '../../storage/teams/TeamService';
|
import { TeamService } from '../../storage/teams/TeamService';
|
||||||
|
import { ITeam } from '../../types/CalendarTypes';
|
||||||
|
|
||||||
export class TeamRenderer implements IRenderer {
|
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
|
||||||
readonly type = 'team';
|
readonly type = 'team';
|
||||||
|
|
||||||
constructor(private teamService: TeamService) {}
|
protected readonly config: IGroupingRendererConfig = {
|
||||||
|
elementTag: 'swp-team-header',
|
||||||
|
idAttribute: 'teamId',
|
||||||
|
colspanVar: '--team-cols'
|
||||||
|
};
|
||||||
|
|
||||||
async render(context: IRenderContext): Promise<void> {
|
constructor(private teamService: TeamService) {
|
||||||
const allowedIds = context.filter[this.type] || [];
|
super();
|
||||||
if (allowedIds.length === 0) return;
|
|
||||||
|
|
||||||
// Fetch teams from IndexedDB (only for name display)
|
|
||||||
const teams = await this.teamService.getByIds(allowedIds);
|
|
||||||
|
|
||||||
const dateCount = context.filter['date']?.length || 1;
|
|
||||||
|
|
||||||
// Get child filter values using childType from context (not hardcoded)
|
|
||||||
const childIds = context.childType ? context.filter[context.childType] || [] : [];
|
|
||||||
|
|
||||||
// Render team headers
|
|
||||||
for (const team of teams) {
|
|
||||||
// Get children from parentChildMap (resolved from belongsTo config)
|
|
||||||
const teamChildIds = context.parentChildMap?.[team.id] || [];
|
|
||||||
|
|
||||||
// Count children that belong to this team AND are in the filter
|
|
||||||
const childCount = teamChildIds.filter(id => childIds.includes(id)).length;
|
|
||||||
const colspan = childCount * dateCount;
|
|
||||||
|
|
||||||
const header = document.createElement('swp-team-header');
|
|
||||||
header.dataset.teamId = team.id;
|
|
||||||
header.textContent = team.name;
|
|
||||||
header.style.setProperty('--team-cols', String(colspan));
|
|
||||||
context.headerContainer.appendChild(header);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getEntities(ids: string[]): Promise<ITeam[]> {
|
||||||
|
return this.teamService.getByIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDisplayName(entity: ITeam): string {
|
||||||
|
return entity.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue