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

@ -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';