Introduces CalendarApp with centralized event-driven rendering

Refactors calendar component initialization to a single, encapsulated entry point

Simplifies host application integration by:
- Centralizing complex setup in CalendarApp
- Implementing command-driven rendering via custom events
- Providing flexible, zero-knowledge calendar component
- Maintaining existing ViewConfig contract
This commit is contained in:
Janus C. H. Knudsen 2025-12-16 07:35:29 +01:00
parent 2c1af56718
commit 6a56396721
5 changed files with 815 additions and 63 deletions

View file

@ -0,0 +1,91 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
/**
* Entity must have id
*/
export interface IGroupingEntity {
id: string;
}
/**
* Configuration for a grouping renderer
*/
export interface IGroupingRendererConfig {
elementTag: string; // e.g., 'swp-team-header'
idAttribute: string; // e.g., 'teamId' -> data-team-id
colspanVar: string; // e.g., '--team-cols'
}
/**
* Abstract base class for grouping renderers
*
* Handles:
* - Fetching entities by IDs
* - Calculating colspan from parentChildMap
* - Creating header elements
* - Appending to container
*
* Subclasses override:
* - renderHeader() for custom content
* - getDisplayName() for entity display text
*/
export abstract class BaseGroupingRenderer<T extends IGroupingEntity> implements IRenderer {
abstract readonly type: string;
protected abstract readonly config: IGroupingRendererConfig;
/**
* Fetch entities from service
*/
protected abstract getEntities(ids: string[]): Promise<T[]>;
/**
* Get display name for entity
*/
protected abstract getDisplayName(entity: T): string;
/**
* Main render method - handles common logic
*/
async render(context: IRenderContext): Promise<void> {
const allowedIds = context.filter[this.type] || [];
if (allowedIds.length === 0) return;
const entities = await this.getEntities(allowedIds);
const dateCount = context.filter['date']?.length || 1;
const childIds = context.childType ? context.filter[context.childType] || [] : [];
for (const entity of entities) {
const entityChildIds = context.parentChildMap?.[entity.id] || [];
const childCount = entityChildIds.filter(id => childIds.includes(id)).length;
const colspan = childCount * dateCount;
const header = document.createElement(this.config.elementTag);
header.dataset[this.config.idAttribute] = entity.id;
header.style.setProperty(this.config.colspanVar, String(colspan));
// Allow subclass to customize header content
this.renderHeader(entity, header, context);
context.headerContainer.appendChild(header);
}
}
/**
* Override this method for custom header rendering
* Default: just sets textContent to display name
*/
protected renderHeader(entity: T, header: HTMLElement, _context: IRenderContext): void {
header.textContent = this.getDisplayName(entity);
}
/**
* Helper to render a single entity header.
* Can be used by subclasses that override render() but want consistent header creation.
*/
protected createHeader(entity: T, context: IRenderContext): HTMLElement {
const header = document.createElement(this.config.elementTag);
header.dataset[this.config.idAttribute] = entity.id;
this.renderHeader(entity, header, context);
return header;
}
}

View file

@ -1,37 +1,25 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { DepartmentService } from '../../storage/departments/DepartmentService';
import { IDepartment } from '../../types/CalendarTypes';
export class DepartmentRenderer implements IRenderer {
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
readonly type = 'department';
constructor(private departmentService: DepartmentService) {}
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-department-header',
idAttribute: 'departmentId',
colspanVar: '--department-cols'
};
async render(context: IRenderContext): Promise<void> {
const allowedIds = context.filter[this.type] || [];
if (allowedIds.length === 0) return;
constructor(private departmentService: DepartmentService) {
super();
}
// Fetch departments from IndexedDB (only for name display)
const departments = await this.departmentService.getByIds(allowedIds);
protected getEntities(ids: string[]): Promise<IDepartment[]> {
return this.departmentService.getByIds(ids);
}
const dateCount = context.filter['date']?.length || 1;
// Get child filter values using childType from context (not hardcoded)
const childIds = context.childType ? context.filter[context.childType] || [] : [];
// Render department headers
for (const dept of departments) {
// Get children from parentChildMap (resolved from belongsTo config)
const deptChildIds = context.parentChildMap?.[dept.id] || [];
// Count children that belong to this department AND are in the filter
const childCount = deptChildIds.filter(id => childIds.includes(id)).length;
const colspan = childCount * dateCount;
const header = document.createElement('swp-department-header');
header.dataset.departmentId = dept.id;
header.textContent = dept.name;
header.style.setProperty('--department-cols', String(colspan));
context.headerContainer.appendChild(header);
}
protected getDisplayName(entity: IDepartment): string {
return entity.name;
}
}

View file

@ -1,11 +1,34 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { ResourceService } from '../../storage/resources/ResourceService';
import { IResource } from '../../types/CalendarTypes';
export class ResourceRenderer implements IRenderer {
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
readonly type = 'resource';
constructor(private resourceService: ResourceService) {}
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-resource-header',
idAttribute: 'resourceId',
colspanVar: '--resource-cols'
};
constructor(private resourceService: ResourceService) {
super();
}
protected getEntities(ids: string[]): Promise<IResource[]> {
return this.resourceService.getByIds(ids);
}
protected getDisplayName(entity: IResource): string {
return entity.displayName;
}
/**
* Override render to handle:
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
*/
async render(context: IRenderContext): Promise<void> {
const resourceIds = context.filter['resource'] || [];
const dateCount = context.filter['date']?.length || 1;
@ -29,7 +52,7 @@ export class ResourceRenderer implements IRenderer {
orderedResourceIds = resourceIds;
}
const resources = await this.resourceService.getByIds(orderedResourceIds);
const resources = await this.getEntities(orderedResourceIds);
// Create a map for quick lookup to preserve order
const resourceMap = new Map(resources.map(r => [r.id, r]));
@ -38,9 +61,7 @@ export class ResourceRenderer implements IRenderer {
const resource = resourceMap.get(resourceId);
if (!resource) continue;
const header = document.createElement('swp-resource-header');
header.dataset.resourceId = resource.id;
header.textContent = resource.displayName;
const header = this.createHeader(resource, context);
header.style.gridColumn = `span ${dateCount}`;
context.headerContainer.appendChild(header);
}

View file

@ -1,37 +1,25 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { TeamService } from '../../storage/teams/TeamService';
import { ITeam } from '../../types/CalendarTypes';
export class TeamRenderer implements IRenderer {
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
readonly type = 'team';
constructor(private teamService: TeamService) {}
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-team-header',
idAttribute: 'teamId',
colspanVar: '--team-cols'
};
async render(context: IRenderContext): Promise<void> {
const allowedIds = context.filter[this.type] || [];
if (allowedIds.length === 0) return;
constructor(private teamService: TeamService) {
super();
}
// Fetch teams from IndexedDB (only for name display)
const teams = await this.teamService.getByIds(allowedIds);
protected getEntities(ids: string[]): Promise<ITeam[]> {
return this.teamService.getByIds(ids);
}
const dateCount = context.filter['date']?.length || 1;
// Get child filter values using childType from context (not hardcoded)
const childIds = context.childType ? context.filter[context.childType] || [] : [];
// Render team headers
for (const team of teams) {
// Get children from parentChildMap (resolved from belongsTo config)
const teamChildIds = context.parentChildMap?.[team.id] || [];
// Count children that belong to this team AND are in the filter
const childCount = teamChildIds.filter(id => childIds.includes(id)).length;
const colspan = childCount * dateCount;
const header = document.createElement('swp-team-header');
header.dataset.teamId = team.id;
header.textContent = team.name;
header.style.setProperty('--team-cols', String(colspan));
context.headerContainer.appendChild(header);
}
protected getDisplayName(entity: ITeam): string {
return entity.name;
}
}