Adds resource scheduling and unavailability tracking

Introduces comprehensive schedule management for resources:
- Adds DateService with advanced time and date utilities
- Implements ResourceScheduleService for managing work hours
- Creates ScheduleRenderer to visualize unavailable time zones
- Extends resource model to support default weekly schedules

Enables granular tracking of resource availability and working hours
This commit is contained in:
Janus C. H. Knudsen 2025-12-10 00:27:19 +01:00
parent 400de8c9d5
commit a2b95515fd
17 changed files with 563 additions and 36 deletions

View file

@ -4,6 +4,7 @@ import { IGroupingStore } from './core/IGroupingStore';
import { DateRenderer } from './features/date/DateRenderer';
import { DateService } from './core/DateService';
import { ITimeFormatConfig } from './core/ITimeFormatConfig';
import { IGridConfig } from './core/IGridConfig';
import { ResourceRenderer } from './features/resource/ResourceRenderer';
import { TeamRenderer } from './features/team/TeamRenderer';
import { CalendarOrchestrator } from './core/CalendarOrchestrator';
@ -42,6 +43,12 @@ import { DataSeeder } from './workers/DataSeeder';
// Features
import { EventRenderer } from './features/event/EventRenderer';
import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
// Schedule
import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore';
import { ScheduleOverrideService } from './storage/schedules/ScheduleOverrideService';
import { ResourceScheduleService } from './storage/schedules/ResourceScheduleService';
const defaultTimeFormatConfig: ITimeFormatConfig = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@ -51,12 +58,20 @@ const defaultTimeFormatConfig: ITimeFormatConfig = {
showSeconds: false
};
const defaultGridConfig: IGridConfig = {
hourHeight: 64,
dayStartHour: 6,
dayEndHour: 18,
snapInterval: 15
};
export function createV2Container(): Container {
const container = new Container();
const builder = container.builder();
// Config
builder.registerInstance(defaultTimeFormatConfig).as<ITimeFormatConfig>();
builder.registerInstance(defaultGridConfig).as<IGridConfig>();
// Core - EventBus
builder.registerType(EventBus).as<EventBus>();
@ -73,6 +88,7 @@ export function createV2Container(): Container {
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(BookingStore).as<IStore>();
builder.registerType(CustomerStore).as<IStore>();
builder.registerType(ScheduleOverrideStore).as<IStore>();
// Entity services (for DataSeeder polymorphic array)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
@ -107,8 +123,13 @@ export function createV2Container(): Container {
// Workers
builder.registerType(DataSeeder).as<DataSeeder>();
// Schedule services
builder.registerType(ScheduleOverrideService).as<ScheduleOverrideService>();
builder.registerType(ResourceScheduleService).as<ResourceScheduleService>();
// Features
builder.registerType(EventRenderer).as<EventRenderer>();
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
// Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
builder.registerType(DateRenderer).as<IRenderer>();

View file

@ -1,12 +1,14 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
import { buildPipeline } from './RenderBuilder';
import { EventRenderer } from '../features/event/EventRenderer';
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
import { ViewConfig } from './ViewConfig';
export class CalendarOrchestrator {
constructor(
private allRenderers: IRenderer[],
private eventRenderer: EventRenderer
private eventRenderer: EventRenderer,
private scheduleRenderer: ScheduleRenderer
) {}
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
@ -43,6 +45,9 @@ export class CalendarOrchestrator {
const pipeline = buildPipeline(activeRenderers);
await pipeline.run(context);
// Render schedule unavailable zones (før events)
await this.scheduleRenderer.render(container, filter);
// Render events med hele filter (date + resource)
await this.eventRenderer.render(container, filter);
}

View file

@ -1,8 +1,20 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import isoWeek from 'dayjs/plugin/isoWeek';
import { ITimeFormatConfig } from './ITimeFormatConfig';
// Enable dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isoWeek);
export class DateService {
constructor(private config: ITimeFormatConfig) {}
private timezone: string;
constructor(private config: ITimeFormatConfig) {
this.timezone = config.timezone;
}
parseISO(isoString: string): Date {
return dayjs(isoString).toDate();
@ -18,4 +30,74 @@ export class DateService {
monday.add(i, 'day').format('YYYY-MM-DD')
);
}
// ============================================
// FORMATTING
// ============================================
formatTime(date: Date, showSeconds = false): string {
const pattern = showSeconds ? 'HH:mm:ss' : 'HH:mm';
return dayjs(date).format(pattern);
}
formatTimeRange(start: Date, end: Date): string {
return `${this.formatTime(start)} - ${this.formatTime(end)}`;
}
formatDate(date: Date): string {
return dayjs(date).format('YYYY-MM-DD');
}
getDateKey(date: Date): string {
return this.formatDate(date);
}
// ============================================
// TIME CALCULATIONS
// ============================================
timeToMinutes(timeString: string): number {
const parts = timeString.split(':').map(Number);
const hours = parts[0] || 0;
const minutes = parts[1] || 0;
return hours * 60 + minutes;
}
minutesToTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return dayjs().hour(hours).minute(minutes).format('HH:mm');
}
getMinutesSinceMidnight(date: Date): number {
const d = dayjs(date);
return d.hour() * 60 + d.minute();
}
// ============================================
// UTC CONVERSIONS
// ============================================
toUTC(localDate: Date): string {
return dayjs.tz(localDate, this.timezone).utc().toISOString();
}
fromUTC(utcString: string): Date {
return dayjs.utc(utcString).tz(this.timezone).toDate();
}
// ============================================
// DATE CREATION
// ============================================
createDateAtTime(baseDate: Date | string, timeString: string): Date {
const totalMinutes = this.timeToMinutes(timeString);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return dayjs(baseDate).startOf('day').hour(hours).minute(minutes).toDate();
}
getISOWeekDay(date: Date | string): number {
return dayjs(date).isoWeekday(); // 1=Monday, 7=Sunday
}
}

View file

@ -0,0 +1,6 @@
export interface IGridConfig {
hourHeight: number; // pixels per hour
dayStartHour: number; // e.g. 6
dayEndHour: number; // e.g. 18
snapInterval: number; // minutes, e.g. 15
}

View file

@ -1,6 +1,8 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { calculateEventPosition, getDateKey, formatTimeRange, GridConfig } from '../../utils/PositionUtils';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
/**
* EventRenderer - Renders calendar events to the DOM
@ -11,13 +13,11 @@ import { calculateEventPosition, getDateKey, formatTimeRange, GridConfig } from
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private readonly gridConfig: GridConfig = {
dayStartHour: 6,
dayEndHour: 18,
hourHeight: 64
};
constructor(private eventService: EventService) {}
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig
) {}
/**
* Render events for visible dates into day columns
@ -53,7 +53,7 @@ export class EventRenderer {
// Filter events for this column
const columnEvents = events.filter(event => {
// Must match date
if (getDateKey(event.start) !== dateKey) return false;
if (this.dateService.getDateKey(event.start) !== dateKey) return false;
// If column has resourceId, event must match
if (columnResourceId && event.resourceId !== columnResourceId) return false;
@ -110,7 +110,7 @@ export class EventRenderer {
// Visible content only
element.innerHTML = `
<swp-event-time>${formatTimeRange(event.start, event.end)}</swp-event-time>
<swp-event-time>${this.dateService.formatTimeRange(event.start, event.end)}</swp-event-time>
<swp-event-title>${this.escapeHtml(event.title)}</swp-event-title>
${event.description ? `<swp-event-description>${this.escapeHtml(event.description)}</swp-event-description>` : ''}
`;

View file

@ -0,0 +1,106 @@
import { ResourceScheduleService } from '../../storage/schedules/ResourceScheduleService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { ITimeSlot } from '../../types/ScheduleTypes';
/**
* ScheduleRenderer - Renders unavailable time zones in day columns
*
* Creates visual indicators for times outside the resource's working hours:
* - Before work start (e.g., 06:00 - 09:00)
* - After work end (e.g., 17:00 - 18:00)
* - Full day if resource is off (schedule = null)
*/
export class ScheduleRenderer {
constructor(
private scheduleService: ResourceScheduleService,
private dateService: DateService,
private gridConfig: IGridConfig
) {}
/**
* Render unavailable zones for visible columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and 'resource' arrays
*/
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
const dates = filter['date'] || [];
const resourceIds = filter['resource'] || [];
if (dates.length === 0) return;
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
for (const column of columns) {
const dateKey = (column as HTMLElement).dataset.date;
const resourceId = (column as HTMLElement).dataset.resourceId;
if (!dateKey || !resourceId) continue;
// Get or create unavailable layer
let unavailableLayer = column.querySelector('swp-unavailable-layer');
if (!unavailableLayer) {
unavailableLayer = document.createElement('swp-unavailable-layer');
column.insertBefore(unavailableLayer, column.firstChild);
}
// Clear existing
unavailableLayer.innerHTML = '';
// Get schedule for this resource/date
const schedule = await this.scheduleService.getScheduleForDate(resourceId, dateKey);
// Render unavailable zones
this.renderUnavailableZones(unavailableLayer as HTMLElement, schedule);
}
}
/**
* Render unavailable time zones based on schedule
*/
private renderUnavailableZones(layer: HTMLElement, schedule: ITimeSlot | null): void {
const dayStartMinutes = this.gridConfig.dayStartHour * 60;
const dayEndMinutes = this.gridConfig.dayEndHour * 60;
const minuteHeight = this.gridConfig.hourHeight / 60;
if (schedule === null) {
// Full day unavailable
const zone = this.createUnavailableZone(0, (dayEndMinutes - dayStartMinutes) * minuteHeight);
layer.appendChild(zone);
return;
}
const workStartMinutes = this.dateService.timeToMinutes(schedule.start);
const workEndMinutes = this.dateService.timeToMinutes(schedule.end);
// Before work start
if (workStartMinutes > dayStartMinutes) {
const top = 0;
const height = (workStartMinutes - dayStartMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
// After work end
if (workEndMinutes < dayEndMinutes) {
const top = (workEndMinutes - dayStartMinutes) * minuteHeight;
const height = (dayEndMinutes - workEndMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
}
/**
* Create an unavailable zone element
*/
private createUnavailableZone(top: number, height: number): HTMLElement {
const zone = document.createElement('swp-unavailable-zone');
zone.style.top = `${top}px`;
zone.style.height = `${height}px`;
return zone;
}
}

View file

@ -0,0 +1 @@
export { ScheduleRenderer } from './ScheduleRenderer';

View file

@ -1,5 +1,6 @@
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
import { IWeekSchedule } from '../types/ScheduleTypes';
interface RawResourceData {
id: string;
@ -9,8 +10,8 @@ interface RawResourceData {
avatarUrl?: string;
color?: string;
isActive?: boolean;
defaultSchedule?: IWeekSchedule;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
/**
@ -57,6 +58,7 @@ export class MockResourceRepository implements IApiRepository<IResource> {
avatarUrl: resource.avatarUrl,
color: resource.color,
isActive: resource.isActive,
defaultSchedule: resource.defaultSchedule,
metadata: resource.metadata,
syncStatus: 'synced' as const
}));

View file

@ -0,0 +1,84 @@
import { ITimeSlot } from '../../types/ScheduleTypes';
import { ResourceService } from '../resources/ResourceService';
import { ScheduleOverrideService } from './ScheduleOverrideService';
import { DateService } from '../../core/DateService';
/**
* ResourceScheduleService - Get effective schedule for a resource on a date
*
* Logic:
* 1. Check for override on this date
* 2. Fall back to default schedule for the weekday
*/
export class ResourceScheduleService {
constructor(
private resourceService: ResourceService,
private overrideService: ScheduleOverrideService,
private dateService: DateService
) {}
/**
* Get effective schedule for a resource on a specific date
*
* @param resourceId - Resource ID
* @param date - Date string "YYYY-MM-DD"
* @returns ITimeSlot or null (fri/closed)
*/
async getScheduleForDate(resourceId: string, date: string): Promise<ITimeSlot | null> {
// 1. Check for override
const override = await this.overrideService.getOverride(resourceId, date);
if (override) {
return override.schedule;
}
// 2. Use default schedule for weekday
const resource = await this.resourceService.get(resourceId);
if (!resource || !resource.defaultSchedule) {
return null;
}
const weekDay = this.dateService.getISOWeekDay(date);
return resource.defaultSchedule[weekDay] || null;
}
/**
* Get schedules for multiple dates
*
* @param resourceId - Resource ID
* @param dates - Array of date strings "YYYY-MM-DD"
* @returns Map of date -> ITimeSlot | null
*/
async getSchedulesForDates(resourceId: string, dates: string[]): Promise<Map<string, ITimeSlot | null>> {
const result = new Map<string, ITimeSlot | null>();
// Get resource once
const resource = await this.resourceService.get(resourceId);
// Get all overrides in date range
const overrides = dates.length > 0
? await this.overrideService.getByDateRange(resourceId, dates[0], dates[dates.length - 1])
: [];
// Build override map
const overrideMap = new Map(overrides.map(o => [o.date, o.schedule]));
// Resolve each date
for (const date of dates) {
// Check override first
if (overrideMap.has(date)) {
result.set(date, overrideMap.get(date)!);
continue;
}
// Fall back to default
if (resource?.defaultSchedule) {
const weekDay = this.dateService.getISOWeekDay(date);
result.set(date, resource.defaultSchedule[weekDay] || null);
} else {
result.set(date, null);
}
}
return result;
}
}

View file

@ -0,0 +1,100 @@
import { IScheduleOverride } from '../../types/ScheduleTypes';
import { IndexedDBContext } from '../IndexedDBContext';
import { ScheduleOverrideStore } from './ScheduleOverrideStore';
/**
* ScheduleOverrideService - CRUD for schedule overrides
*
* Provides access to date-specific schedule overrides for resources.
*/
export class ScheduleOverrideService {
private context: IndexedDBContext;
constructor(context: IndexedDBContext) {
this.context = context;
}
private get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Get override for a specific resource and date
*/
async getOverride(resourceId: string, date: string): Promise<IScheduleOverride | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId_date');
const request = index.get([resourceId, date]);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get override for ${resourceId} on ${date}: ${request.error}`));
};
});
}
/**
* Get all overrides for a resource
*/
async getByResource(resourceId: string): Promise<IScheduleOverride[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error(`Failed to get overrides for ${resourceId}: ${request.error}`));
};
});
}
/**
* Get overrides for a date range
*/
async getByDateRange(resourceId: string, startDate: string, endDate: string): Promise<IScheduleOverride[]> {
const all = await this.getByResource(resourceId);
return all.filter(o => o.date >= startDate && o.date <= endDate);
}
/**
* Save an override
*/
async save(override: IScheduleOverride): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.put(override);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to save override ${override.id}: ${request.error}`));
};
});
}
/**
* Delete an override
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite');
const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(new Error(`Failed to delete override ${id}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,21 @@
import { IStore } from '../IStore';
/**
* ScheduleOverrideStore - IndexedDB ObjectStore for schedule overrides
*
* Stores date-specific schedule overrides for resources.
* Indexes: resourceId, date, compound (resourceId + date)
*/
export class ScheduleOverrideStore implements IStore {
static readonly STORE_NAME = 'scheduleOverrides';
readonly storeName = ScheduleOverrideStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ScheduleOverrideStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('resourceId', 'resourceId', { unique: false });
store.createIndex('date', 'date', { unique: false });
store.createIndex('resourceId_date', ['resourceId', 'date'], { unique: true });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -2,6 +2,8 @@
* Calendar V2 Type Definitions
*/
import { IWeekSchedule } from './ScheduleTypes';
export type SyncStatus = 'synced' | 'pending' | 'error';
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit';
@ -101,6 +103,7 @@ export interface IResource extends ISync {
avatarUrl?: string;
color?: string;
isActive?: boolean;
defaultSchedule?: IWeekSchedule; // Default arbejdstider per ugedag
metadata?: Record<string, unknown>;
}

View file

@ -0,0 +1,27 @@
/**
* Schedule Types - Resource arbejdstider
*/
// Genbrugelig tidsslot
export interface ITimeSlot {
start: string; // "HH:mm"
end: string; // "HH:mm"
}
// Ugedag: 1=mandag, 7=søndag (ISO 8601)
export type WeekDay = 1 | 2 | 3 | 4 | 5 | 6 | 7;
// Default arbejdstider per ugedag
export interface IWeekSchedule {
[day: number]: ITimeSlot | null; // null = fri den dag
}
// Override for specifik dato
export interface IScheduleOverride {
id: string;
resourceId: string;
date: string; // "YYYY-MM-DD"
schedule: ITimeSlot | null; // null = fri den dag
breaks?: ITimeSlot[];
syncStatus?: 'synced' | 'pending' | 'error';
}

View file

@ -1,27 +1,24 @@
/**
* PositionUtils - Event position calculations
* PositionUtils - Pixel/position calculations for calendar grid
*
* Converts between time and pixel positions for calendar events.
* RESPONSIBILITY: Convert between time and pixel positions
* NOTE: Date formatting belongs in DateService, not here
*/
import { IGridConfig } from '../core/IGridConfig';
export interface EventPosition {
top: number; // pixels from day start
height: number; // pixels
}
export interface GridConfig {
dayStartHour: number;
dayEndHour: number;
hourHeight: number;
}
/**
* Calculate pixel position for an event based on its times
*/
export function calculateEventPosition(
start: Date,
end: Date,
config: GridConfig
config: IGridConfig
): EventPosition {
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
@ -36,23 +33,23 @@ export function calculateEventPosition(
}
/**
* Get the date key (YYYY-MM-DD) for a Date object
* Convert minutes to pixels
*/
export function getDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
export function minutesToPixels(minutes: number, config: IGridConfig): number {
return (minutes / 60) * config.hourHeight;
}
/**
* Format time range for display (e.g., "10:00 - 11:30")
* Convert pixels to minutes
*/
export function formatTimeRange(start: Date, end: Date): string {
const formatTime = (d: Date) => {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
};
return `${formatTime(start)} - ${formatTime(end)}`;
export function pixelsToMinutes(pixels: number, config: IGridConfig): number {
return (pixels / config.hourHeight) * 60;
}
/**
* Snap pixel position to grid interval
*/
export function snapToGrid(pixels: number, config: IGridConfig): number {
const snapPixels = minutesToPixels(config.snapInterval, config);
return Math.round(pixels / snapPixels) * snapPixels;
}