Adds resource-based calendar view mode

Introduces new ResourceColumnDataSource and ResourceHeaderRenderer to support column rendering by resources instead of dates

Enables dynamic calendar mode switching between date and resource views
Updates core managers and services to support async column retrieval
Refactors data source interfaces to use Promise-based methods

Improves calendar flexibility and resource management capabilities
This commit is contained in:
Janus C. H. Knudsen 2025-11-22 19:42:12 +01:00
parent a7d365b186
commit eeaeddeef8
19 changed files with 765 additions and 991 deletions

View file

@ -30,7 +30,7 @@ export class DateColumnDataSource implements IColumnDataSource {
/**
* Get columns (dates) to display
*/
public getColumns(): IColumnInfo[] {
public async getColumns(): Promise<IColumnInfo[]> {
let dates: Date[];
switch (this.currentView) {

View file

@ -0,0 +1,61 @@
import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource';
import { CalendarView } from '../types/CalendarTypes';
import { ResourceService } from '../storage/resources/ResourceService';
/**
* ResourceColumnDataSource - Provides resource-based columns
*
* In resource mode, columns represent resources (people, rooms, etc.)
* instead of dates. Events are still filtered by current date,
* but grouped by resourceId.
*/
export class ResourceColumnDataSource implements IColumnDataSource {
private resourceService: ResourceService;
private currentDate: Date;
private currentView: CalendarView;
constructor(resourceService: ResourceService) {
this.resourceService = resourceService;
this.currentDate = new Date();
this.currentView = 'day';
}
/**
* Get columns (resources) to display
*/
public async getColumns(): Promise<IColumnInfo[]> {
const resources = await this.resourceService.getActive();
return resources.map(resource => ({
identifier: resource.id,
data: resource
}));
}
/**
* Get type of datasource
*/
public getType(): 'date' | 'resource' {
return 'resource';
}
/**
* Update current date (for event filtering)
*/
public setCurrentDate(date: Date): void {
this.currentDate = date;
}
/**
* Update current view
*/
public setCurrentView(view: CalendarView): void {
this.currentView = view;
}
/**
* Get current date (for event filtering)
*/
public getCurrentDate(): Date {
return this.currentDate;
}
}

View file

@ -70,6 +70,9 @@ import { EventStackManager } from './managers/EventStackManager';
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
import { IColumnDataSource } from './types/ColumnDataSource';
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
import { ResourceColumnDataSource } from './datasources/ResourceColumnDataSource';
import { ResourceHeaderRenderer } from './renderers/ResourceHeaderRenderer';
import { ResourceColumnRenderer } from './renderers/ResourceColumnRenderer';
import { IBooking } from './types/BookingTypes';
import { ICustomer } from './types/CustomerTypes';
import { IResource } from './types/ResourceTypes';
@ -137,13 +140,25 @@ async function initializeCalendar(): Promise<void> {
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
// Calendar mode: 'date' or 'resource' (default to resource)
const calendarMode: 'date' | 'resource' = 'resource';
// Register DataSource and HeaderRenderer based on mode
if (calendarMode === 'resource') {
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();
builder.registerType(ResourceHeaderRenderer).as<IHeaderRenderer>();
} else {
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
}
// Register entity services (sync status management)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(ResourceService).as<IEntityService<IResource>>();
builder.registerType(ResourceService).as<ResourceService>();
builder.registerType(AuditService).as<AuditService>();
// Register workers
@ -151,8 +166,12 @@ async function initializeCalendar(): Promise<void> {
builder.registerType(DataSeeder).as<DataSeeder>();
// Register renderers
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
// Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode
if (calendarMode === 'resource') {
builder.registerType(ResourceColumnRenderer).as<IColumnRenderer>();
} else {
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
}
builder.registerType(DateEventRenderer).as<IEventRenderer>();
// Register core services and utilities

View file

@ -89,7 +89,10 @@ export class GridManager {
}
// Get columns from datasource - single source of truth
const columns = this.dataSource.getColumns();
const columns = await this.dataSource.getColumns();
// Set grid columns CSS variable based on actual column count
document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
// Extract dates for EventManager query
const dates = columns.map(col => col.data as Date);

View file

@ -99,7 +99,7 @@ export class HeaderManager {
/**
* Update header content for navigation
*/
private updateHeader(currentDate: Date): void {
private async updateHeader(currentDate: Date): Promise<void> {
console.log('🎯 HeaderManager.updateHeader called', {
currentDate,
rendererType: this.headerRenderer.constructor.name
@ -116,7 +116,7 @@ export class HeaderManager {
// Update DataSource with current date and get columns
this.dataSource.setCurrentDate(currentDate);
const columns = this.dataSource.getColumns();
const columns = await this.dataSource.getColumns();
// Render new header content using injected renderer
const context: IHeaderRenderContext = {

View file

@ -173,7 +173,7 @@ export class NavigationManager {
/**
* Animation transition using pre-rendered containers when available
*/
private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void {
private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise<void> {
const container = document.querySelector('swp-calendar-container') as HTMLElement;
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
@ -194,7 +194,7 @@ export class NavigationManager {
// Update DataSource with target week and get columns
this.dataSource.setCurrentDate(targetWeek);
const columns = this.dataSource.getColumns();
const columns = await this.dataSource.getColumns();
// Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, columns);

View file

@ -0,0 +1,46 @@
import { WorkHoursManager } from '../managers/WorkHoursManager';
import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer';
/**
* Resource-based column renderer
*
* In resource mode, columns represent resources (people, rooms, etc.)
* Work hours are hardcoded (09:00-18:00) for all columns.
* TODO: Each resource should have its own work hours.
*/
export class ResourceColumnRenderer implements IColumnRenderer {
private workHoursManager: WorkHoursManager;
constructor(workHoursManager: WorkHoursManager) {
this.workHoursManager = workHoursManager;
}
render(columnContainer: HTMLElement, context: IColumnRenderContext): void {
const { columns } = context;
// Hardcoded work hours for all resources: 09:00 - 18:00
const workHours = { start: 9, end: 18 };
columns.forEach((columnInfo) => {
const column = document.createElement('swp-day-column');
column.dataset.columnId = columnInfo.identifier;
// Apply hardcoded work hours to all resource columns
this.applyWorkHoursToColumn(column, workHours);
const eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
columnContainer.appendChild(column);
});
}
private applyWorkHoursToColumn(column: HTMLElement, workHours: { start: number; end: number }): void {
const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours);
if (nonWorkStyle) {
column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`);
column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`);
}
}
}

View file

@ -0,0 +1,58 @@
import { IHeaderRenderer, IHeaderRenderContext } from './DateHeaderRenderer';
import { IResource } from '../types/ResourceTypes';
/**
* ResourceHeaderRenderer - Renders resource-based headers
*
* Displays resource information (avatar, name) instead of dates.
* Used in resource mode where columns represent people/rooms/equipment.
*/
export class ResourceHeaderRenderer implements IHeaderRenderer {
render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void {
const { columns } = context;
// Create all-day container (same structure as date mode)
const allDayContainer = document.createElement('swp-allday-container');
calendarHeader.appendChild(allDayContainer);
columns.forEach((columnInfo) => {
const resource = columnInfo.data as IResource;
const header = document.createElement('swp-day-header');
// Build header content
let avatarHtml = '';
if (resource.avatarUrl) {
avatarHtml = `<img class="swp-resource-avatar" src="${resource.avatarUrl}" alt="${resource.displayName}" />`;
} else {
// Fallback: initials
const initials = this.getInitials(resource.displayName);
const bgColor = resource.color || '#6366f1';
avatarHtml = `<span class="swp-resource-initials" style="background-color: ${bgColor}">${initials}</span>`;
}
header.innerHTML = `
<div class="swp-resource-header">
${avatarHtml}
<span class="swp-resource-name">${resource.displayName}</span>
</div>
`;
header.dataset.columnId = columnInfo.identifier;
header.dataset.resourceId = resource.id;
calendarHeader.appendChild(header);
});
}
/**
* Get initials from display name
*/
private getInitials(name: string): string {
return name
.split(' ')
.map(part => part.charAt(0))
.join('')
.toUpperCase()
.substring(0, 2);
}
}

View file

@ -11,7 +11,7 @@ import { EntityType } from '../types/CalendarTypes';
export class MockAuditRepository implements IApiRepository<IAuditEntry> {
readonly entityType: EntityType = 'Audit';
async sendCreate(entity: IAuditEntry): Promise<void> {
async sendCreate(entity: IAuditEntry): Promise<IAuditEntry> {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 100));
@ -22,9 +22,11 @@ export class MockAuditRepository implements IApiRepository<IAuditEntry> {
operation: entity.operation,
timestamp: new Date(entity.timestamp).toISOString()
});
return entity;
}
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<void> {
async sendUpdate(_id: string, entity: IAuditEntry): Promise<IAuditEntry> {
// Audit entries are immutable - updates should not happen
throw new Error('Audit entries cannot be updated');
}

View file

@ -19,7 +19,7 @@ import { IStore } from './IStore';
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarDB';
private static readonly DB_VERSION = 3; // Bumped for audit store
private static readonly DB_VERSION = 5; // Bumped to add syncStatus index to resources
static readonly QUEUE_STORE = 'operationQueue';
static readonly SYNC_STATE_STORE = 'syncState';

View file

@ -31,68 +31,25 @@ export class ResourceService extends BaseEntityService<IResource> {
/**
* Get resources by type
*
* @param type - Resource type (person, room, equipment, etc.)
* @returns Array of resources of this type
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
const all = await this.getAll();
return all.filter(r => r.type === type);
}
/**
* Get active resources only
*
* @returns Array of active resources (isActive = true)
*/
async getActive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(IDBKeyRange.only(true));
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get active resources: ${request.error}`));
};
});
const all = await this.getAll();
return all.filter(r => r.isActive === true);
}
/**
* Get inactive resources
*
* @returns Array of inactive resources (isActive = false)
*/
async getInactive(): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('isActive');
const request = index.getAll(IDBKeyRange.only(false));
request.onsuccess = () => {
resolve(request.result as IResource[]);
};
request.onerror = () => {
reject(new Error(`Failed to get inactive resources: ${request.error}`));
};
});
const all = await this.getAll();
return all.filter(r => r.isActive === false);
}
}

View file

@ -20,16 +20,7 @@ export class ResourceStore implements IStore {
* @param db - IDBDatabase instance
*/
create(db: IDBDatabase): void {
// Create ObjectStore with 'id' as keyPath
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
// Index: type (for filtering by resource category)
store.createIndex('type', 'type', { unique: false });
// Index: isActive (for showing/hiding inactive resources)
store.createIndex('isActive', 'isActive', { unique: false });
// Index: syncStatus (for querying by sync status - used by SyncPlugin)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -21,7 +21,7 @@ export interface IColumnDataSource {
* Get the list of columns to render
* @returns Array of column information
*/
getColumns(): IColumnInfo[];
getColumns(): Promise<IColumnInfo[]>;
/**
* Get the type of columns this datasource provides