From a2b95515fdff1a03f058038c93a617fc06016c40 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Wed, 10 Dec 2025 00:27:19 +0100 Subject: [PATCH] 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 --- src/v2/V2CompositionRoot.ts | 21 ++++ src/v2/core/CalendarOrchestrator.ts | 7 +- src/v2/core/DateService.ts | 84 +++++++++++++- src/v2/core/IGridConfig.ts | 6 + src/v2/features/event/EventRenderer.ts | 20 ++-- src/v2/features/schedule/ScheduleRenderer.ts | 106 ++++++++++++++++++ src/v2/features/schedule/index.ts | 1 + src/v2/repositories/MockResourceRepository.ts | 4 +- .../schedules/ResourceScheduleService.ts | 84 ++++++++++++++ .../schedules/ScheduleOverrideService.ts | 100 +++++++++++++++++ .../schedules/ScheduleOverrideStore.ts | 21 ++++ src/v2/types/CalendarTypes.ts | 3 + src/v2/types/ScheduleTypes.ts | 27 +++++ src/v2/utils/PositionUtils.ts | 43 ++++--- wwwroot/css/v2/calendar-v2-base.css | 1 + wwwroot/css/v2/calendar-v2-layout.css | 17 +++ wwwroot/data/mock-resources.json | 54 +++++++++ 17 files changed, 563 insertions(+), 36 deletions(-) create mode 100644 src/v2/core/IGridConfig.ts create mode 100644 src/v2/features/schedule/ScheduleRenderer.ts create mode 100644 src/v2/features/schedule/index.ts create mode 100644 src/v2/storage/schedules/ResourceScheduleService.ts create mode 100644 src/v2/storage/schedules/ScheduleOverrideService.ts create mode 100644 src/v2/storage/schedules/ScheduleOverrideStore.ts create mode 100644 src/v2/types/ScheduleTypes.ts diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index a0112b4..2739a80 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -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(); + builder.registerInstance(defaultGridConfig).as(); // Core - EventBus builder.registerType(EventBus).as(); @@ -73,6 +88,7 @@ export function createV2Container(): Container { builder.registerType(ResourceStore).as(); builder.registerType(BookingStore).as(); builder.registerType(CustomerStore).as(); + builder.registerType(ScheduleOverrideStore).as(); // Entity services (for DataSeeder polymorphic array) builder.registerType(EventService).as>(); @@ -107,8 +123,13 @@ export function createV2Container(): Container { // Workers builder.registerType(DataSeeder).as(); + // Schedule services + builder.registerType(ScheduleOverrideService).as(); + builder.registerType(ResourceScheduleService).as(); + // Features builder.registerType(EventRenderer).as(); + builder.registerType(ScheduleRenderer).as(); // Renderers - registreres som Renderer (array injection til CalendarOrchestrator) builder.registerType(DateRenderer).as(); diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index b78b4c3..591014a 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -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 { @@ -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); } diff --git a/src/v2/core/DateService.ts b/src/v2/core/DateService.ts index e11e9cb..a12236e 100644 --- a/src/v2/core/DateService.ts +++ b/src/v2/core/DateService.ts @@ -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 + } } diff --git a/src/v2/core/IGridConfig.ts b/src/v2/core/IGridConfig.ts new file mode 100644 index 0000000..3703968 --- /dev/null +++ b/src/v2/core/IGridConfig.ts @@ -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 +} diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts index cf6d9c7..107e229 100644 --- a/src/v2/features/event/EventRenderer.ts +++ b/src/v2/features/event/EventRenderer.ts @@ -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 = ` - ${formatTimeRange(event.start, event.end)} + ${this.dateService.formatTimeRange(event.start, event.end)} ${this.escapeHtml(event.title)} ${event.description ? `${this.escapeHtml(event.description)}` : ''} `; diff --git a/src/v2/features/schedule/ScheduleRenderer.ts b/src/v2/features/schedule/ScheduleRenderer.ts new file mode 100644 index 0000000..93c3c14 --- /dev/null +++ b/src/v2/features/schedule/ScheduleRenderer.ts @@ -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): Promise { + 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; + } +} diff --git a/src/v2/features/schedule/index.ts b/src/v2/features/schedule/index.ts new file mode 100644 index 0000000..c6ca514 --- /dev/null +++ b/src/v2/features/schedule/index.ts @@ -0,0 +1 @@ +export { ScheduleRenderer } from './ScheduleRenderer'; diff --git a/src/v2/repositories/MockResourceRepository.ts b/src/v2/repositories/MockResourceRepository.ts index eb7731e..0f2217f 100644 --- a/src/v2/repositories/MockResourceRepository.ts +++ b/src/v2/repositories/MockResourceRepository.ts @@ -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; - [key: string]: unknown; } /** @@ -57,6 +58,7 @@ export class MockResourceRepository implements IApiRepository { avatarUrl: resource.avatarUrl, color: resource.color, isActive: resource.isActive, + defaultSchedule: resource.defaultSchedule, metadata: resource.metadata, syncStatus: 'synced' as const })); diff --git a/src/v2/storage/schedules/ResourceScheduleService.ts b/src/v2/storage/schedules/ResourceScheduleService.ts new file mode 100644 index 0000000..48b96d1 --- /dev/null +++ b/src/v2/storage/schedules/ResourceScheduleService.ts @@ -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 { + // 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> { + const result = new Map(); + + // 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; + } +} diff --git a/src/v2/storage/schedules/ScheduleOverrideService.ts b/src/v2/storage/schedules/ScheduleOverrideService.ts new file mode 100644 index 0000000..d7b57d0 --- /dev/null +++ b/src/v2/storage/schedules/ScheduleOverrideService.ts @@ -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 { + 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 { + 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 { + const all = await this.getByResource(resourceId); + return all.filter(o => o.date >= startDate && o.date <= endDate); + } + + /** + * Save an override + */ + async save(override: IScheduleOverride): Promise { + 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 { + 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}`)); + }; + }); + } +} diff --git a/src/v2/storage/schedules/ScheduleOverrideStore.ts b/src/v2/storage/schedules/ScheduleOverrideStore.ts new file mode 100644 index 0000000..03351ea --- /dev/null +++ b/src/v2/storage/schedules/ScheduleOverrideStore.ts @@ -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 }); + } +} diff --git a/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts index 8d032d1..af9ebc7 100644 --- a/src/v2/types/CalendarTypes.ts +++ b/src/v2/types/CalendarTypes.ts @@ -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; } diff --git a/src/v2/types/ScheduleTypes.ts b/src/v2/types/ScheduleTypes.ts new file mode 100644 index 0000000..65bfee7 --- /dev/null +++ b/src/v2/types/ScheduleTypes.ts @@ -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'; +} diff --git a/src/v2/utils/PositionUtils.ts b/src/v2/utils/PositionUtils.ts index 02bed4f..5c99e4b 100644 --- a/src/v2/utils/PositionUtils.ts +++ b/src/v2/utils/PositionUtils.ts @@ -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; } diff --git a/wwwroot/css/v2/calendar-v2-base.css b/wwwroot/css/v2/calendar-v2-base.css index 6089e38..64a9f10 100644 --- a/wwwroot/css/v2/calendar-v2-base.css +++ b/wwwroot/css/v2/calendar-v2-base.css @@ -25,6 +25,7 @@ /* Colors - Grid */ --color-hour-line: rgba(0, 0, 0, 0.2); --color-grid-line-light: rgba(0, 0, 0, 0.05); + --color-unavailable: rgba(0, 0, 0, 0.02); /* Named color palette for events (fra V1) */ --b-color-red: #e53935; diff --git a/wwwroot/css/v2/calendar-v2-layout.css b/wwwroot/css/v2/calendar-v2-layout.css index 6e0d8be..35e0ba2 100644 --- a/wwwroot/css/v2/calendar-v2-layout.css +++ b/wwwroot/css/v2/calendar-v2-layout.css @@ -276,4 +276,21 @@ swp-day-column { swp-events-layer { position: absolute; inset: 0; + z-index: 10; +} + +/* Unavailable time zones (outside working hours) */ +swp-unavailable-layer { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; +} + +swp-unavailable-zone { + position: absolute; + left: 0; + right: 0; + background: var(--color-unavailable, rgba(0, 0, 0, 0.05)); + pointer-events: none; } diff --git a/wwwroot/data/mock-resources.json b/wwwroot/data/mock-resources.json index 3600c0a..0776705 100644 --- a/wwwroot/data/mock-resources.json +++ b/wwwroot/data/mock-resources.json @@ -7,6 +7,15 @@ "avatarUrl": "/avatars/camilla.jpg", "color": "#9c27b0", "isActive": true, + "defaultSchedule": { + "1": { "start": "09:00", "end": "17:00" }, + "2": { "start": "09:00", "end": "17:00" }, + "3": { "start": "09:00", "end": "15:00" }, + "4": { "start": "09:00", "end": "17:00" }, + "5": { "start": "09:00", "end": "14:00" }, + "6": null, + "7": null + }, "metadata": { "role": "master stylist", "specialties": ["balayage", "color", "bridal"] @@ -20,6 +29,15 @@ "avatarUrl": "/avatars/isabella.jpg", "color": "#e91e63", "isActive": true, + "defaultSchedule": { + "1": { "start": "10:00", "end": "18:00" }, + "2": { "start": "10:00", "end": "18:00" }, + "3": { "start": "10:00", "end": "18:00" }, + "4": { "start": "10:00", "end": "18:00" }, + "5": { "start": "10:00", "end": "16:00" }, + "6": null, + "7": null + }, "metadata": { "role": "master stylist", "specialties": ["highlights", "ombre", "styling"] @@ -33,6 +51,15 @@ "avatarUrl": "/avatars/alexander.jpg", "color": "#3f51b5", "isActive": true, + "defaultSchedule": { + "1": { "start": "08:00", "end": "16:00" }, + "2": { "start": "08:00", "end": "16:00" }, + "3": { "start": "08:00", "end": "16:00" }, + "4": { "start": "08:00", "end": "16:00" }, + "5": { "start": "08:00", "end": "14:00" }, + "6": null, + "7": null + }, "metadata": { "role": "master stylist", "specialties": ["men's cuts", "beard", "fade"] @@ -46,6 +73,15 @@ "avatarUrl": "/avatars/viktor.jpg", "color": "#009688", "isActive": true, + "defaultSchedule": { + "1": { "start": "09:00", "end": "17:00" }, + "2": null, + "3": { "start": "09:00", "end": "17:00" }, + "4": { "start": "09:00", "end": "17:00" }, + "5": { "start": "09:00", "end": "17:00" }, + "6": { "start": "10:00", "end": "14:00" }, + "7": null + }, "metadata": { "role": "stylist", "specialties": ["cuts", "styling", "perms"] @@ -59,6 +95,15 @@ "avatarUrl": "/avatars/line.jpg", "color": "#8bc34a", "isActive": true, + "defaultSchedule": { + "1": { "start": "09:00", "end": "15:00" }, + "2": { "start": "09:00", "end": "15:00" }, + "3": { "start": "09:00", "end": "15:00" }, + "4": null, + "5": { "start": "09:00", "end": "15:00" }, + "6": null, + "7": null + }, "metadata": { "role": "student", "specialties": ["wash", "blow-dry", "basic cuts"] @@ -72,6 +117,15 @@ "avatarUrl": "/avatars/mads.jpg", "color": "#ff9800", "isActive": true, + "defaultSchedule": { + "1": null, + "2": { "start": "10:00", "end": "16:00" }, + "3": { "start": "10:00", "end": "16:00" }, + "4": { "start": "10:00", "end": "16:00" }, + "5": null, + "6": null, + "7": null + }, "metadata": { "role": "student", "specialties": ["wash", "styling assistance"]