Moving away from Azure Devops #1
17 changed files with 563 additions and 36 deletions
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/v2/core/IGridConfig.ts
Normal file
6
src/v2/core/IGridConfig.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>` : ''}
|
||||
`;
|
||||
|
|
|
|||
106
src/v2/features/schedule/ScheduleRenderer.ts
Normal file
106
src/v2/features/schedule/ScheduleRenderer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/v2/features/schedule/index.ts
Normal file
1
src/v2/features/schedule/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ScheduleRenderer } from './ScheduleRenderer';
|
||||
|
|
@ -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
|
||||
}));
|
||||
|
|
|
|||
84
src/v2/storage/schedules/ResourceScheduleService.ts
Normal file
84
src/v2/storage/schedules/ResourceScheduleService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/v2/storage/schedules/ScheduleOverrideService.ts
Normal file
100
src/v2/storage/schedules/ScheduleOverrideService.ts
Normal 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}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
21
src/v2/storage/schedules/ScheduleOverrideStore.ts
Normal file
21
src/v2/storage/schedules/ScheduleOverrideStore.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
27
src/v2/types/ScheduleTypes.ts
Normal file
27
src/v2/types/ScheduleTypes.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue