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

View file

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

View file

@ -1,8 +1,20 @@
import dayjs from 'dayjs'; 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'; import { ITimeFormatConfig } from './ITimeFormatConfig';
// Enable dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isoWeek);
export class DateService { export class DateService {
constructor(private config: ITimeFormatConfig) {} private timezone: string;
constructor(private config: ITimeFormatConfig) {
this.timezone = config.timezone;
}
parseISO(isoString: string): Date { parseISO(isoString: string): Date {
return dayjs(isoString).toDate(); return dayjs(isoString).toDate();
@ -18,4 +30,74 @@ export class DateService {
monday.add(i, 'day').format('YYYY-MM-DD') 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 { ICalendarEvent } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService'; 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 * EventRenderer - Renders calendar events to the DOM
@ -11,13 +13,11 @@ import { calculateEventPosition, getDateKey, formatTimeRange, GridConfig } from
* - Event data retrieved via EventService when needed * - Event data retrieved via EventService when needed
*/ */
export class EventRenderer { export class EventRenderer {
private readonly gridConfig: GridConfig = { constructor(
dayStartHour: 6, private eventService: EventService,
dayEndHour: 18, private dateService: DateService,
hourHeight: 64 private gridConfig: IGridConfig
}; ) {}
constructor(private eventService: EventService) {}
/** /**
* Render events for visible dates into day columns * Render events for visible dates into day columns
@ -53,7 +53,7 @@ export class EventRenderer {
// Filter events for this column // Filter events for this column
const columnEvents = events.filter(event => { const columnEvents = events.filter(event => {
// Must match date // 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 column has resourceId, event must match
if (columnResourceId && event.resourceId !== columnResourceId) return false; if (columnResourceId && event.resourceId !== columnResourceId) return false;
@ -110,7 +110,7 @@ export class EventRenderer {
// Visible content only // Visible content only
element.innerHTML = ` 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> <swp-event-title>${this.escapeHtml(event.title)}</swp-event-title>
${event.description ? `<swp-event-description>${this.escapeHtml(event.description)}</swp-event-description>` : ''} ${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 { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository'; import { IApiRepository } from './IApiRepository';
import { IWeekSchedule } from '../types/ScheduleTypes';
interface RawResourceData { interface RawResourceData {
id: string; id: string;
@ -9,8 +10,8 @@ interface RawResourceData {
avatarUrl?: string; avatarUrl?: string;
color?: string; color?: string;
isActive?: boolean; isActive?: boolean;
defaultSchedule?: IWeekSchedule;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
[key: string]: unknown;
} }
/** /**
@ -57,6 +58,7 @@ export class MockResourceRepository implements IApiRepository<IResource> {
avatarUrl: resource.avatarUrl, avatarUrl: resource.avatarUrl,
color: resource.color, color: resource.color,
isActive: resource.isActive, isActive: resource.isActive,
defaultSchedule: resource.defaultSchedule,
metadata: resource.metadata, metadata: resource.metadata,
syncStatus: 'synced' as const 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 * Calendar V2 Type Definitions
*/ */
import { IWeekSchedule } from './ScheduleTypes';
export type SyncStatus = 'synced' | 'pending' | 'error'; export type SyncStatus = 'synced' | 'pending' | 'error';
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit';
@ -101,6 +103,7 @@ export interface IResource extends ISync {
avatarUrl?: string; avatarUrl?: string;
color?: string; color?: string;
isActive?: boolean; isActive?: boolean;
defaultSchedule?: IWeekSchedule; // Default arbejdstider per ugedag
metadata?: Record<string, unknown>; 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 { export interface EventPosition {
top: number; // pixels from day start top: number; // pixels from day start
height: number; // pixels height: number; // pixels
} }
export interface GridConfig {
dayStartHour: number;
dayEndHour: number;
hourHeight: number;
}
/** /**
* Calculate pixel position for an event based on its times * Calculate pixel position for an event based on its times
*/ */
export function calculateEventPosition( export function calculateEventPosition(
start: Date, start: Date,
end: Date, end: Date,
config: GridConfig config: IGridConfig
): EventPosition { ): EventPosition {
const startMinutes = start.getHours() * 60 + start.getMinutes(); const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.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 { export function minutesToPixels(minutes: number, config: IGridConfig): number {
const year = date.getFullYear(); return (minutes / 60) * config.hourHeight;
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} }
/** /**
* Format time range for display (e.g., "10:00 - 11:30") * Convert pixels to minutes
*/ */
export function formatTimeRange(start: Date, end: Date): string { export function pixelsToMinutes(pixels: number, config: IGridConfig): number {
const formatTime = (d: Date) => { return (pixels / config.hourHeight) * 60;
const h = String(d.getHours()).padStart(2, '0'); }
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`; /**
}; * Snap pixel position to grid interval
return `${formatTime(start)} - ${formatTime(end)}`; */
export function snapToGrid(pixels: number, config: IGridConfig): number {
const snapPixels = minutesToPixels(config.snapInterval, config);
return Math.round(pixels / snapPixels) * snapPixels;
} }

View file

@ -25,6 +25,7 @@
/* Colors - Grid */ /* Colors - Grid */
--color-hour-line: rgba(0, 0, 0, 0.2); --color-hour-line: rgba(0, 0, 0, 0.2);
--color-grid-line-light: rgba(0, 0, 0, 0.05); --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) */ /* Named color palette for events (fra V1) */
--b-color-red: #e53935; --b-color-red: #e53935;

View file

@ -276,4 +276,21 @@ swp-day-column {
swp-events-layer { swp-events-layer {
position: absolute; position: absolute;
inset: 0; 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;
} }

View file

@ -7,6 +7,15 @@
"avatarUrl": "/avatars/camilla.jpg", "avatarUrl": "/avatars/camilla.jpg",
"color": "#9c27b0", "color": "#9c27b0",
"isActive": true, "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": { "metadata": {
"role": "master stylist", "role": "master stylist",
"specialties": ["balayage", "color", "bridal"] "specialties": ["balayage", "color", "bridal"]
@ -20,6 +29,15 @@
"avatarUrl": "/avatars/isabella.jpg", "avatarUrl": "/avatars/isabella.jpg",
"color": "#e91e63", "color": "#e91e63",
"isActive": true, "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": { "metadata": {
"role": "master stylist", "role": "master stylist",
"specialties": ["highlights", "ombre", "styling"] "specialties": ["highlights", "ombre", "styling"]
@ -33,6 +51,15 @@
"avatarUrl": "/avatars/alexander.jpg", "avatarUrl": "/avatars/alexander.jpg",
"color": "#3f51b5", "color": "#3f51b5",
"isActive": true, "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": { "metadata": {
"role": "master stylist", "role": "master stylist",
"specialties": ["men's cuts", "beard", "fade"] "specialties": ["men's cuts", "beard", "fade"]
@ -46,6 +73,15 @@
"avatarUrl": "/avatars/viktor.jpg", "avatarUrl": "/avatars/viktor.jpg",
"color": "#009688", "color": "#009688",
"isActive": true, "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": { "metadata": {
"role": "stylist", "role": "stylist",
"specialties": ["cuts", "styling", "perms"] "specialties": ["cuts", "styling", "perms"]
@ -59,6 +95,15 @@
"avatarUrl": "/avatars/line.jpg", "avatarUrl": "/avatars/line.jpg",
"color": "#8bc34a", "color": "#8bc34a",
"isActive": true, "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": { "metadata": {
"role": "student", "role": "student",
"specialties": ["wash", "blow-dry", "basic cuts"] "specialties": ["wash", "blow-dry", "basic cuts"]
@ -72,6 +117,15 @@
"avatarUrl": "/avatars/mads.jpg", "avatarUrl": "/avatars/mads.jpg",
"color": "#ff9800", "color": "#ff9800",
"isActive": true, "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": { "metadata": {
"role": "student", "role": "student",
"specialties": ["wash", "styling assistance"] "specialties": ["wash", "styling assistance"]