From a3a1b9a4216bca364db10035a5fa956cedcfb675 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Tue, 9 Dec 2025 00:51:41 +0100 Subject: [PATCH] Refactor calendar rendering with RenderBuilder Simplifies calendar rendering process by introducing a new RenderBuilder pattern Improves flexibility and modularity of grouping renderer chain Decouples rendering logic from specific grouping types Enables dynamic column span calculation and recursive rendering Reduces complexity in CalendarOrchestrator Enhances type safety and simplifies renderer interfaces --- package-lock.json | 6 +- src/v2/core/CalendarOrchestrator.ts | 162 ++++++------------- src/v2/core/IGroupingRenderer.ts | 19 ++- src/v2/core/RenderBuilder.ts | 82 ++++++++++ src/v2/core/RenderContext.ts | 9 -- src/v2/features/date/DateRenderer.ts | 19 ++- src/v2/features/resource/ResourceRenderer.ts | 37 ++++- src/v2/features/team/TeamRenderer.ts | 47 +++++- src/v2/index.ts | 4 +- 9 files changed, 230 insertions(+), 155 deletions(-) create mode 100644 src/v2/core/RenderBuilder.ts delete mode 100644 src/v2/core/RenderContext.ts diff --git a/package-lock.json b/package-lock.json index 9de71a8..3b1a7b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4960,9 +4960,9 @@ } }, "node_modules/ts-linq-light": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.0.tgz", - "integrity": "sha512-8GGyHkHlKuKFTbT/Xz/0y72OTKl1hVRMJ0MqXAaWaergCExKw3bd5M6DR3NSOA7UL0gkTMhWsql9kFYyAjkIdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.1.tgz", + "integrity": "sha512-Qk1TKZ8M/XYH6Vt+zUOtAyVOqezIMd3r7EDtgCPOnWgIs0Xdrj/miqUQAEoRl3LttbQQ/6gBMhM/84S/mTb/sg==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index 78964a7..b3ac610 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -1,23 +1,10 @@ import { from } from 'ts-linq-light'; -import { ViewConfig, GroupingConfig } from './ViewConfig'; -import { RenderContext } from './RenderContext'; -import { IGroupingRenderer } from './IGroupingRenderer'; +import { ViewConfig } from './ViewConfig'; +import { IGroupingRenderer, RenderContext } from './IGroupingRenderer'; import { IGroupingStore } from './IGroupingStore'; +import { RenderBuilder, RenderData } from './RenderBuilder'; import { EventRenderer } from '../features/event/EventRenderer'; -interface HierarchyNode { - type: string; - id: string; - data: unknown; - parentId?: string; - children: HierarchyNode[]; -} - -interface GroupingData { - items: { id: string; data: unknown }[]; - byParent: Map | null; -} - export class CalendarOrchestrator { constructor( private renderers: IGroupingRenderer[], @@ -40,115 +27,60 @@ export class CalendarOrchestrator { throw new Error('Missing swp-calendar-header or swp-day-columns'); } - const groupingData = this.fetchAllData(viewConfig.groupings); - const hierarchy = this.buildHierarchy(viewConfig.groupings, groupingData); - const totalColumns = this.countLeaves(hierarchy); - - container.style.setProperty('--grid-columns', String(totalColumns)); - - const types = from(viewConfig.groupings).select(g => g.type).toArray(); - headerContainer.dataset.levels = types.join(' '); + const context: RenderContext = { headerContainer, columnContainer }; + // Clear containers headerContainer.innerHTML = ''; columnContainer.innerHTML = ''; - this.renderHierarchy(hierarchy, headerContainer, columnContainer); + // Set header levels + const types = from(viewConfig.groupings).select(g => g.type).toArray(); + headerContainer.dataset.levels = types.join(' '); - // Render events from IndexedDB + // Hent alt data + const data: RenderData = { + teams: this.getItems('team', viewConfig), + resources: this.getItems('resource', viewConfig), + dates: this.getItems('date', viewConfig) + }; + + // Byg renderer chain + const builder = new RenderBuilder(context, data); + + for (const grouping of viewConfig.groupings) { + const renderer = this.getRenderer(grouping.type); + if (renderer) { + const items = this.getItems(grouping.type, viewConfig); + builder.add(renderer, items); + } + } + + // Beregn total columns og render + const totalColumns = builder.getTotalCount(); + container.style.setProperty('--grid-columns', String(totalColumns)); + + builder.build(); + + // Render events const visibleDates = this.extractVisibleDates(viewConfig); await this.eventRenderer.render(container, visibleDates); } + private getItems(type: string, viewConfig: ViewConfig): ReturnType { + const grouping = from(viewConfig.groupings).firstOrDefault(g => g.type === type); + if (!grouping) return from([]); + + if (type === 'date') { + return from(grouping.values); + } + + const store = this.getStore(type); + if (!store) return from([]); + + return from(store.getByIds(grouping.values)); + } + private extractVisibleDates(viewConfig: ViewConfig): string[] { return from(viewConfig.groupings).firstOrDefault(g => g.type === 'date')?.values || []; } - - private fetchAllData(groupings: GroupingConfig[]): Map { - const result = new Map(); - - for (const g of groupings) { - if (g.type === 'date') { - result.set(g.type, { - items: from(g.values).select(v => ({ id: v, data: { id: v } })).toArray(), - byParent: null - }); - continue; - } - - const store = this.getStore(g.type); - if (!store) continue; - const rawItems = store.getByIds(g.values); - const items = from(rawItems).select((item: any) => ({ id: item.id, data: item })).toArray(); - let byParent: Map | null = null; - if (g.parentKey) { - byParent = new Map(); - const grouped = from(items) - .where(item => (item.data as any)[g.parentKey!] != null) - .groupBy(item => (item.data as any)[g.parentKey!] as string); - for (const group of grouped) { - byParent.set(group.key, group.toArray()); - } - } - - result.set(g.type, { items, byParent }); - } - - return result; - } - - private buildHierarchy( - groupings: GroupingConfig[], - data: Map, - level = 0, - parentId?: string - ): HierarchyNode[] { - if (level >= groupings.length) return []; - - const g = groupings[level]; - const gData = data.get(g.type); - if (!gData) return []; - - const items = parentId && gData.byParent - ? gData.byParent.get(parentId) ?? [] - : gData.items; - - return from(items).select(item => ({ - type: g.type, - id: item.id, - data: item.data, - parentId, - children: this.buildHierarchy(groupings, data, level + 1, item.id) - })).toArray(); - } - - private countLeaves(nodes: HierarchyNode[]): number { - return nodes.reduce((sum, n) => - sum + (n.children.length ? this.countLeaves(n.children) : 1), 0); - } - - private renderHierarchy( - nodes: HierarchyNode[], - headerContainer: HTMLElement, - columnContainer: HTMLElement, - headerRow = 1 - ): void { - for (const node of nodes) { - const renderer = this.getRenderer(node.type); - const colspan = this.countLeaves([node]) || 1; - - renderer?.render({ - headerContainer, - columnContainer, - values: [node.id], - headerRow, - columnIndex: 0, // Not used - grid auto-places - colspan, - parentId: node.parentId - } as RenderContext); - - if (node.children.length) { - this.renderHierarchy(node.children, headerContainer, columnContainer, headerRow + 1); - } - } - } } diff --git a/src/v2/core/IGroupingRenderer.ts b/src/v2/core/IGroupingRenderer.ts index 392e381..39a784a 100644 --- a/src/v2/core/IGroupingRenderer.ts +++ b/src/v2/core/IGroupingRenderer.ts @@ -1,6 +1,17 @@ -import { RenderContext } from './RenderContext'; +import { IEnumerable } from 'ts-linq-light'; +import { NextFunction, RenderData } from './RenderBuilder'; -export interface IGroupingRenderer { - readonly type: string; - render(context: RenderContext): void; +export interface RenderContext { + headerContainer: HTMLElement; + columnContainer: HTMLElement; +} + +export interface IGroupingRenderer { + readonly type: string; + render( + items: IEnumerable, + data: RenderData, + next: NextFunction, + context: RenderContext + ): void; } diff --git a/src/v2/core/RenderBuilder.ts b/src/v2/core/RenderBuilder.ts new file mode 100644 index 0000000..e847547 --- /dev/null +++ b/src/v2/core/RenderBuilder.ts @@ -0,0 +1,82 @@ +import { from, IEnumerable } from 'ts-linq-light'; +import { IGroupingRenderer, RenderContext } from './IGroupingRenderer'; + +export interface NextFunction { + count(items: IEnumerable): number; + render(items: IEnumerable): void; +} + +export interface RenderData { + teams?: IEnumerable; + resources?: IEnumerable; + dates?: IEnumerable; +} + +interface RenderLevel { + renderer: IGroupingRenderer; + items: IEnumerable; +} + +export class RenderBuilder { + private levels: RenderLevel[] = []; + + constructor( + private context: RenderContext, + private data: RenderData + ) {} + + add(renderer: IGroupingRenderer, items: IEnumerable): this { + this.levels.push({ renderer, items }); + return this; + } + + getTotalCount(): number { + if (this.levels.length === 0) return 0; + + const chain = this.buildChain(0); + return chain.count(this.levels[0].items); + } + + build(): void { + if (this.levels.length === 0) return; + + const chain = this.buildChain(0); + chain.render(this.levels[0].items); + } + + private buildChain(index: number): NextFunction { + if (index >= this.levels.length) { + // Leaf - ingen flere levels + return { + count: (items) => from(items).count(), + render: () => {} + }; + } + + const level = this.levels[index]; + const nextChain = this.buildChain(index + 1); + + return { + count: (items) => { + let total = 0; + for (const item of items) { + const childItems = this.getChildItems(index, item); + total += nextChain.count(childItems); + } + return total || from(items).count(); + }, + render: (items) => { + level.renderer.render(items, this.data, nextChain, this.context); + } + }; + } + + private getChildItems(levelIndex: number, _parentItem: unknown): IEnumerable { + // Returnerer næste levels items - rendereren selv filtrerer baseret på parent + const nextLevel = this.levels[levelIndex + 1]; + if (!nextLevel) { + return from([]); + } + return nextLevel.items; + } +} diff --git a/src/v2/core/RenderContext.ts b/src/v2/core/RenderContext.ts deleted file mode 100644 index 841789e..0000000 --- a/src/v2/core/RenderContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface RenderContext { - headerContainer: HTMLElement; - columnContainer: HTMLElement; - values: string[]; - headerRow: number; - colspan: number; - parentId?: string; - columnIndex: number; // Kept for interface compatibility, but not used by renderers -} diff --git a/src/v2/features/date/DateRenderer.ts b/src/v2/features/date/DateRenderer.ts index 667ab4a..702bc69 100644 --- a/src/v2/features/date/DateRenderer.ts +++ b/src/v2/features/date/DateRenderer.ts @@ -1,14 +1,20 @@ -import { IGroupingRenderer } from '../../core/IGroupingRenderer'; -import { RenderContext } from '../../core/RenderContext'; +import { IEnumerable } from 'ts-linq-light'; +import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer'; +import { NextFunction, RenderData } from '../../core/RenderBuilder'; import { DateService } from '../../core/DateService'; -export class DateRenderer implements IGroupingRenderer { +export class DateRenderer implements IGroupingRenderer { readonly type = 'date'; constructor(private dateService: DateService) {} - render(context: RenderContext): void { - for (const dateStr of context.values) { + render( + dates: IEnumerable, + _data: RenderData, + _next: NextFunction, + context: RenderContext + ): void { + for (const dateStr of dates) { const date = this.dateService.parseISO(dateStr); const headerCell = document.createElement('swp-day-header'); @@ -21,9 +27,10 @@ export class DateRenderer implements IGroupingRenderer { const column = document.createElement('swp-day-column'); column.dataset.date = dateStr; - if (context.parentId) column.dataset.parentId = context.parentId; column.innerHTML = ''; context.columnContainer.appendChild(column); + + // Leaf renderer - ingen next.render() kald } } } diff --git a/src/v2/features/resource/ResourceRenderer.ts b/src/v2/features/resource/ResourceRenderer.ts index b7b9fb4..4b72e71 100644 --- a/src/v2/features/resource/ResourceRenderer.ts +++ b/src/v2/features/resource/ResourceRenderer.ts @@ -1,16 +1,37 @@ -import { IGroupingRenderer } from '../../core/IGroupingRenderer'; -import { RenderContext } from '../../core/RenderContext'; +import { from, IEnumerable } from 'ts-linq-light'; +import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer'; +import { NextFunction, RenderData } from '../../core/RenderBuilder'; -export class ResourceRenderer implements IGroupingRenderer { +interface Resource { + id: string; + name?: string; +} + +export class ResourceRenderer implements IGroupingRenderer { readonly type = 'resource'; - render(context: RenderContext): void { - for (const resourceId of context.values) { + render( + resources: IEnumerable, + data: RenderData, + next: NextFunction, + context: RenderContext + ): void { + const dates = data.dates || from([]); + + for (const resource of resources) { + const colspan = next.count(dates); + const cell = document.createElement('swp-resource-header'); - cell.dataset.resourceId = resourceId; - cell.textContent = resourceId; - if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`; + cell.dataset.resourceId = resource.id; + cell.textContent = resource.name || resource.id; + + if (colspan > 1) { + cell.style.gridColumn = `span ${colspan}`; + } + context.headerContainer.appendChild(cell); + + next.render(dates); } } } diff --git a/src/v2/features/team/TeamRenderer.ts b/src/v2/features/team/TeamRenderer.ts index c1e1d8e..bd74039 100644 --- a/src/v2/features/team/TeamRenderer.ts +++ b/src/v2/features/team/TeamRenderer.ts @@ -1,16 +1,47 @@ -import { IGroupingRenderer } from '../../core/IGroupingRenderer'; -import { RenderContext } from '../../core/RenderContext'; +import { from, IEnumerable } from 'ts-linq-light'; +import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer'; +import { NextFunction, RenderData } from '../../core/RenderBuilder'; -export class TeamRenderer implements IGroupingRenderer { +interface Team { + id: string; + name?: string; +} + +interface Resource { + id: string; + teamId?: string; +} + +export class TeamRenderer implements IGroupingRenderer { readonly type = 'team'; - render(context: RenderContext): void { - for (const teamId of context.values) { + render( + teams: IEnumerable, + data: RenderData, + next: NextFunction, + context: RenderContext + ): void { + const resources = data.resources as IEnumerable || from([]); + const resourcesByTeam = from(resources).groupBy((r: Resource) => r.teamId || ''); + + for (const team of teams) { + const teamResources = from(resourcesByTeam) + .firstOrDefault(g => g.key === team.id); + + const teamResourcesEnum = teamResources ? from(teamResources) : from([]); + const colspan = next.count(teamResourcesEnum); + const cell = document.createElement('swp-team-header'); - cell.dataset.teamId = teamId; - cell.textContent = teamId; - if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`; + cell.dataset.teamId = team.id; + cell.textContent = team.name || team.id; + + if (colspan > 1) { + cell.style.gridColumn = `span ${colspan}`; + } + context.headerContainer.appendChild(cell); + + next.render(teamResourcesEnum); } } } diff --git a/src/v2/index.ts b/src/v2/index.ts index 7ddcc28..ecd176f 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -1,10 +1,10 @@ // Core exports export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; -export { RenderContext } from './core/RenderContext'; -export { IGroupingRenderer } from './core/IGroupingRenderer'; +export { IGroupingRenderer, RenderContext } from './core/IGroupingRenderer'; export { IGroupingStore } from './core/IGroupingStore'; export { CalendarOrchestrator } from './core/CalendarOrchestrator'; export { NavigationAnimator } from './core/NavigationAnimator'; +export { RenderBuilder, NextFunction, RenderData } from './core/RenderBuilder'; // Feature exports export { DateRenderer } from './features/date';