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

View file

@ -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<void> {
@ -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[] {

View file

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

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 {
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;
}

View file

@ -37,6 +37,9 @@ export class DemoApp {
) {}
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
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' }
]
};
}

View file

@ -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<string, string[]>): Promise<void> {
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// 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');

View file

@ -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<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');
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<string, string> = { 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);
}
/**