Refactors calendar application architecture

Introduces CalendarApp as a reusable core component to centralize calendar rendering and navigation logic

Separates concerns between core application logic and demo implementation
Improves modularity and extensibility of calendar system
This commit is contained in:
Janus C. H. Knudsen 2025-12-16 22:37:35 +01:00
parent 7f9d0129bf
commit 8161b3c42a
3 changed files with 182 additions and 132 deletions

View file

@ -9,6 +9,7 @@ import { ResourceRenderer } from './features/resource/ResourceRenderer';
import { TeamRenderer } from './features/team/TeamRenderer'; import { TeamRenderer } from './features/team/TeamRenderer';
import { DepartmentRenderer } from './features/department/DepartmentRenderer'; import { DepartmentRenderer } from './features/department/DepartmentRenderer';
import { CalendarOrchestrator } from './core/CalendarOrchestrator'; import { CalendarOrchestrator } from './core/CalendarOrchestrator';
import { CalendarApp } from './core/CalendarApp';
import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
import { ScrollManager } from './core/ScrollManager'; import { ScrollManager } from './core/ScrollManager';
import { HeaderDrawerManager } from './core/HeaderDrawerManager'; import { HeaderDrawerManager } from './core/HeaderDrawerManager';
@ -220,6 +221,9 @@ export function createV2Container(): Container {
builder.registerType(ResizeManager).as<ResizeManager>(); builder.registerType(ResizeManager).as<ResizeManager>();
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>(); builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
// CalendarApp - genbrugelig kalenderkomponent
builder.registerType(CalendarApp).as<CalendarApp>();
// Demo app // Demo app
builder.registerType(DemoApp).as<DemoApp>(); builder.registerType(DemoApp).as<DemoApp>();

140
src/v2/core/CalendarApp.ts Normal file
View file

@ -0,0 +1,140 @@
import { CalendarOrchestrator } from './CalendarOrchestrator';
import { TimeAxisRenderer } from '../features/timeaxis/TimeAxisRenderer';
import { NavigationAnimator } from './NavigationAnimator';
import { DateService } from './DateService';
import { ScrollManager } from './ScrollManager';
import { HeaderDrawerManager } from './HeaderDrawerManager';
import { ViewConfig } from './ViewConfig';
import { DragDropManager } from '../managers/DragDropManager';
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
import { ResizeManager } from '../managers/ResizeManager';
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
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 CalendarApp {
private animator!: NavigationAnimator;
private container!: HTMLElement;
private weekOffset = 0;
private currentViewId = 'simple';
private workweekPreset: IWorkweekPreset | null = null;
constructor(
private orchestrator: CalendarOrchestrator,
private timeAxisRenderer: TimeAxisRenderer,
private dateService: DateService,
private scrollManager: ScrollManager,
private headerDrawerManager: HeaderDrawerManager,
private dragDropManager: DragDropManager,
private edgeScrollManager: EdgeScrollManager,
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer,
private eventPersistenceManager: EventPersistenceManager,
private settingsService: SettingsService,
private resourceService: ResourceService,
private viewConfigService: ViewConfigService
) {}
async init(container: HTMLElement): Promise<void> {
this.container = container;
// Load default workweek preset from settings
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
// Create NavigationAnimator with DOM elements
this.animator = new NavigationAnimator(
container.querySelector('swp-header-track') as HTMLElement,
container.querySelector('swp-content-track') as HTMLElement
);
// Render time axis (from settings later, hardcoded for now)
this.timeAxisRenderer.render(
container.querySelector('#time-axis') as HTMLElement,
6,
18
);
// Init managers
this.scrollManager.init(container);
this.headerDrawerManager.init(container);
this.dragDropManager.init(container);
this.resizeManager.init(container);
const scrollableContent = container.querySelector('swp-scrollable-content') as HTMLElement;
this.edgeScrollManager.init(scrollableContent);
// Setup command event listeners
this.setupEventListeners();
// Emit ready status
this.emitStatus('ready');
}
private setupEventListeners(): void {
this.container.addEventListener('calendar:cmd:render', ((e: CustomEvent) => {
const { viewId, direction } = e.detail;
this.handleRenderCommand(viewId, direction);
}) as EventListener);
this.container.addEventListener('calendar:cmd:navigate', ((e: CustomEvent) => {
const { offset, direction } = e.detail;
this.handleNavigateCommand(offset, direction);
}) as EventListener);
this.container.addEventListener('calendar:cmd:drawer:toggle', (() => {
this.headerDrawerManager.toggle();
}) as EventListener);
}
private async handleRenderCommand(viewId: string, direction?: 'left' | 'right'): Promise<void> {
this.currentViewId = viewId;
if (direction) {
await this.animator.slide(direction, () => this.render());
} else {
await this.render();
}
this.emitStatus('rendered', { viewId });
}
private async handleNavigateCommand(offset: number, direction: 'left' | 'right'): Promise<void> {
this.weekOffset += offset;
await this.animator.slide(direction, () => this.render());
this.emitStatus('rendered', { viewId: this.currentViewId });
}
private async render(): Promise<void> {
const storedConfig = await this.viewConfigService.getById(this.currentViewId);
if (!storedConfig) {
this.emitStatus('error', { message: `ViewConfig not found: ${this.currentViewId}` });
return;
}
// Populate date values based on workweek and offset
const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5];
const dates = this.currentViewId === '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 emitStatus(status: string, detail?: object): void {
this.container.dispatchEvent(new CustomEvent(`calendar:status:${status}`, {
detail,
bubbles: true
}));
}
}

View file

@ -1,47 +1,19 @@
import { CalendarOrchestrator } from '../core/CalendarOrchestrator';
import { TimeAxisRenderer } from '../features/timeaxis/TimeAxisRenderer';
import { NavigationAnimator } from '../core/NavigationAnimator';
import { DateService } from '../core/DateService';
import { ScrollManager } from '../core/ScrollManager';
import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
import { IndexedDBContext } from '../storage/IndexedDBContext'; import { IndexedDBContext } from '../storage/IndexedDBContext';
import { DataSeeder } from '../workers/DataSeeder'; import { DataSeeder } from '../workers/DataSeeder';
import { ViewConfig } from '../core/ViewConfig';
import { DragDropManager } from '../managers/DragDropManager';
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
import { ResizeManager } from '../managers/ResizeManager';
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { AuditService } from '../storage/audit/AuditService'; import { AuditService } from '../storage/audit/AuditService';
import { SettingsService } from '../storage/settings/SettingsService'; import { CalendarApp } from '../core/CalendarApp';
import { ResourceService } from '../storage/resources/ResourceService'; import { DateService } from '../core/DateService';
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
import { IWorkweekPreset } from '../types/SettingsTypes';
export class DemoApp { export class DemoApp {
private animator!: NavigationAnimator;
private container!: HTMLElement; private container!: HTMLElement;
private weekOffset = 0; private currentView = 'simple';
private currentView: 'day' | 'simple' | 'resource' | 'picker' | 'team' | 'department' = 'simple';
private workweekPreset: IWorkweekPreset | null = null;
constructor( constructor(
private orchestrator: CalendarOrchestrator,
private timeAxisRenderer: TimeAxisRenderer,
private dateService: DateService,
private scrollManager: ScrollManager,
private headerDrawerManager: HeaderDrawerManager,
private indexedDBContext: IndexedDBContext, private indexedDBContext: IndexedDBContext,
private dataSeeder: DataSeeder, private dataSeeder: DataSeeder,
private dragDropManager: DragDropManager,
private edgeScrollManager: EdgeScrollManager,
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer,
private eventPersistenceManager: EventPersistenceManager,
private auditService: AuditService, private auditService: AuditService,
private settingsService: SettingsService, private calendarApp: CalendarApp,
private resourceService: ResourceService, private dateService: DateService
private viewConfigService: ViewConfigService
) {} ) {}
async init(): Promise<void> { async init(): Promise<void> {
@ -56,145 +28,79 @@ export class DemoApp {
await this.dataSeeder.seedIfEmpty(); await this.dataSeeder.seedIfEmpty();
console.log('[DemoApp] Data seeding complete'); console.log('[DemoApp] Data seeding complete');
// Load default workweek preset from settings
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
console.log('[DemoApp] Workweek preset loaded:', this.workweekPreset?.id);
this.container = document.querySelector('swp-calendar-container') as HTMLElement; this.container = document.querySelector('swp-calendar-container') as HTMLElement;
// NavigationAnimator har DOM-dependencies - tilladt med new // Initialize CalendarApp
this.animator = new NavigationAnimator( await this.calendarApp.init(this.container);
document.querySelector('swp-header-track') as HTMLElement, console.log('[DemoApp] CalendarApp initialized');
document.querySelector('swp-content-track') as HTMLElement
);
// Render time axis (06:00 - 18:00) // Setup demo UI handlers
this.timeAxisRenderer.render(document.getElementById('time-axis') as HTMLElement, 6, 18);
// Init scroll synkronisering
this.scrollManager.init(this.container);
// Init header drawer
this.headerDrawerManager.init(this.container);
// Init drag-drop
this.dragDropManager.init(this.container);
// Init edge scroll
const scrollableContent = this.container.querySelector('swp-scrollable-content') as HTMLElement;
this.edgeScrollManager.init(scrollableContent);
// Init resize
this.resizeManager.init(this.container);
// Setup event handlers
this.setupNavigation(); this.setupNavigation();
this.setupDrawerToggle(); this.setupDrawerToggle();
this.setupViewSwitching(); this.setupViewSwitching();
// Setup resource selector for picker view // Listen for calendar status events
await this.setupResourceSelector(); this.setupStatusListeners();
// Initial render // Initial render
this.render(); this.emitRenderCommand(this.currentView);
}
private async render(): Promise<void> {
// 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 { private setupNavigation(): void {
document.getElementById('btn-prev')!.onclick = () => { document.getElementById('btn-prev')!.onclick = () => {
this.weekOffset--; this.emitNavigateCommand(-1, 'right');
this.animator.slide('right', () => this.render());
}; };
document.getElementById('btn-next')!.onclick = () => { document.getElementById('btn-next')!.onclick = () => {
this.weekOffset++; this.emitNavigateCommand(1, 'left');
this.animator.slide('left', () => this.render());
}; };
} }
private setupViewSwitching(): void { private setupViewSwitching(): void {
// View chip buttons
const chips = document.querySelectorAll('.view-chip'); const chips = document.querySelectorAll('.view-chip');
chips.forEach(chip => { chips.forEach(chip => {
chip.addEventListener('click', () => { chip.addEventListener('click', () => {
// Update active state
chips.forEach(c => c.classList.remove('active')); chips.forEach(c => c.classList.remove('active'));
chip.classList.add('active'); chip.classList.add('active');
// Switch view const view = (chip as HTMLElement).dataset.view;
const view = (chip as HTMLElement).dataset.view as typeof this.currentView;
if (view) { if (view) {
this.currentView = view; this.currentView = view;
this.updateSelectorVisibility(); this.emitRenderCommand(view);
this.render();
} }
}); });
}); });
// Workweek preset dropdown
const workweekSelect = document.getElementById('workweek-select') as HTMLSelectElement;
workweekSelect?.addEventListener('change', async () => {
const presetId = workweekSelect.value;
const preset = await this.settingsService.getWorkweekPreset(presetId);
if (preset) {
this.workweekPreset = preset;
this.render();
}
});
} }
private setupDrawerToggle(): void { private setupDrawerToggle(): void {
document.getElementById('btn-drawer')!.onclick = () => { document.getElementById('btn-drawer')!.onclick = () => {
this.headerDrawerManager.toggle(); this.container.dispatchEvent(new CustomEvent('calendar:cmd:drawer:toggle'));
}; };
} }
private async setupResourceSelector(): Promise<void> { private setupStatusListeners(): void {
const resources = await this.resourceService.getAll(); this.container.addEventListener('calendar:status:ready', () => {
const container = document.querySelector('.resource-checkboxes') as HTMLElement; console.log('[DemoApp] Calendar ready');
if (!container) return;
// Clear existing
container.innerHTML = '';
// Display resources (read-only, values are stored in ViewConfig)
resources.forEach(r => {
const label = document.createElement('label');
label.innerHTML = `
<input type="checkbox" value="${r.id}" checked disabled>
${r.displayName}
`;
container.appendChild(label);
}); });
this.container.addEventListener('calendar:status:rendered', ((e: CustomEvent) => {
console.log('[DemoApp] Calendar rendered:', e.detail.viewId);
}) as EventListener);
this.container.addEventListener('calendar:status:error', ((e: CustomEvent) => {
console.error('[DemoApp] Calendar error:', e.detail.message);
}) as EventListener);
} }
private updateSelectorVisibility(): void { private emitRenderCommand(viewId: string, direction?: 'left' | 'right'): void {
const selector = document.querySelector('swp-resource-selector'); this.container.dispatchEvent(new CustomEvent('calendar:cmd:render', {
const showSelector = this.currentView === 'picker' || this.currentView === 'day'; detail: { viewId, direction }
selector?.classList.toggle('hidden', !showSelector); }));
}
private emitNavigateCommand(offset: number, direction: 'left' | 'right'): void {
this.container.dispatchEvent(new CustomEvent('calendar:cmd:navigate', {
detail: { offset, direction }
}));
} }
} }