Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
9 changed files with 217 additions and 708 deletions
Showing only changes of commit 7f9d0129bf - Show all commits

View file

@ -1,664 +1,98 @@
# Specification: Calendar Command Event System # CalendarApp Event Specification
## 1. Overview ## 1. Oversigt
### 1.1 Purpose CalendarApp initialiseres med `CalendarApp.create(container)`.
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 Kommunikation sker via DOM events:
| Principle | Description | - **Command events**: Host → Calendar
|-----------|-------------| - **Status events**: Calendar → Host
| **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 Settings hentes fra `SettingsService` (IndexedDB).
```
┌─────────────────────────────────────────────────────────────────┐
│ 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) ## 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 ```typescript
interface IRenderCommandPayload { interface IRenderCommandPayload {
viewConfig: ViewConfig; viewConfig: ViewConfig;
animation?: 'left' | 'right'; // Optional slide animation
} }
``` ```
### 3.2 Status Events (Outbound) **Eksempel:**
| 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 ```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', { document.dispatchEvent(new CustomEvent('calendar:cmd:render', {
detail: { detail: {
viewConfig: { viewConfig: {
templateId: 'team', templateId: 'team',
groupings: [ groupings: [
{ type: 'team', values: ['sales', 'support'] }, { type: 'team', values: ['team1', 'team2'] },
{ type: 'resource', values: ['john', 'jane', 'bob'], { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId', belongsTo: 'team.resourceIds' },
idProperty: 'resourceId', belongsTo: 'team.members' }, { type: 'date', values: ['2025-12-08', '2025-12-09'], idProperty: 'date', derivedFrom: 'start' }
{ type: 'date', values: ['2025-12-15', '2025-12-16'],
idProperty: 'date', derivedFrom: 'start' }
] ]
}, }
animation: 'left'
} }
})); }));
``` ```
--- ---
## 8. Implementation Plan ## 3. Status Events (Calendar → Host)
| Phase | Tasks | Files | | Event | Payload | Beskrivelse |
|-------|-------|-------| |-------|---------|-------------|
| **1** | Create types | `src/v2/types/CommandTypes.ts` | | `calendar:status:ready` | `{}` | Calendar initialiseret |
| **2** | Create events | `src/v2/constants/CommandEvents.ts` | | `calendar:status:rendered` | `{ templateId }` | Rendering færdig |
| **3** | Create CalendarApp | `src/v2/CalendarApp.ts` | | `calendar:status:error` | `{ message, code }` | Fejl opstået |
| **4** | Simplify DemoApp | `src/v2/demo/DemoApp.ts` |
| **5** | Test | Manual testing | ```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 ```typescript
// DemoApp.init() - 100+ linjer setup await CalendarApp.create(container);
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) ### 4.2 Dependencies (via DI)
```typescript - CalendarOrchestrator
// Init - én linje - SettingsService
await CalendarApp.create(container, { dayStartHour: 6, dayEndHour: 18 }); - TimeAxisRenderer
- ScrollManager
- DragDropManager
- EdgeScrollManager
- ResizeManager
- HeaderDrawerManager
- EventBus
// Render - command event ### 4.3 Ansvar
document.dispatchEvent(new CustomEvent('calendar:cmd:render', { - Subscribe på `calendar:cmd:render`
detail: { viewConfig, animation: 'left' } - Emit `calendar:status:ready` ved init
})); - Emit `calendar:status:rendered` efter render
``` - Emit `calendar:status:error` ved fejl
### Fordele ---
| Aspekt | Før | Efter |
|--------|-----|-------| ## 5. Filer
| Init kompleksitet | 100+ linjer | 1 linje |
| Kalender viden | Host kender alle managers | Host kender kun ViewConfig | | Fil | Beskrivelse |
| Genbrugelighed | Svær - tæt koblet | Nem - løs koblet | |-----|-------------|
| ViewConfig | Uændret | Uændret | | `src/v2/CalendarApp.ts` | Entry point |
| Ekstern kontrol | Ikke mulig | Via events | | `src/v2/types/CommandTypes.ts` | Payload interfaces |
| `src/v2/constants/CommandEvents.ts` | Event konstanter |

View file

@ -38,6 +38,9 @@ import { DepartmentService } from './storage/departments/DepartmentService';
import { SettingsStore } from './storage/settings/SettingsStore'; import { SettingsStore } from './storage/settings/SettingsStore';
import { SettingsService } from './storage/settings/SettingsService'; import { SettingsService } from './storage/settings/SettingsService';
import { ITenantSettings } from './types/SettingsTypes'; import { ITenantSettings } from './types/SettingsTypes';
import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
import { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
import { ViewConfig } from './core/ViewConfig';
// Audit // Audit
import { AuditStore } from './storage/audit/AuditStore'; import { AuditStore } from './storage/audit/AuditStore';
@ -54,6 +57,7 @@ import { MockAuditRepository } from './repositories/MockAuditRepository';
import { MockTeamRepository } from './repositories/MockTeamRepository'; import { MockTeamRepository } from './repositories/MockTeamRepository';
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository'; import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
import { MockSettingsRepository } from './repositories/MockSettingsRepository'; import { MockSettingsRepository } from './repositories/MockSettingsRepository';
import { MockViewConfigRepository } from './repositories/MockViewConfigRepository';
// Workers // Workers
import { DataSeeder } from './workers/DataSeeder'; import { DataSeeder } from './workers/DataSeeder';
@ -118,6 +122,7 @@ export function createV2Container(): Container {
builder.registerType(ScheduleOverrideStore).as<IStore>(); builder.registerType(ScheduleOverrideStore).as<IStore>();
builder.registerType(AuditStore).as<IStore>(); builder.registerType(AuditStore).as<IStore>();
builder.registerType(SettingsStore).as<IStore>(); builder.registerType(SettingsStore).as<IStore>();
builder.registerType(ViewConfigStore).as<IStore>();
// Entity services (for DataSeeder polymorphic array) // Entity services (for DataSeeder polymorphic array)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>(); builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
@ -148,6 +153,10 @@ export function createV2Container(): Container {
builder.registerType(SettingsService).as<IEntityService<ISync>>(); builder.registerType(SettingsService).as<IEntityService<ISync>>();
builder.registerType(SettingsService).as<SettingsService>(); builder.registerType(SettingsService).as<SettingsService>();
builder.registerType(ViewConfigService).as<IEntityService<ViewConfig>>();
builder.registerType(ViewConfigService).as<IEntityService<ISync>>();
builder.registerType(ViewConfigService).as<ViewConfigService>();
// Repositories (for DataSeeder polymorphic array) // Repositories (for DataSeeder polymorphic array)
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>(); builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>(); builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
@ -173,6 +182,9 @@ export function createV2Container(): Container {
builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>(); builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>();
builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>(); builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>();
builder.registerType(MockViewConfigRepository).as<IApiRepository<ViewConfig>>();
builder.registerType(MockViewConfigRepository).as<IApiRepository<ISync>>();
// Audit service (listens to ENTITY_SAVED/DELETED events automatically) // Audit service (listens to ENTITY_SAVED/DELETED events automatically)
builder.registerType(AuditService).as<AuditService>(); builder.registerType(AuditService).as<AuditService>();

View file

@ -1,11 +1,13 @@
import { ISync } from '../types/CalendarTypes';
export interface ViewTemplate { export interface ViewTemplate {
id: string; id: string;
name: string; name: string;
groupingTypes: string[]; groupingTypes: string[];
} }
export interface ViewConfig { export interface ViewConfig extends ISync {
templateId: string; id: string; // templateId (e.g. 'day', 'simple', 'resource')
groupings: GroupingConfig[]; groupings: GroupingConfig[];
} }

View file

@ -15,6 +15,7 @@ import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRende
import { AuditService } from '../storage/audit/AuditService'; import { AuditService } from '../storage/audit/AuditService';
import { SettingsService } from '../storage/settings/SettingsService'; import { SettingsService } from '../storage/settings/SettingsService';
import { ResourceService } from '../storage/resources/ResourceService'; import { ResourceService } from '../storage/resources/ResourceService';
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
import { IWorkweekPreset } from '../types/SettingsTypes'; import { IWorkweekPreset } from '../types/SettingsTypes';
export class DemoApp { export class DemoApp {
@ -23,7 +24,6 @@ export class DemoApp {
private weekOffset = 0; private weekOffset = 0;
private currentView: 'day' | 'simple' | 'resource' | 'picker' | 'team' | 'department' = 'simple'; private currentView: 'day' | 'simple' | 'resource' | 'picker' | 'team' | 'department' = 'simple';
private workweekPreset: IWorkweekPreset | null = null; private workweekPreset: IWorkweekPreset | null = null;
private selectedResourceIds: string[] = [];
constructor( constructor(
private orchestrator: CalendarOrchestrator, private orchestrator: CalendarOrchestrator,
@ -40,7 +40,8 @@ export class DemoApp {
private eventPersistenceManager: EventPersistenceManager, private eventPersistenceManager: EventPersistenceManager,
private auditService: AuditService, private auditService: AuditService,
private settingsService: SettingsService, private settingsService: SettingsService,
private resourceService: ResourceService private resourceService: ResourceService,
private viewConfigService: ViewConfigService
) {} ) {}
async init(): Promise<void> { async init(): Promise<void> {
@ -99,72 +100,28 @@ export class DemoApp {
} }
private async render(): Promise<void> { private async render(): Promise<void> {
const viewConfig = this.buildViewConfig(); // Load ViewConfig from IndexedDB
await this.orchestrator.render(viewConfig, this.container); const storedConfig = await this.viewConfigService.getById(this.currentView);
} if (!storedConfig) {
console.error(`[DemoApp] ViewConfig not found for templateId: ${this.currentView}`);
private buildViewConfig(): ViewConfig { return;
// 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' }
]
};
} }
// 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 { private setupNavigation(): void {
@ -224,25 +181,15 @@ export class DemoApp {
// Clear existing // Clear existing
container.innerHTML = ''; container.innerHTML = '';
// Create checkboxes for each resource // Display resources (read-only, values are stored in ViewConfig)
resources.forEach(r => { resources.forEach(r => {
const label = document.createElement('label'); const label = document.createElement('label');
label.innerHTML = ` label.innerHTML = `
<input type="checkbox" value="${r.id}" checked> <input type="checkbox" value="${r.id}" checked disabled>
${r.displayName} ${r.displayName}
`; `;
container.appendChild(label); 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<HTMLInputElement>;
this.selectedResourceIds = Array.from(checked).map(cb => cb.value);
this.render();
});
} }
private updateSelectorVisibility(): void { private updateSelectorVisibility(): void {

View file

@ -0,0 +1,41 @@
import { EntityType } from '../types/CalendarTypes';
import { ViewConfig } from '../core/ViewConfig';
import { IApiRepository } from './IApiRepository';
export class MockViewConfigRepository implements IApiRepository<ViewConfig> {
public readonly entityType: EntityType = 'ViewConfig';
private readonly dataUrl = 'data/viewconfigs.json';
public async fetchAll(): Promise<ViewConfig[]> {
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<ViewConfig> {
throw new Error('MockViewConfigRepository does not support sendCreate. Mock data is read-only.');
}
public async sendUpdate(_id: string, _updates: Partial<ViewConfig>): Promise<ViewConfig> {
throw new Error('MockViewConfigRepository does not support sendUpdate. Mock data is read-only.');
}
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockViewConfigRepository does not support sendDelete. Mock data is read-only.');
}
}

View file

@ -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<ViewConfig> {
readonly storeName = ViewConfigStore.STORE_NAME;
readonly entityType: EntityType = 'ViewConfig';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
async getById(id: string): Promise<ViewConfig | null> {
return this.get(id);
}
}

View file

@ -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' });
}
}

View file

@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes';
export type SyncStatus = 'synced' | 'pending' | 'error'; 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 * CalendarEventType - Used by ICalendarEvent.type

View file

@ -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" }
]
}
]