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:
parent
c2f7564f8e
commit
dd647acab8
8 changed files with 331 additions and 41 deletions
180
docs/filter-template.md
Normal file
180
docs/filter-template.md
Normal 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)
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
75
src/v2/core/FilterTemplate.ts
Normal file
75
src/v2/core/FilterTemplate.ts
Normal 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 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue