From 7f9d0129bfecacb6e7bcdf414777f69aecab5275 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Tue, 16 Dec 2025 17:13:27 +0100 Subject: [PATCH] Refactors calendar view configuration management Decouples view configuration from DemoApp logic by: - Introducing ViewConfigService and MockViewConfigRepository - Moving view configuration to centralized JSON data - Simplifying DemoApp rendering process Improves separation of concerns and makes view configurations more maintainable --- docs/calendar-command-system-spec.md | 686 ++---------------- src/v2/V2CompositionRoot.ts | 12 + src/v2/core/ViewConfig.ts | 6 +- src/v2/demo/DemoApp.ts | 105 +-- .../repositories/MockViewConfigRepository.ts | 41 ++ .../storage/viewconfigs/ViewConfigService.ts | 18 + src/v2/storage/viewconfigs/ViewConfigStore.ts | 10 + src/v2/types/CalendarTypes.ts | 2 +- wwwroot/data/viewconfigs.json | 45 ++ 9 files changed, 217 insertions(+), 708 deletions(-) create mode 100644 src/v2/repositories/MockViewConfigRepository.ts create mode 100644 src/v2/storage/viewconfigs/ViewConfigService.ts create mode 100644 src/v2/storage/viewconfigs/ViewConfigStore.ts create mode 100644 wwwroot/data/viewconfigs.json diff --git a/docs/calendar-command-system-spec.md b/docs/calendar-command-system-spec.md index 2314274..3157f72 100644 --- a/docs/calendar-command-system-spec.md +++ b/docs/calendar-command-system-spec.md @@ -1,664 +1,98 @@ -# Specification: Calendar Command Event System +# CalendarApp Event Specification -## 1. Overview +## 1. Oversigt -### 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.) +CalendarApp initialiseres med `CalendarApp.create(container)`. -### 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 | +Kommunikation sker via DOM events: +- **Command events**: Host → Calendar +- **Status events**: Calendar → Host -### 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 │ -└─────────────────────────────────────────────────────────────────┘ -``` +Settings hentes fra `SettingsService` (IndexedDB). --- -## 2. ViewConfig (Eksisterende - Uændret) +## 2. Command Events (Host → Calendar) -Vi genbruger den eksisterende ViewConfig fra `src/v2/core/ViewConfig.ts`: +| Event | Payload | Beskrivelse | +|-------|---------|-------------| +| `calendar:cmd:render` | `{ viewConfig }` | Render kalenderen med ViewConfig | -```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 +**Eksempel:** ```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' } + { 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' } ] - }, - animation: 'left' + } } })); ``` --- -## 8. Implementation Plan +## 3. Status Events (Calendar → Host) -| 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 | +| Event | Payload | Beskrivelse | +|-------|---------|-------------| +| `calendar:status:ready` | `{}` | Calendar initialiseret | +| `calendar:status:rendered` | `{ templateId }` | Rendering færdig | +| `calendar:status:error` | `{ message, code }` | Fejl opstået | + +```typescript +interface IRenderedStatusPayload { + templateId: string; +} + +interface IErrorStatusPayload { + message: string; + code: 'INVALID_PAYLOAD' | 'RENDER_FAILED'; +} +``` --- -## 9. Summary +## 4. CalendarApp -### Før (nuværende DemoApp) +### 4.1 Initialisering ```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); +await CalendarApp.create(container); ``` -### Efter (med CalendarApp) -```typescript -// Init - én linje -await CalendarApp.create(container, { dayStartHour: 6, dayEndHour: 18 }); +### 4.2 Dependencies (via DI) +- CalendarOrchestrator +- SettingsService +- TimeAxisRenderer +- ScrollManager +- DragDropManager +- EdgeScrollManager +- ResizeManager +- HeaderDrawerManager +- EventBus -// Render - command event -document.dispatchEvent(new CustomEvent('calendar:cmd:render', { - detail: { viewConfig, animation: 'left' } -})); -``` +### 4.3 Ansvar +- Subscribe på `calendar:cmd:render` +- Emit `calendar:status:ready` ved init +- Emit `calendar:status:rendered` efter render +- Emit `calendar:status:error` ved fejl -### 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 | +--- + +## 5. Filer + +| Fil | Beskrivelse | +|-----|-------------| +| `src/v2/CalendarApp.ts` | Entry point | +| `src/v2/types/CommandTypes.ts` | Payload interfaces | +| `src/v2/constants/CommandEvents.ts` | Event konstanter | diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index b8728f9..1814b18 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -38,6 +38,9 @@ import { DepartmentService } from './storage/departments/DepartmentService'; import { SettingsStore } from './storage/settings/SettingsStore'; import { SettingsService } from './storage/settings/SettingsService'; import { ITenantSettings } from './types/SettingsTypes'; +import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore'; +import { ViewConfigService } from './storage/viewconfigs/ViewConfigService'; +import { ViewConfig } from './core/ViewConfig'; // Audit import { AuditStore } from './storage/audit/AuditStore'; @@ -54,6 +57,7 @@ import { MockAuditRepository } from './repositories/MockAuditRepository'; import { MockTeamRepository } from './repositories/MockTeamRepository'; import { MockDepartmentRepository } from './repositories/MockDepartmentRepository'; import { MockSettingsRepository } from './repositories/MockSettingsRepository'; +import { MockViewConfigRepository } from './repositories/MockViewConfigRepository'; // Workers import { DataSeeder } from './workers/DataSeeder'; @@ -118,6 +122,7 @@ export function createV2Container(): Container { builder.registerType(ScheduleOverrideStore).as(); builder.registerType(AuditStore).as(); builder.registerType(SettingsStore).as(); + builder.registerType(ViewConfigStore).as(); // Entity services (for DataSeeder polymorphic array) builder.registerType(EventService).as>(); @@ -148,6 +153,10 @@ export function createV2Container(): Container { builder.registerType(SettingsService).as>(); builder.registerType(SettingsService).as(); + builder.registerType(ViewConfigService).as>(); + builder.registerType(ViewConfigService).as>(); + builder.registerType(ViewConfigService).as(); + // Repositories (for DataSeeder polymorphic array) builder.registerType(MockEventRepository).as>(); builder.registerType(MockEventRepository).as>(); @@ -173,6 +182,9 @@ export function createV2Container(): Container { builder.registerType(MockSettingsRepository).as>(); builder.registerType(MockSettingsRepository).as>(); + builder.registerType(MockViewConfigRepository).as>(); + builder.registerType(MockViewConfigRepository).as>(); + // Audit service (listens to ENTITY_SAVED/DELETED events automatically) builder.registerType(AuditService).as(); diff --git a/src/v2/core/ViewConfig.ts b/src/v2/core/ViewConfig.ts index c964562..8ecd79b 100644 --- a/src/v2/core/ViewConfig.ts +++ b/src/v2/core/ViewConfig.ts @@ -1,11 +1,13 @@ +import { ISync } from '../types/CalendarTypes'; + export interface ViewTemplate { id: string; name: string; groupingTypes: string[]; } -export interface ViewConfig { - templateId: string; +export interface ViewConfig extends ISync { + id: string; // templateId (e.g. 'day', 'simple', 'resource') groupings: GroupingConfig[]; } diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 4c30821..75ffc33 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -15,6 +15,7 @@ import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRende import { AuditService } from '../storage/audit/AuditService'; import { SettingsService } from '../storage/settings/SettingsService'; import { ResourceService } from '../storage/resources/ResourceService'; +import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService'; import { IWorkweekPreset } from '../types/SettingsTypes'; export class DemoApp { @@ -23,7 +24,6 @@ export class DemoApp { private weekOffset = 0; private currentView: 'day' | 'simple' | 'resource' | 'picker' | 'team' | 'department' = 'simple'; private workweekPreset: IWorkweekPreset | null = null; - private selectedResourceIds: string[] = []; constructor( private orchestrator: CalendarOrchestrator, @@ -40,7 +40,8 @@ export class DemoApp { private eventPersistenceManager: EventPersistenceManager, private auditService: AuditService, private settingsService: SettingsService, - private resourceService: ResourceService + private resourceService: ResourceService, + private viewConfigService: ViewConfigService ) {} async init(): Promise { @@ -99,72 +100,28 @@ export class DemoApp { } private async render(): Promise { - const viewConfig = this.buildViewConfig(); - await this.orchestrator.render(viewConfig, this.container); - } - - private buildViewConfig(): ViewConfig { - // Use workweek preset to determine which days to show - const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5]; // Fallback to Mon-Fri - const dates = this.dateService.getWorkWeekDates(this.weekOffset, workDays); - const today = this.dateService.getWeekDates(this.weekOffset, 1); - - switch (this.currentView) { - case 'day': - return { - templateId: 'day', - groupings: [ - { type: 'resource', values: this.selectedResourceIds, idProperty: 'resourceId' }, - { type: 'date', values: today, idProperty: 'date', derivedFrom: 'start', hideHeader: true } - ] - }; - - case 'simple': - return { - templateId: 'simple', - groupings: [ - { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } - ] - }; - - case 'resource': - return { - templateId: 'resource', - groupings: [ - { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' }, - { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } - ] - }; - - case 'team': - return { - templateId: 'team', - groupings: [ - { type: 'team', values: ['team1', 'team2'] }, - { type: 'resource', values: ['EMP001', 'EMP002', 'EMP003', 'EMP004'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' }, - { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } - ] - }; - - case 'department': - return { - templateId: 'department', - groupings: [ - { type: 'department', values: ['dept-styling', 'dept-training'] }, - { type: 'resource', values: ['EMP001', 'EMP002', 'EMP003', 'EMP004', 'STUDENT001', 'STUDENT002'], idProperty: 'resourceId', belongsTo: 'department.resourceIds' }, - { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } - ] - }; - - case 'picker': - return { - templateId: 'picker', - groupings: [ - { type: 'resource', values: this.selectedResourceIds, idProperty: 'resourceId' }, - { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } - ] - }; + // Load ViewConfig from IndexedDB + const storedConfig = await this.viewConfigService.getById(this.currentView); + if (!storedConfig) { + console.error(`[DemoApp] ViewConfig not found for templateId: ${this.currentView}`); + return; } + + // Populate date values based on workweek and offset + const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5]; + const dates = this.currentView === 'day' + ? this.dateService.getWeekDates(this.weekOffset, 1) + : this.dateService.getWorkWeekDates(this.weekOffset, workDays); + + // Clone config and populate dates + const viewConfig: ViewConfig = { + ...storedConfig, + groupings: storedConfig.groupings.map(g => + g.type === 'date' ? { ...g, values: dates } : g + ) + }; + + await this.orchestrator.render(viewConfig, this.container); } private setupNavigation(): void { @@ -224,25 +181,15 @@ export class DemoApp { // Clear existing container.innerHTML = ''; - // Create checkboxes for each resource + // Display resources (read-only, values are stored in ViewConfig) resources.forEach(r => { const label = document.createElement('label'); label.innerHTML = ` - + ${r.displayName} `; container.appendChild(label); }); - - // Default: all selected - this.selectedResourceIds = resources.map(r => r.id); - - // Event listener for checkbox changes - container.addEventListener('change', () => { - const checked = container.querySelectorAll('input:checked') as NodeListOf; - this.selectedResourceIds = Array.from(checked).map(cb => cb.value); - this.render(); - }); } private updateSelectorVisibility(): void { diff --git a/src/v2/repositories/MockViewConfigRepository.ts b/src/v2/repositories/MockViewConfigRepository.ts new file mode 100644 index 0000000..866a6a7 --- /dev/null +++ b/src/v2/repositories/MockViewConfigRepository.ts @@ -0,0 +1,41 @@ +import { EntityType } from '../types/CalendarTypes'; +import { ViewConfig } from '../core/ViewConfig'; +import { IApiRepository } from './IApiRepository'; + +export class MockViewConfigRepository implements IApiRepository { + public readonly entityType: EntityType = 'ViewConfig'; + private readonly dataUrl = 'data/viewconfigs.json'; + + public async fetchAll(): Promise { + try { + const response = await fetch(this.dataUrl); + + if (!response.ok) { + throw new Error(`Failed to load viewconfigs: ${response.status} ${response.statusText}`); + } + + const rawData = await response.json(); + // Ensure syncStatus is set on each config + const configs: ViewConfig[] = rawData.map((config: ViewConfig) => ({ + ...config, + syncStatus: config.syncStatus || 'synced' + })); + return configs; + } catch (error) { + console.error('Failed to load viewconfigs:', error); + throw error; + } + } + + public async sendCreate(_config: ViewConfig): Promise { + throw new Error('MockViewConfigRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockViewConfigRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockViewConfigRepository does not support sendDelete. Mock data is read-only.'); + } +} diff --git a/src/v2/storage/viewconfigs/ViewConfigService.ts b/src/v2/storage/viewconfigs/ViewConfigService.ts new file mode 100644 index 0000000..03a42f7 --- /dev/null +++ b/src/v2/storage/viewconfigs/ViewConfigService.ts @@ -0,0 +1,18 @@ +import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { ViewConfig } from '../../core/ViewConfig'; +import { ViewConfigStore } from './ViewConfigStore'; +import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../IndexedDBContext'; + +export class ViewConfigService extends BaseEntityService { + readonly storeName = ViewConfigStore.STORE_NAME; + readonly entityType: EntityType = 'ViewConfig'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + async getById(id: string): Promise { + return this.get(id); + } +} diff --git a/src/v2/storage/viewconfigs/ViewConfigStore.ts b/src/v2/storage/viewconfigs/ViewConfigStore.ts new file mode 100644 index 0000000..fb02d07 --- /dev/null +++ b/src/v2/storage/viewconfigs/ViewConfigStore.ts @@ -0,0 +1,10 @@ +import { IStore } from '../IStore'; + +export class ViewConfigStore implements IStore { + static readonly STORE_NAME = 'viewconfigs'; + readonly storeName = ViewConfigStore.STORE_NAME; + + create(db: IDBDatabase): void { + db.createObjectStore(ViewConfigStore.STORE_NAME, { keyPath: 'id' }); + } +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts index 7c24479..8bcbb41 100644 --- a/src/v2/types/CalendarTypes.ts +++ b/src/v2/types/CalendarTypes.ts @@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes'; export type SyncStatus = 'synced' | 'pending' | 'error'; -export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings'; +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings' | 'ViewConfig'; /** * CalendarEventType - Used by ICalendarEvent.type diff --git a/wwwroot/data/viewconfigs.json b/wwwroot/data/viewconfigs.json new file mode 100644 index 0000000..2e1f027 --- /dev/null +++ b/wwwroot/data/viewconfigs.json @@ -0,0 +1,45 @@ +[ + { + "id": "day", + "groupings": [ + { "type": "resource", "values": ["EMP001", "EMP002"], "idProperty": "resourceId" }, + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start", "hideHeader": true } + ] + }, + { + "id": "simple", + "groupings": [ + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start" } + ] + }, + { + "id": "resource", + "groupings": [ + { "type": "resource", "values": ["EMP001", "EMP002"], "idProperty": "resourceId" }, + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start" } + ] + }, + { + "id": "team", + "groupings": [ + { "type": "team", "values": ["team1", "team2"] }, + { "type": "resource", "values": ["EMP001", "EMP002", "EMP003", "EMP004"], "idProperty": "resourceId", "belongsTo": "team.resourceIds" }, + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start" } + ] + }, + { + "id": "department", + "groupings": [ + { "type": "department", "values": ["dept-styling", "dept-training"] }, + { "type": "resource", "values": ["EMP001", "EMP002", "EMP003", "EMP004", "STUDENT001", "STUDENT002"], "idProperty": "resourceId", "belongsTo": "department.resourceIds" }, + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start" } + ] + }, + { + "id": "picker", + "groupings": [ + { "type": "resource", "values": ["EMP001", "EMP002", "EMP003", "EMP004"], "idProperty": "resourceId" }, + { "type": "date", "values": [], "idProperty": "date", "derivedFrom": "start" } + ] + } +]