Enhance calendar view with resource-aware rendering

Adds support for filtering events and rendering across multiple views with resource-specific context

Improves event and date rendering to handle resource-based filtering
Introduces day view and extends existing calendar infrastructure to support more flexible view configurations
This commit is contained in:
Janus C. H. Knudsen 2025-12-09 22:31:28 +01:00
parent 6fc9be9534
commit 7f6279a6f3
17 changed files with 570 additions and 38 deletions

View file

@ -15,7 +15,7 @@ import { DemoApp } from './demo/DemoApp';
// Event system
import { EventBus } from './core/EventBus';
import { IEventBus, ICalendarEvent, ISync } from './types/CalendarTypes';
import { IEventBus, ICalendarEvent, ISync, IResource, IBooking, ICustomer } from './types/CalendarTypes';
// Storage
import { IndexedDBContext } from './storage/IndexedDBContext';
@ -23,10 +23,19 @@ import { IStore } from './storage/IStore';
import { IEntityService } from './storage/IEntityService';
import { EventStore } from './storage/events/EventStore';
import { EventService } from './storage/events/EventService';
import { ResourceStore } from './storage/resources/ResourceStore';
import { ResourceService } from './storage/resources/ResourceService';
import { BookingStore } from './storage/bookings/BookingStore';
import { BookingService } from './storage/bookings/BookingService';
import { CustomerStore } from './storage/customers/CustomerStore';
import { CustomerService } from './storage/customers/CustomerService';
// Repositories
import { IApiRepository } from './repositories/IApiRepository';
import { MockEventRepository } from './repositories/MockEventRepository';
import { MockResourceRepository } from './repositories/MockResourceRepository';
import { MockBookingRepository } from './repositories/MockBookingRepository';
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
// Workers
import { DataSeeder } from './workers/DataSeeder';
@ -58,17 +67,43 @@ export function createV2Container(): Container {
// Storage infrastructure
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
builder.registerType(EventStore).as<IStore>();
// Entity services
// Stores (for IndexedDB schema creation)
builder.registerType(EventStore).as<IStore>();
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(BookingStore).as<IStore>();
builder.registerType(CustomerStore).as<IStore>();
// Entity services (for DataSeeder polymorphic array)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(EventService).as<IEntityService<ISync>>();
builder.registerType(EventService).as<EventService>();
// Repositories
builder.registerType(ResourceService).as<IEntityService<IResource>>();
builder.registerType(ResourceService).as<IEntityService<ISync>>();
builder.registerType(ResourceService).as<ResourceService>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(BookingService).as<IEntityService<ISync>>();
builder.registerType(BookingService).as<BookingService>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(CustomerService).as<IEntityService<ISync>>();
builder.registerType(CustomerService).as<CustomerService>();
// Repositories (for DataSeeder polymorphic array)
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
builder.registerType(MockResourceRepository).as<IApiRepository<ISync>>();
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
builder.registerType(MockBookingRepository).as<IApiRepository<ISync>>();
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
builder.registerType(MockCustomerRepository).as<IApiRepository<ISync>>();
// Workers
builder.registerType(DataSeeder).as<DataSeeder>();

View file

@ -43,9 +43,8 @@ export class CalendarOrchestrator {
const pipeline = buildPipeline(activeRenderers);
pipeline.run(context);
// Events
const dates = filter['date'] || [];
await this.eventRenderer.render(container, dates);
// Render events med hele filter (date + resource)
await this.eventRenderer.render(container, filter);
}
private selectRenderers(viewConfig: ViewConfig): Renderer[] {

View file

@ -12,7 +12,7 @@ export class DemoApp {
private animator!: NavigationAnimator;
private container!: HTMLElement;
private weekOffset = 0;
private currentView: 'simple' | 'resource' | 'team' = 'simple';
private currentView: 'day' | 'simple' | 'resource' | 'team' = 'simple';
constructor(
private orchestrator: CalendarOrchestrator,
@ -66,8 +66,18 @@ export class DemoApp {
private buildViewConfig(): ViewConfig {
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
const today = this.dateService.getWeekDates(this.weekOffset, 1);
switch (this.currentView) {
case 'day':
return {
templateId: 'day',
groupings: [
{ type: 'resource', values: ['res1', 'res2'] },
{ type: 'date', values: today }
]
};
case 'simple':
return {
templateId: 'simple',
@ -110,6 +120,11 @@ export class DemoApp {
}
private setupViewSwitching(): void {
document.getElementById('btn-day')?.addEventListener('click', () => {
this.currentView = 'day';
this.render();
});
document.getElementById('btn-simple')?.addEventListener('click', () => {
this.currentView = 'simple';
this.render();

View file

@ -8,16 +8,23 @@ export class DateRenderer implements Renderer {
render(context: RenderContext): void {
const dates = context.filter['date'] || [];
const resourceCount = context.filter['resource']?.length || 1;
const resourceIds = context.filter['resource'] || [];
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
const iterations = resourceIds.length || 1;
for (let r = 0; r < iterations; r++) {
const resourceId = resourceIds[r]; // undefined hvis ingen resources
// Render dates for HVER resource (resourceCount gange)
for (let r = 0; r < resourceCount; r++) {
for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr);
// Header
const header = document.createElement('swp-day-header');
header.dataset.date = dateStr;
if (resourceId) {
header.dataset.resourceId = resourceId;
}
header.innerHTML = `
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
@ -27,6 +34,9 @@ export class DateRenderer implements Renderer {
// Column
const column = document.createElement('swp-day-column');
column.dataset.date = dateStr;
if (resourceId) {
column.dataset.resourceId = resourceId;
}
column.innerHTML = '<swp-events-layer></swp-events-layer>';
context.columnContainer.appendChild(column);
}

View file

@ -21,8 +21,14 @@ 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
*/
async render(container: HTMLElement, visibleDates: string[]): Promise<void> {
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get date range for query
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
@ -31,19 +37,32 @@ export class EventRenderer {
// Fetch events from IndexedDB
const events = await this.eventService.getByDateRange(startDate, endDate);
// Group events by date
const eventsByDate = this.groupEventsByDate(events);
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
// Render events into columns
columns.forEach((column, index) => {
const dateKey = visibleDates[index];
const dateEvents = eventsByDate.get(dateKey) || [];
// Render events into each column based on data attributes
columns.forEach(column => {
const dateKey = (column as HTMLElement).dataset.date;
const columnResourceId = (column as HTMLElement).dataset.resourceId;
if (!dateKey) return;
// Filter events for this column
const columnEvents = events.filter(event => {
// Must match date
if (getDateKey(event.start) !== dateKey) 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
let eventsLayer = column.querySelector('swp-events-layer');
@ -55,8 +74,8 @@ export class EventRenderer {
// Clear existing events
eventsLayer.innerHTML = '';
// Render each event
dateEvents.forEach(event => {
// Render each timed event
columnEvents.forEach(event => {
if (!event.allDay) {
const eventElement = this.createEventElement(event);
eventsLayer!.appendChild(eventElement);
@ -65,22 +84,6 @@ export class EventRenderer {
});
}
/**
* Group events by their date key
*/
private groupEventsByDate(events: ICalendarEvent[]): Map<string, ICalendarEvent[]> {
const map = new Map<string, ICalendarEvent[]>();
events.forEach(event => {
const dateKey = getDateKey(event.start);
const existing = map.get(dateKey) || [];
existing.push(event);
map.set(dateKey, existing);
});
return map;
}
/**
* Create a single event element
*

View file

@ -0,0 +1,73 @@
import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawBookingData {
id: string;
customerId: string;
status: string;
createdAt: string | Date;
services: RawBookingService[];
totalPrice?: number;
tags?: string[];
notes?: string;
[key: string]: unknown;
}
interface RawBookingService {
serviceId: string;
serviceName: string;
baseDuration: number;
basePrice: number;
customPrice?: number;
resourceId: string;
}
/**
* MockBookingRepository - Loads booking data from local JSON file
*/
export class MockBookingRepository implements IApiRepository<IBooking> {
public readonly entityType: EntityType = 'Booking';
private readonly dataUrl = 'data/mock-bookings.json';
public async fetchAll(): Promise<IBooking[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`);
}
const rawData: RawBookingData[] = await response.json();
return this.processBookingData(rawData);
} catch (error) {
console.error('Failed to load booking data:', error);
throw error;
}
}
public async sendCreate(_booking: IBooking): Promise<IBooking> {
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
}
public async sendUpdate(_id: string, _updates: Partial<IBooking>): Promise<IBooking> {
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
}
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
}
private processBookingData(data: RawBookingData[]): IBooking[] {
return data.map((booking): IBooking => ({
id: booking.id,
customerId: booking.customerId,
status: booking.status as BookingStatus,
createdAt: new Date(booking.createdAt),
services: booking.services as IBookingService[],
totalPrice: booking.totalPrice,
tags: booking.tags,
notes: booking.notes,
syncStatus: 'synced' as const
}));
}
}

View file

@ -0,0 +1,58 @@
import { ICustomer, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawCustomerData {
id: string;
name: string;
phone: string;
email?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* MockCustomerRepository - Loads customer data from local JSON file
*/
export class MockCustomerRepository implements IApiRepository<ICustomer> {
public readonly entityType: EntityType = 'Customer';
private readonly dataUrl = 'data/mock-customers.json';
public async fetchAll(): Promise<ICustomer[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`);
}
const rawData: RawCustomerData[] = await response.json();
return this.processCustomerData(rawData);
} catch (error) {
console.error('Failed to load customer data:', error);
throw error;
}
}
public async sendCreate(_customer: ICustomer): Promise<ICustomer> {
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
}
public async sendUpdate(_id: string, _updates: Partial<ICustomer>): Promise<ICustomer> {
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
}
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
}
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
return data.map((customer): ICustomer => ({
id: customer.id,
name: customer.name,
phone: customer.phone,
email: customer.email,
metadata: customer.metadata,
syncStatus: 'synced' as const
}));
}
}

View file

@ -0,0 +1,64 @@
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawResourceData {
id: string;
name: string;
displayName: string;
type: string;
avatarUrl?: string;
color?: string;
isActive?: boolean;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* MockResourceRepository - Loads resource data from local JSON file
*/
export class MockResourceRepository implements IApiRepository<IResource> {
public readonly entityType: EntityType = 'Resource';
private readonly dataUrl = 'data/mock-resources.json';
public async fetchAll(): Promise<IResource[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`);
}
const rawData: RawResourceData[] = await response.json();
return this.processResourceData(rawData);
} catch (error) {
console.error('Failed to load resource data:', error);
throw error;
}
}
public async sendCreate(_resource: IResource): Promise<IResource> {
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
}
public async sendUpdate(_id: string, _updates: Partial<IResource>): Promise<IResource> {
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
}
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
}
private processResourceData(data: RawResourceData[]): IResource[] {
return data.map((resource): IResource => ({
id: resource.id,
name: resource.name,
displayName: resource.displayName,
type: resource.type as ResourceType,
avatarUrl: resource.avatarUrl,
color: resource.color,
isActive: resource.isActive,
metadata: resource.metadata,
syncStatus: 'synced' as const
}));
}
}

View file

@ -10,7 +10,7 @@ import { IStore } from './IStore';
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarV2DB';
private static readonly DB_VERSION = 1;
private static readonly DB_VERSION = 2;
private db: IDBDatabase | null = null;
private initialized: boolean = false;

View file

@ -0,0 +1,75 @@
import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*/
export class BookingService extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(booking: IBooking): unknown {
return {
...booking,
createdAt: booking.createdAt.toISOString()
};
}
protected deserialize(data: unknown): IBooking {
const raw = data as Record<string, unknown>;
return {
...raw,
createdAt: new Date(raw.createdAt as string)
} as IBooking;
}
/**
* Get bookings for a customer
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get bookings by status
*/
async getByStatus(status: BookingStatus): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('status');
const request = index.getAll(status);
request.onsuccess = () => {
const data = request.result as unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,18 @@
import { IStore } from '../IStore';
/**
* BookingStore - IndexedDB ObjectStore definition for bookings
*/
export class BookingStore implements IStore {
static readonly STORE_NAME = 'bookings';
readonly storeName = BookingStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('customerId', 'customerId', { unique: false });
store.createIndex('status', 'status', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
}

View file

@ -0,0 +1,46 @@
import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*/
export class CustomerService extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Search customers by name (case-insensitive contains)
*/
async searchByName(query: string): Promise<ICustomer[]> {
const all = await this.getAll();
const lowerQuery = query.toLowerCase();
return all.filter(c => c.name.toLowerCase().includes(lowerQuery));
}
/**
* Find customer by phone
*/
async getByPhone(phone: string): Promise<ICustomer | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.get(phone);
request.onsuccess = () => {
const data = request.result;
resolve(data ? (data as ICustomer) : null);
};
request.onerror = () => {
reject(new Error(`Failed to find customer by phone ${phone}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IStore } from '../IStore';
/**
* CustomerStore - IndexedDB ObjectStore definition for customers
*/
export class CustomerStore implements IStore {
static readonly STORE_NAME = 'customers';
readonly storeName = CustomerStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('phone', 'phone', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -0,0 +1,45 @@
import { IResource, EntityType, IEventBus } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*/
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get all active resources
*/
async getActive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive !== false);
}
/**
* Get resources by 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 = () => {
const data = request.result as IResource[];
resolve(data);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*/
export class ResourceStore implements IStore {
static readonly STORE_NAME = 'resources';
readonly storeName = ResourceStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('type', 'type', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('isActive', 'isActive', { unique: false });
}
}

View file

@ -84,3 +84,59 @@ export interface IEntityDeletedPayload {
entityType: EntityType;
id: string;
}
// Resource types
export type ResourceType =
| 'person'
| 'room'
| 'equipment'
| 'vehicle'
| 'custom';
export interface IResource extends ISync {
id: string;
name: string;
displayName: string;
type: ResourceType;
avatarUrl?: string;
color?: string;
isActive?: boolean;
metadata?: Record<string, unknown>;
}
// Booking types
export type BookingStatus =
| 'created'
| 'arrived'
| 'paid'
| 'noshow'
| 'cancelled';
export interface IBookingService {
serviceId: string;
serviceName: string;
baseDuration: number;
basePrice: number;
customPrice?: number;
resourceId: string;
}
export interface IBooking extends ISync {
id: string;
customerId: string;
status: BookingStatus;
createdAt: Date;
services: IBookingService[];
totalPrice?: number;
tags?: string[];
notes?: string;
}
// Customer types
export interface ICustomer extends ISync {
id: string;
name: string;
phone: string;
email?: string;
metadata?: Record<string, unknown>;
}