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
This commit is contained in:
Janus C. H. Knudsen 2025-12-15 00:33:27 +01:00
parent c2f7564f8e
commit dd647acab8
8 changed files with 331 additions and 41 deletions

180
docs/filter-template.md Normal file
View file

@ -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)

View file

@ -4,13 +4,16 @@ import { EventRenderer } from '../features/event/EventRenderer';
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer'; import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer'; import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { ViewConfig } from './ViewConfig'; import { ViewConfig } from './ViewConfig';
import { FilterTemplate } from './FilterTemplate';
import { DateService } from './DateService';
export class CalendarOrchestrator { export class CalendarOrchestrator {
constructor( constructor(
private allRenderers: IRenderer[], private allRenderers: IRenderer[],
private eventRenderer: EventRenderer, private eventRenderer: EventRenderer,
private scheduleRenderer: ScheduleRenderer, private scheduleRenderer: ScheduleRenderer,
private headerDrawerRenderer: HeaderDrawerRenderer private headerDrawerRenderer: HeaderDrawerRenderer,
private dateService: DateService
) {} ) {}
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> { async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
@ -26,6 +29,12 @@ export class CalendarOrchestrator {
filter[grouping.type] = grouping.values; 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 }; const context: IRenderContext = { headerContainer, columnContainer, filter };
// Clear // Clear
@ -50,11 +59,11 @@ export class CalendarOrchestrator {
// Render schedule unavailable zones (før events) // Render schedule unavailable zones (før events)
await this.scheduleRenderer.render(container, filter); await this.scheduleRenderer.render(container, filter);
// Render timed events in grid // Render timed events in grid (med filterTemplate til matching)
await this.eventRenderer.render(container, filter); await this.eventRenderer.render(container, filter, filterTemplate);
// Render allDay events in header drawer // Render allDay events in header drawer (med filterTemplate til matching)
await this.headerDrawerRenderer.render(container, filter); await this.headerDrawerRenderer.render(container, filter, filterTemplate);
} }
private selectRenderers(viewConfig: ViewConfig): IRenderer[] { private selectRenderers(viewConfig: ViewConfig): IRenderer[] {

View file

@ -11,9 +11,26 @@ dayjs.extend(isoWeek);
export class DateService { export class DateService {
private timezone: string; private timezone: string;
private baseDate: dayjs.Dayjs;
constructor(private config: ITimeFormatConfig) { constructor(private config: ITimeFormatConfig, baseDate?: Date) {
this.timezone = config.timezone; 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 { parseISO(isoString: string): Date {
@ -25,7 +42,7 @@ export class DateService {
} }
getWeekDates(offset = 0, days = 7): string[] { 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) => return Array.from({ length: days }, (_, i) =>
monday.add(i, 'day').format('YYYY-MM-DD') monday.add(i, 'day').format('YYYY-MM-DD')
); );

View file

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

View file

@ -12,5 +12,7 @@ export interface ViewConfig {
export interface GroupingConfig { export interface GroupingConfig {
type: string; type: string;
values: 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; parentKey?: string;
} }

View file

@ -37,6 +37,9 @@ export class DemoApp {
) {} ) {}
async init(): Promise<void> { async init(): Promise<void> {
// Set base date to match mock data (8. december 2025 = mandag)
this.dateService.setBaseDate(new Date('2025-12-08'));
// Initialize IndexedDB // Initialize IndexedDB
await this.indexedDBContext.initialize(); await this.indexedDBContext.initialize();
console.log('[DemoApp] IndexedDB initialized'); console.log('[DemoApp] IndexedDB initialized');
@ -95,8 +98,8 @@ export class DemoApp {
return { return {
templateId: 'day', templateId: 'day',
groupings: [ groupings: [
{ type: 'resource', values: ['EMP001', 'EMP002'] }, { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' },
{ type: 'date', values: today } { type: 'date', values: today, idProperty: 'date', derivedFrom: 'start' }
] ]
}; };
@ -104,7 +107,7 @@ export class DemoApp {
return { return {
templateId: 'simple', templateId: 'simple',
groupings: [ groupings: [
{ type: 'date', values: dates } { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
] ]
}; };
@ -112,8 +115,8 @@ export class DemoApp {
return { return {
templateId: 'resource', templateId: 'resource',
groupings: [ groupings: [
{ type: 'resource', values: ['EMP001', 'EMP002'] }, { type: 'resource', values: ['EMP001', 'EMP002'], idProperty: 'resourceId' },
{ type: 'date', values: dates } { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
] ]
}; };
@ -121,9 +124,9 @@ export class DemoApp {
return { return {
templateId: 'team', templateId: 'team',
groupings: [ groupings: [
{ type: 'team', values: ['team1', 'team2'] }, { type: 'team', values: ['team1', 'team2'], idProperty: 'teamId' },
{ type: 'resource', values: ['res1', 'res2', 'res3'] }, { type: 'resource', values: ['res1', 'res2', 'res3'], idProperty: 'resourceId' },
{ type: 'date', values: dates } { type: 'date', values: dates, idProperty: 'date', derivedFrom: 'start' }
] ]
}; };
} }

View file

@ -7,6 +7,7 @@ import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes'; import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine'; import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes'; import { IGridGroupLayout } from './EventLayoutTypes';
import { FilterTemplate } from '../../core/FilterTemplate';
/** /**
* EventRenderer - Renders calendar events to the DOM * EventRenderer - Renders calendar events to the DOM
@ -238,8 +239,9 @@ export class EventRenderer {
* Render events for visible dates into day columns * Render events for visible dates into day columns
* @param container - Calendar container element * @param container - Calendar container element
* @param filter - Filter with 'date' and optionally 'resource' arrays * @param filter - Filter with 'date' and optionally 'resource' arrays
* @param filterTemplate - Template for matching events to columns
*/ */
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> { async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store container reference for later re-renders // Store container reference for later re-renders
this.container = container; this.container = container;
@ -261,26 +263,12 @@ export class EventRenderer {
const columns = dayColumns.querySelectorAll('swp-day-column'); 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 => { columns.forEach(column => {
const date = (column as HTMLElement).dataset.date; const columnEl = column as HTMLElement;
const columnResourceId = (column as HTMLElement).dataset.resourceId;
if (!date) return; // Use FilterTemplate for matching - only fields in template are checked
const columnEvents = events.filter(event => filterTemplate.matches(event, columnEl));
// 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;
});
// Get or create events layer // Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer'); let eventsLayer = column.querySelector('swp-events-layer');

View file

@ -4,6 +4,7 @@ import { CoreEvents } from '../../constants/CoreEvents';
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager'; import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
import { EventService } from '../../storage/events/EventService'; import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService'; import { DateService } from '../../core/DateService';
import { FilterTemplate } from '../../core/FilterTemplate';
import { import {
IDragEnterHeaderPayload, IDragEnterHeaderPayload,
IDragMoveHeaderPayload, IDragMoveHeaderPayload,
@ -36,6 +37,7 @@ export class HeaderDrawerRenderer {
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private sourceElement: HTMLElement | null = null; private sourceElement: HTMLElement | null = null;
private wasExpandedBeforeDrag = false; private wasExpandedBeforeDrag = false;
private filterTemplate: FilterTemplate | null = null;
constructor( constructor(
private eventBus: IEventBus, private eventBus: IEventBus,
@ -49,8 +51,12 @@ export class HeaderDrawerRenderer {
/** /**
* Render allDay events into the header drawer with row stacking * Render allDay events into the header drawer with row stacking
* @param filterTemplate - Template for matching events to columns
*/ */
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> { async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store filterTemplate for buildColumnKeyFromEvent
this.filterTemplate = filterTemplate;
const drawer = container.querySelector('swp-header-drawer'); const drawer = container.querySelector('swp-header-drawer');
if (!drawer) return; if (!drawer) return;
@ -150,14 +156,24 @@ export class HeaderDrawerRenderer {
} }
/** /**
* Build columnKey from event fields * Build columnKey from event using FilterTemplate
* This is the only place we construct columnKey from event data * Uses the same template that columns use for matching
*/ */
private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string { private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string {
const dateStr = this.dateService.getDateKey(date || event.start); if (!this.filterTemplate) {
const segments: Record<string, string> = { date: dateStr }; // Fallback if no template - shouldn't happen in normal flow
if (event.resourceId) segments.resource = event.resourceId; const dateStr = this.dateService.getDateKey(date || event.start);
return this.dateService.buildColumnKey(segments); 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);
} }
/** /**