From dd647acab8bf57334973b2f161b23534a4b00e59 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 15 Dec 2025 00:33:27 +0100 Subject: [PATCH] Implements FilterTemplate system for event matching Introduces flexible key-based filtering for calendar events across different view configurations Adds new FilterTemplate class to: - Define event matching rules based on view configuration - Support multi-level grouping (team/resource/date) - Handle dynamic key generation for columns and events Enhances view configuration with explicit id properties and derived fields --- docs/filter-template.md | 180 ++++++++++++++++++ src/v2/core/CalendarOrchestrator.ts | 19 +- src/v2/core/DateService.ts | 21 +- src/v2/core/FilterTemplate.ts | 75 ++++++++ src/v2/core/ViewConfig.ts | 2 + src/v2/demo/DemoApp.ts | 19 +- src/v2/features/event/EventRenderer.ts | 26 +-- .../headerdrawer/HeaderDrawerRenderer.ts | 30 ++- 8 files changed, 331 insertions(+), 41 deletions(-) create mode 100644 docs/filter-template.md create mode 100644 src/v2/core/FilterTemplate.ts diff --git a/docs/filter-template.md b/docs/filter-template.md new file mode 100644 index 0000000..e7a2daa --- /dev/null +++ b/docs/filter-template.md @@ -0,0 +1,180 @@ +# FilterTemplate System + +## Problem + +En kolonne har en unik nøgle baseret på view-konfigurationen (f.eks. team + resource + date). +Events skal matches mod denne nøgle - men kun på de felter viewet definerer. + +## Løsning: FilterTemplate + +ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle. +Samme template bruges til at bygge nøgle for både kolonne og event. + +**Princip:** Kolonnens nøgle-template bestemmer hvad der matches på. + +--- + +## ViewConfig med idProperty + +ViewConfig er kilden til sandhed - den definerer grupper OG deres relations-id property. + +```typescript +interface GroupingConfig { + type: string; // 'team', 'resource', 'date' + values: string[]; // ['EMP001', 'EMP002'] + idProperty: string; // property-navn på event (eks. 'resourceId') + derivedFrom?: string; // for date: udledes fra 'start' +} +``` + +### Eksempler + +**Team → Resource → Date view:** +```typescript +{ + groupings: [ + { type: 'team', values: ['team-a'], idProperty: 'teamId' }, + { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' }, + { type: 'date', values: ['2025-12-09', '2025-12-10'], idProperty: 'date', derivedFrom: 'start' } + ] +} +``` + +**Simple date-only view:** +```typescript +{ + groupings: [ + { type: 'date', values: ['2025-12-09', '2025-12-10'], idProperty: 'date', derivedFrom: 'start' } + ] +} +``` + +--- + +## FilterTemplate Klasse + +```typescript +class FilterTemplate { + private fields: Array<{ + idProperty: string; + derivedFrom?: string; + }> = []; + + addField(idProperty: string, derivedFrom?: string): this { + this.fields.push({ idProperty, derivedFrom }); + return this; + } + + buildKeyFromColumn(column: HTMLElement): string { + return this.fields + .map(f => column.dataset[f.idProperty] || '') + .join(':'); + } + + buildKeyFromEvent(event: ICalendarEvent, dateService: DateService): string { + return this.fields + .map(f => { + if (f.derivedFrom) { + return dateService.getDateKey((event as any)[f.derivedFrom]); + } + return (event as any)[f.idProperty] || ''; + }) + .join(':'); + } +} +``` + +--- + +## Flow + +``` +Orchestrator + │ + ├── Læs ViewConfig.groupings + │ + ├── Byg FilterTemplate fra groupings: + │ for (grouping of viewConfig.groupings) { + │ template.addField(grouping.idProperty, grouping.derivedFrom); + │ } + │ + ├── Kør group-renderers (bygger headers + kolonner) + │ └── DateRenderer sætter column.dataset[idProperty] for ALLE grupperinger + │ + └── EventRenderer.render(ctx, template) + │ + └── for each column: + columnKey = template.buildKeyFromColumn(column) + columnEvents = events.filter(e => + template.buildKeyFromEvent(e) === columnKey + ) +``` + +--- + +## Eksempler + +### 3-niveau view: Team → Resource → Date + +**ViewConfig:** +```typescript +groupings: [ + { type: 'team', values: ['team-a'], idProperty: 'teamId' }, + { type: 'resource', values: ['EMP001'], idProperty: 'resourceId' }, + { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' } +] +``` + +**Template:** `['teamId', 'resourceId', 'date']` + +**Kolonne-nøgle:** `"team-a:EMP001:2025-12-09"` +**Event-nøgle:** `"team-a:EMP001:2025-12-09"` + +**Match!** + +--- + +### 2-niveau view: Resource → Date + +**ViewConfig:** +```typescript +groupings: [ + { type: 'resource', values: ['EMP001'], idProperty: 'resourceId' }, + { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' } +] +``` + +**Template:** `['resourceId', 'date']` + +**Kolonne-nøgle:** `"EMP001:2025-12-09"` +**Event-nøgle:** `"EMP001:2025-12-09"` (teamId ignoreres - ikke i template) + +**Match!** + +--- + +### 1-niveau view: Kun Date + +**ViewConfig:** +```typescript +groupings: [ + { type: 'date', values: ['2025-12-09'], idProperty: 'date', derivedFrom: 'start' } +] +``` + +**Template:** `['date']` + +**Kolonne-nøgle:** `"2025-12-09"` +**Event-nøgle:** `"2025-12-09"` (alle andre felter ignoreres) + +**Match!** Samme event vises i alle views - kun de relevante felter indgår i matching. + +--- + +## Kerneprincipper + +1. **ViewConfig definerer nøgle-template** - hvilke idProperties der indgår +2. **Samme template til kolonne og event** - sikrer konsistent matching +3. **Felter udenfor template ignoreres** - event med ekstra felter matcher stadig +4. **idProperty** - eksplicit mapping mellem gruppering og event-felt +5. **derivedFrom** - håndterer felter der udledes (f.eks. date fra start) diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index c0d60e2..3e7a756 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -4,13 +4,16 @@ import { EventRenderer } from '../features/event/EventRenderer'; import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer'; import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer'; import { ViewConfig } from './ViewConfig'; +import { FilterTemplate } from './FilterTemplate'; +import { DateService } from './DateService'; export class CalendarOrchestrator { constructor( private allRenderers: IRenderer[], private eventRenderer: EventRenderer, private scheduleRenderer: ScheduleRenderer, - private headerDrawerRenderer: HeaderDrawerRenderer + private headerDrawerRenderer: HeaderDrawerRenderer, + private dateService: DateService ) {} async render(viewConfig: ViewConfig, container: HTMLElement): Promise { @@ -26,6 +29,12 @@ export class CalendarOrchestrator { filter[grouping.type] = grouping.values; } + // Byg FilterTemplate fra viewConfig groupings + const filterTemplate = new FilterTemplate(this.dateService); + for (const grouping of viewConfig.groupings) { + filterTemplate.addField(grouping.idProperty, grouping.derivedFrom); + } + const context: IRenderContext = { headerContainer, columnContainer, filter }; // Clear @@ -50,11 +59,11 @@ export class CalendarOrchestrator { // Render schedule unavailable zones (før events) await this.scheduleRenderer.render(container, filter); - // Render timed events in grid - await this.eventRenderer.render(container, filter); + // Render timed events in grid (med filterTemplate til matching) + await this.eventRenderer.render(container, filter, filterTemplate); - // Render allDay events in header drawer - await this.headerDrawerRenderer.render(container, filter); + // Render allDay events in header drawer (med filterTemplate til matching) + await this.headerDrawerRenderer.render(container, filter, filterTemplate); } private selectRenderers(viewConfig: ViewConfig): IRenderer[] { diff --git a/src/v2/core/DateService.ts b/src/v2/core/DateService.ts index 265e6e3..f230378 100644 --- a/src/v2/core/DateService.ts +++ b/src/v2/core/DateService.ts @@ -11,9 +11,26 @@ dayjs.extend(isoWeek); export class DateService { private timezone: string; + private baseDate: dayjs.Dayjs; - constructor(private config: ITimeFormatConfig) { + constructor(private config: ITimeFormatConfig, baseDate?: Date) { this.timezone = config.timezone; + // Allow setting a fixed base date for demo/testing purposes + this.baseDate = baseDate ? dayjs(baseDate) : dayjs(); + } + + /** + * Set a fixed base date (useful for demos with static mock data) + */ + setBaseDate(date: Date): void { + this.baseDate = dayjs(date); + } + + /** + * Get the current base date (either fixed or today) + */ + getBaseDate(): Date { + return this.baseDate.toDate(); } parseISO(isoString: string): Date { @@ -25,7 +42,7 @@ export class DateService { } getWeekDates(offset = 0, days = 7): string[] { - const monday = dayjs().startOf('week').add(1, 'day').add(offset, 'week'); + const monday = this.baseDate.startOf('week').add(1, 'day').add(offset, 'week'); return Array.from({ length: days }, (_, i) => monday.add(i, 'day').format('YYYY-MM-DD') ); diff --git a/src/v2/core/FilterTemplate.ts b/src/v2/core/FilterTemplate.ts new file mode 100644 index 0000000..b838337 --- /dev/null +++ b/src/v2/core/FilterTemplate.ts @@ -0,0 +1,75 @@ +import { ICalendarEvent } from '../types/CalendarTypes'; +import { DateService } from './DateService'; + +/** + * Field definition for FilterTemplate + */ +interface IFilterField { + idProperty: string; + derivedFrom?: string; +} + +/** + * FilterTemplate - Bygger nøgler til event-kolonne matching + * + * ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle. + * Samme template bruges til at bygge nøgle for både kolonne og event. + * + * Princip: Kolonnens nøgle-template bestemmer hvad der matches på. + * + * @see docs/filter-template.md + */ +export class FilterTemplate { + private fields: IFilterField[] = []; + + constructor(private dateService: DateService) {} + + /** + * Tilføj felt til template + * @param idProperty - Property-navn (bruges på både event og column.dataset) + * @param derivedFrom - Hvis feltet udledes fra anden property (f.eks. date fra start) + */ + addField(idProperty: string, derivedFrom?: string): this { + this.fields.push({ idProperty, derivedFrom }); + return this; + } + + /** + * Byg nøgle fra kolonne + * Læser værdier fra column.dataset[idProperty] + */ + buildKeyFromColumn(column: HTMLElement): string { + return this.fields + .map(f => column.dataset[f.idProperty] || '') + .join(':'); + } + + /** + * Byg nøgle fra event + * Læser værdier fra event[idProperty] eller udleder fra derivedFrom + */ + buildKeyFromEvent(event: ICalendarEvent): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventRecord = event as any; + return this.fields + .map(f => { + if (f.derivedFrom) { + // Udled værdi (f.eks. date fra start) + const sourceValue = eventRecord[f.derivedFrom]; + if (sourceValue instanceof Date) { + return this.dateService.getDateKey(sourceValue); + } + return String(sourceValue || ''); + } + return String(eventRecord[f.idProperty] || ''); + }) + .join(':'); + } + + /** + * Match event mod kolonne + */ + matches(event: ICalendarEvent, column: HTMLElement): boolean { + return this.buildKeyFromEvent(event) === this.buildKeyFromColumn(column); + } +} diff --git a/src/v2/core/ViewConfig.ts b/src/v2/core/ViewConfig.ts index 99535e2..074943c 100644 --- a/src/v2/core/ViewConfig.ts +++ b/src/v2/core/ViewConfig.ts @@ -12,5 +12,7 @@ export interface ViewConfig { export interface GroupingConfig { type: string; values: string[]; + idProperty: string; // Property-navn på event (f.eks. 'resourceId', 'teamId') + derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start') parentKey?: string; } diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index b5a1464..3f83c36 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -37,6 +37,9 @@ export class DemoApp { ) {} async init(): Promise { + // Set base date to match mock data (8. december 2025 = mandag) + this.dateService.setBaseDate(new Date('2025-12-08')); + // Initialize IndexedDB await this.indexedDBContext.initialize(); console.log('[DemoApp] IndexedDB initialized'); @@ -95,8 +98,8 @@ export class DemoApp { return { templateId: 'day', groupings: [ - { type: 'resource', values: ['EMP001', 'EMP002'] }, - { type: 'date', values: today } + { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' }, + { type: 'date', values: today, idProperty: 'date', derivedFrom: 'start' } ] }; @@ -104,7 +107,7 @@ export class DemoApp { return { templateId: 'simple', groupings: [ - { type: 'date', values: dates } + { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } ] }; @@ -112,8 +115,8 @@ export class DemoApp { return { templateId: 'resource', groupings: [ - { type: 'resource', values: ['EMP001', 'EMP002'] }, - { type: 'date', values: dates } + { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' }, + { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } ] }; @@ -121,9 +124,9 @@ export class DemoApp { return { templateId: 'team', groupings: [ - { type: 'team', values: ['team1', 'team2'] }, - { type: 'resource', values: ['res1', 'res2', 'res3'] }, - { type: 'date', values: dates } + { type: 'team', values: ['team1', 'team2'], idProperty: 'teamId' }, + { type: 'resource', values: ['res1', 'res2', 'res3'], idProperty: 'resourceId' }, + { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' } ] }; } diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts index 630e3bf..6cc1b96 100644 --- a/src/v2/features/event/EventRenderer.ts +++ b/src/v2/features/event/EventRenderer.ts @@ -7,6 +7,7 @@ import { CoreEvents } from '../../constants/CoreEvents'; import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes'; import { calculateColumnLayout } from './EventLayoutEngine'; import { IGridGroupLayout } from './EventLayoutTypes'; +import { FilterTemplate } from '../../core/FilterTemplate'; /** * EventRenderer - Renders calendar events to the DOM @@ -238,8 +239,9 @@ export class EventRenderer { * Render events for visible dates into day columns * @param container - Calendar container element * @param filter - Filter with 'date' and optionally 'resource' arrays + * @param filterTemplate - Template for matching events to columns */ - async render(container: HTMLElement, filter: Record): Promise { + async render(container: HTMLElement, filter: Record, filterTemplate: FilterTemplate): Promise { // Store container reference for later re-renders this.container = container; @@ -261,26 +263,12 @@ export class EventRenderer { const columns = dayColumns.querySelectorAll('swp-day-column'); - // Render events into each column based on data attributes + // Render events into each column based on FilterTemplate matching columns.forEach(column => { - const date = (column as HTMLElement).dataset.date; - const columnResourceId = (column as HTMLElement).dataset.resourceId; + const columnEl = column as HTMLElement; - if (!date) return; - - // Filter events for this column - const columnEvents = events.filter(event => { - // Must match date - if (this.dateService.getDateKey(event.start) !== date) return false; - - // If column has resourceId, event must match - if (columnResourceId && event.resourceId !== columnResourceId) return false; - - // If no resourceId on column but resources in filter, show all - // (this handles 'simple' view without resources) - - return true; - }); + // Use FilterTemplate for matching - only fields in template are checked + const columnEvents = events.filter(event => filterTemplate.matches(event, columnEl)); // Get or create events layer let eventsLayer = column.querySelector('swp-events-layer'); diff --git a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts index 8a2531f..6eaa6f7 100644 --- a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts +++ b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts @@ -4,6 +4,7 @@ import { CoreEvents } from '../../constants/CoreEvents'; import { HeaderDrawerManager } from '../../core/HeaderDrawerManager'; import { EventService } from '../../storage/events/EventService'; import { DateService } from '../../core/DateService'; +import { FilterTemplate } from '../../core/FilterTemplate'; import { IDragEnterHeaderPayload, IDragMoveHeaderPayload, @@ -36,6 +37,7 @@ export class HeaderDrawerRenderer { private container: HTMLElement | null = null; private sourceElement: HTMLElement | null = null; private wasExpandedBeforeDrag = false; + private filterTemplate: FilterTemplate | null = null; constructor( private eventBus: IEventBus, @@ -49,8 +51,12 @@ export class HeaderDrawerRenderer { /** * Render allDay events into the header drawer with row stacking + * @param filterTemplate - Template for matching events to columns */ - async render(container: HTMLElement, filter: Record): Promise { + async render(container: HTMLElement, filter: Record, filterTemplate: FilterTemplate): Promise { + // Store filterTemplate for buildColumnKeyFromEvent + this.filterTemplate = filterTemplate; + const drawer = container.querySelector('swp-header-drawer'); if (!drawer) return; @@ -150,14 +156,24 @@ export class HeaderDrawerRenderer { } /** - * Build columnKey from event fields - * This is the only place we construct columnKey from event data + * Build columnKey from event using FilterTemplate + * Uses the same template that columns use for matching */ private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string { - const dateStr = this.dateService.getDateKey(date || event.start); - const segments: Record = { date: dateStr }; - if (event.resourceId) segments.resource = event.resourceId; - return this.dateService.buildColumnKey(segments); + if (!this.filterTemplate) { + // Fallback if no template - shouldn't happen in normal flow + const dateStr = this.dateService.getDateKey(date || event.start); + return dateStr; + } + + // For multi-day events, we need to override the date in the event + if (date && date.getTime() !== event.start.getTime()) { + // Create temporary event with overridden start for key generation + const tempEvent = { ...event, start: date }; + return this.filterTemplate.buildKeyFromEvent(tempEvent); + } + + return this.filterTemplate.buildKeyFromEvent(event); } /**