diff --git a/docs/calendar-command-system-spec.md b/docs/calendar-command-system-spec.md new file mode 100644 index 0000000..2314274 --- /dev/null +++ b/docs/calendar-command-system-spec.md @@ -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 { + // 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 | diff --git a/src/v2/core/BaseGroupingRenderer.ts b/src/v2/core/BaseGroupingRenderer.ts new file mode 100644 index 0000000..60c9abf --- /dev/null +++ b/src/v2/core/BaseGroupingRenderer.ts @@ -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 implements IRenderer { + abstract readonly type: string; + protected abstract readonly config: IGroupingRendererConfig; + + /** + * Fetch entities from service + */ + protected abstract getEntities(ids: string[]): Promise; + + /** + * Get display name for entity + */ + protected abstract getDisplayName(entity: T): string; + + /** + * Main render method - handles common logic + */ + async render(context: IRenderContext): Promise { + 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; + } +} diff --git a/src/v2/features/department/DepartmentRenderer.ts b/src/v2/features/department/DepartmentRenderer.ts index c05b2a8..90e8eca 100644 --- a/src/v2/features/department/DepartmentRenderer.ts +++ b/src/v2/features/department/DepartmentRenderer.ts @@ -1,37 +1,25 @@ -import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer'; +import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer'; import { DepartmentService } from '../../storage/departments/DepartmentService'; +import { IDepartment } from '../../types/CalendarTypes'; -export class DepartmentRenderer implements IRenderer { +export class DepartmentRenderer extends BaseGroupingRenderer { 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 { - const allowedIds = context.filter[this.type] || []; - if (allowedIds.length === 0) return; + constructor(private departmentService: DepartmentService) { + super(); + } - // Fetch departments from IndexedDB (only for name display) - const departments = await this.departmentService.getByIds(allowedIds); + protected getEntities(ids: string[]): Promise { + return this.departmentService.getByIds(ids); + } - 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 getDisplayName(entity: IDepartment): string { + return entity.name; } } diff --git a/src/v2/features/resource/ResourceRenderer.ts b/src/v2/features/resource/ResourceRenderer.ts index 72d503d..2bf565f 100644 --- a/src/v2/features/resource/ResourceRenderer.ts +++ b/src/v2/features/resource/ResourceRenderer.ts @@ -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 { IResource } from '../../types/CalendarTypes'; -export class ResourceRenderer implements IRenderer { +export class ResourceRenderer extends BaseGroupingRenderer { 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 { + 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 { const resourceIds = context.filter['resource'] || []; const dateCount = context.filter['date']?.length || 1; @@ -29,7 +52,7 @@ export class ResourceRenderer implements IRenderer { orderedResourceIds = resourceIds; } - const resources = await this.resourceService.getByIds(orderedResourceIds); + const resources = await this.getEntities(orderedResourceIds); // Create a map for quick lookup to preserve order const resourceMap = new Map(resources.map(r => [r.id, r])); @@ -38,9 +61,7 @@ export class ResourceRenderer implements IRenderer { const resource = resourceMap.get(resourceId); if (!resource) continue; - const header = document.createElement('swp-resource-header'); - header.dataset.resourceId = resource.id; - header.textContent = resource.displayName; + const header = this.createHeader(resource, context); header.style.gridColumn = `span ${dateCount}`; context.headerContainer.appendChild(header); } diff --git a/src/v2/features/team/TeamRenderer.ts b/src/v2/features/team/TeamRenderer.ts index 26691f3..c042b45 100644 --- a/src/v2/features/team/TeamRenderer.ts +++ b/src/v2/features/team/TeamRenderer.ts @@ -1,37 +1,25 @@ -import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer'; +import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer'; import { TeamService } from '../../storage/teams/TeamService'; +import { ITeam } from '../../types/CalendarTypes'; -export class TeamRenderer implements IRenderer { +export class TeamRenderer extends BaseGroupingRenderer { 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 { - const allowedIds = context.filter[this.type] || []; - if (allowedIds.length === 0) return; + constructor(private teamService: TeamService) { + super(); + } - // Fetch teams from IndexedDB (only for name display) - const teams = await this.teamService.getByIds(allowedIds); + protected getEntities(ids: string[]): Promise { + return this.teamService.getByIds(ids); + } - 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 getDisplayName(entity: ITeam): string { + return entity.name; } }