Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
97 changed files with 13858 additions and 1 deletions
Showing only changes of commit ceb44446f0 - Show all commits

View file

@ -22,7 +22,14 @@
"Bash(node -e:*)",
"Bash(ls:*)",
"Bash(find:*)",
"WebFetch(domain:www.elegantthemes.com)"
"WebFetch(domain:www.elegantthemes.com)",
"Bash(npm publish:*)",
"Bash(npm init:*)",
"Bash(node dist/bundle.js:*)",
"Bash(node build.js:*)",
"Bash(npm ls:*)",
"Bash(npm view:*)",
"Bash(npm update:*)"
],
"deny": [],
"ask": []

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ Thumbs.db
*.userosscache
*.sln.docstates
js/
packages/calendar/dist/

View file

@ -0,0 +1,47 @@
import * as esbuild from 'esbuild';
import { NovadiUnplugin } from '@novadi/core/unplugin';
import * as fs from 'fs';
import * as path from 'path';
const entryPoints = [
'src/index.ts',
'src/extensions/teams/index.ts',
'src/extensions/departments/index.ts',
'src/extensions/bookings/index.ts',
'src/extensions/customers/index.ts',
'src/extensions/schedules/index.ts',
'src/extensions/audit/index.ts'
];
async function build() {
await esbuild.build({
entryPoints,
bundle: true,
outdir: 'dist',
format: 'esm',
platform: 'browser',
external: ['@novadi/core', 'dayjs'],
splitting: true,
sourcemap: true,
target: 'es2020',
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true })]
});
console.log('Build complete: dist/');
// Bundle CSS
const cssDir = 'dist/css';
if (!fs.existsSync(cssDir)) {
fs.mkdirSync(cssDir, { recursive: true });
}
const cssFiles = [
'../../wwwroot/css/calendar-base.css',
'../../wwwroot/css/calendar-layout.css',
'../../wwwroot/css/calendar-events.css'
];
const bundledCss = cssFiles.map(f => fs.readFileSync(f, 'utf8')).join('\n');
fs.writeFileSync(path.join(cssDir, 'calendar.css'), bundledCss);
console.log('CSS bundled: dist/css/calendar.css');
}
build();

167
packages/calendar/package-lock.json generated Normal file
View file

@ -0,0 +1,167 @@
{
"name": "@plantempus/calendar",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@plantempus/calendar",
"version": "0.1.0",
"dependencies": {
"dayjs": "^1.11.0"
},
"peerDependencies": {
"@novadi/core": "^0.6.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT",
"peer": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@novadi/core": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@novadi/core/-/core-0.6.0.tgz",
"integrity": "sha512-CU1134Nd7ULMg9OQbID5oP+FLtrMkNiLJ17+dmy4jjmPDcPK/dVzKTFxvJmbBvEfZEc9WtmkmJjqw11ABU7Jxw==",
"license": "MIT",
"peer": true,
"dependencies": {
"unplugin": "^2.3.10"
},
"optionalDependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.52.5"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT",
"peer": true
}
}
}

View file

@ -0,0 +1,57 @@
{
"name": "calendar",
"version": "0.1.6",
"description": "Calendar library",
"author": "SWP",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./teams": {
"import": "./dist/extensions/teams/index.js",
"types": "./dist/extensions/teams/index.d.ts"
},
"./departments": {
"import": "./dist/extensions/departments/index.js",
"types": "./dist/extensions/departments/index.d.ts"
},
"./bookings": {
"import": "./dist/extensions/bookings/index.js",
"types": "./dist/extensions/bookings/index.d.ts"
},
"./customers": {
"import": "./dist/extensions/customers/index.js",
"types": "./dist/extensions/customers/index.d.ts"
},
"./schedules": {
"import": "./dist/extensions/schedules/index.js",
"types": "./dist/extensions/schedules/index.d.ts"
},
"./audit": {
"import": "./dist/extensions/audit/index.js",
"types": "./dist/extensions/audit/index.d.ts"
},
"./styles": "./dist/css/calendar.css"
},
"files": [
"dist"
],
"scripts": {
"build": "node build.js && npm run build:types",
"build:types": "tsc --emitDeclarationOnly --outDir dist"
},
"peerDependencies": {
"@novadi/core": "^0.6.0"
},
"dependencies": {
"dayjs": "^1.11.0"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,163 @@
import { Container, Builder } from '@novadi/core';
// Core
import { EventBus } from './core/EventBus';
import { DateService } from './core/DateService';
import { CalendarOrchestrator } from './core/CalendarOrchestrator';
import { CalendarApp } from './core/CalendarApp';
import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
import { ScrollManager } from './core/ScrollManager';
import { HeaderDrawerManager } from './core/HeaderDrawerManager';
import { ITimeFormatConfig } from './core/ITimeFormatConfig';
import { IGridConfig } from './core/IGridConfig';
// Types
import { IEventBus, ICalendarEvent, ISync, IResource } from './types/CalendarTypes';
import { TenantSetting } from './types/SettingsTypes';
import { ViewConfig } from './core/ViewConfig';
// Renderers
import { IRenderer } from './core/IGroupingRenderer';
import { DateRenderer } from './features/date/DateRenderer';
import { ResourceRenderer } from './features/resource/ResourceRenderer';
import { EventRenderer } from './features/event/EventRenderer';
import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
import { HeaderDrawerRenderer } from './features/headerdrawer/HeaderDrawerRenderer';
// Storage
import { IndexedDBContext, IDBConfig, defaultDBConfig } from './storage/IndexedDBContext';
import { IStore } from './storage/IStore';
import { IEntityService } from './storage/IEntityService';
import { EventStore } from './storage/events/EventStore';
import { EventService } from './storage/events/EventService';
import { ResourceStore } from './storage/resources/ResourceStore';
import { ResourceService } from './storage/resources/ResourceService';
import { SettingsStore } from './storage/settings/SettingsStore';
import { SettingsService } from './storage/settings/SettingsService';
import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
import { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
// Managers
import { DragDropManager } from './managers/DragDropManager';
import { EdgeScrollManager } from './managers/EdgeScrollManager';
import { ResizeManager } from './managers/ResizeManager';
import { EventPersistenceManager } from './managers/EventPersistenceManager';
/**
* Default configuration values
*/
export const defaultTimeFormatConfig: ITimeFormatConfig = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
use24HourFormat: true,
locale: 'da-DK',
dateFormat: 'locale',
showSeconds: false
};
export const defaultGridConfig: IGridConfig = {
hourHeight: 64,
dayStartHour: 6,
dayEndHour: 18,
snapInterval: 15,
gridStartThresholdMinutes: 30
};
/**
* Calendar configuration options
*/
export interface ICalendarOptions {
timeConfig?: ITimeFormatConfig;
gridConfig?: IGridConfig;
dbConfig?: IDBConfig;
}
/**
* Creates a configured DI container with all core calendar services registered.
* Call this to get a ready-to-use container for the calendar.
*
* @param options - Optional calendar configuration options
* @returns Configured Container instance
*/
export function createCalendarContainer(options?: ICalendarOptions): Container {
const container = new Container();
const builder = container.builder();
registerCoreServices(builder, options);
return builder.build();
}
/**
* Registers all core calendar services with the DI container builder.
* Use this when you need to customize the container or add extensions.
*
* @param builder - ContainerBuilder to register services with
* @param options - Optional calendar configuration options
*/
export function registerCoreServices(
builder: Builder,
options?: ICalendarOptions
): void {
const timeConfig = options?.timeConfig ?? defaultTimeFormatConfig;
const gridConfig = options?.gridConfig ?? defaultGridConfig;
const dbConfig = options?.dbConfig ?? defaultDBConfig;
// Configuration instances
builder.registerInstance(timeConfig).as<ITimeFormatConfig>();
builder.registerInstance(gridConfig).as<IGridConfig>();
builder.registerInstance(dbConfig).as<IDBConfig>();
// Core - EventBus (singleton pattern via dual registration)
builder.registerType(EventBus).as<EventBus>();
builder.registerType(EventBus).as<IEventBus>();
// Core Services
builder.registerType(DateService).as<DateService>();
// Storage infrastructure
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
// Core Stores (for IndexedDB schema creation via IStore[] array injection)
builder.registerType(EventStore).as<IStore>();
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(SettingsStore).as<IStore>();
builder.registerType(ViewConfigStore).as<IStore>();
// Core Entity Services (polymorphic via IEntityService<T>)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(EventService).as<IEntityService<ISync>>();
builder.registerType(EventService).as<EventService>();
builder.registerType(ResourceService).as<IEntityService<IResource>>();
builder.registerType(ResourceService).as<IEntityService<ISync>>();
builder.registerType(ResourceService).as<ResourceService>();
builder.registerType(SettingsService).as<IEntityService<TenantSetting>>();
builder.registerType(SettingsService).as<IEntityService<ISync>>();
builder.registerType(SettingsService).as<SettingsService>();
builder.registerType(ViewConfigService).as<IEntityService<ViewConfig>>();
builder.registerType(ViewConfigService).as<IEntityService<ISync>>();
builder.registerType(ViewConfigService).as<ViewConfigService>();
// Core Renderers
builder.registerType(EventRenderer).as<EventRenderer>();
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
builder.registerType(HeaderDrawerRenderer).as<HeaderDrawerRenderer>();
builder.registerType(TimeAxisRenderer).as<TimeAxisRenderer>();
// Grouping Renderers (registered as IRenderer[] for CalendarOrchestrator)
builder.registerType(DateRenderer).as<IRenderer>();
builder.registerType(ResourceRenderer).as<IRenderer>();
// Core Managers
builder.registerType(ScrollManager).as<ScrollManager>();
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
builder.registerType(DragDropManager).as<DragDropManager>();
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
builder.registerType(ResizeManager).as<ResizeManager>();
builder.registerType(EventPersistenceManager).as<EventPersistenceManager>();
// Orchestrator and App
builder.registerType(CalendarOrchestrator).as<CalendarOrchestrator>();
builder.registerType(CalendarApp).as<CalendarApp>();
}

View file

@ -0,0 +1,71 @@
/**
* CoreEvents - Consolidated essential events for the calendar
*/
export const CoreEvents = {
// Lifecycle events
INITIALIZED: 'core:initialized',
READY: 'core:ready',
DESTROYED: 'core:destroyed',
// View events
VIEW_CHANGED: 'view:changed',
VIEW_RENDERED: 'view:rendered',
// Navigation events
DATE_CHANGED: 'nav:date-changed',
NAVIGATION_COMPLETED: 'nav:navigation-completed',
// Data events
DATA_LOADING: 'data:loading',
DATA_LOADED: 'data:loaded',
DATA_ERROR: 'data:error',
// Grid events
GRID_RENDERED: 'grid:rendered',
GRID_CLICKED: 'grid:clicked',
// Event management
EVENT_CREATED: 'event:created',
EVENT_UPDATED: 'event:updated',
EVENT_DELETED: 'event:deleted',
EVENT_SELECTED: 'event:selected',
// Event drag-drop
EVENT_DRAG_START: 'event:drag-start',
EVENT_DRAG_MOVE: 'event:drag-move',
EVENT_DRAG_END: 'event:drag-end',
EVENT_DRAG_CANCEL: 'event:drag-cancel',
EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
// Header drag (timed → header conversion)
EVENT_DRAG_ENTER_HEADER: 'event:drag-enter-header',
EVENT_DRAG_MOVE_HEADER: 'event:drag-move-header',
EVENT_DRAG_LEAVE_HEADER: 'event:drag-leave-header',
// Event resize
EVENT_RESIZE_START: 'event:resize-start',
EVENT_RESIZE_END: 'event:resize-end',
// Edge scroll
EDGE_SCROLL_TICK: 'edge-scroll:tick',
EDGE_SCROLL_STARTED: 'edge-scroll:started',
EDGE_SCROLL_STOPPED: 'edge-scroll:stopped',
// System events
ERROR: 'system:error',
// Sync events
SYNC_STARTED: 'sync:started',
SYNC_COMPLETED: 'sync:completed',
SYNC_FAILED: 'sync:failed',
// Entity events - for audit and sync
ENTITY_SAVED: 'entity:saved',
ENTITY_DELETED: 'entity:deleted',
// Audit events
AUDIT_LOGGED: 'audit:logged',
// Rendering events
EVENTS_RENDERED: 'events:rendered'
} as const;

View file

@ -0,0 +1,91 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
/**
* Entity must have id
*/
export interface IGroupingEntity {
id: string;
}
/**
* Configuration for a grouping renderer
*/
export interface IGroupingRendererConfig {
elementTag: string; // e.g., 'swp-team-header'
idAttribute: string; // e.g., 'teamId' -> data-team-id
colspanVar: string; // e.g., '--team-cols'
}
/**
* Abstract base class for grouping renderers
*
* Handles:
* - Fetching entities by IDs
* - Calculating colspan from parentChildMap
* - Creating header elements
* - Appending to container
*
* Subclasses override:
* - renderHeader() for custom content
* - getDisplayName() for entity display text
*/
export abstract class BaseGroupingRenderer<T extends IGroupingEntity> implements IRenderer {
abstract readonly type: string;
protected abstract readonly config: IGroupingRendererConfig;
/**
* Fetch entities from service
*/
protected abstract getEntities(ids: string[]): Promise<T[]>;
/**
* Get display name for entity
*/
protected abstract getDisplayName(entity: T): string;
/**
* Main render method - handles common logic
*/
async render(context: IRenderContext): Promise<void> {
const allowedIds = context.filter[this.type] || [];
if (allowedIds.length === 0) return;
const entities = await this.getEntities(allowedIds);
const dateCount = context.filter['date']?.length || 1;
const childIds = context.childType ? context.filter[context.childType] || [] : [];
for (const entity of entities) {
const entityChildIds = context.parentChildMap?.[entity.id] || [];
const childCount = entityChildIds.filter(id => childIds.includes(id)).length;
const colspan = childCount * dateCount;
const header = document.createElement(this.config.elementTag);
header.dataset[this.config.idAttribute] = entity.id;
header.style.setProperty(this.config.colspanVar, String(colspan));
// Allow subclass to customize header content
this.renderHeader(entity, header, context);
context.headerContainer.appendChild(header);
}
}
/**
* Override this method for custom header rendering
* Default: just sets textContent to display name
*/
protected renderHeader(entity: T, header: HTMLElement, _context: IRenderContext): void {
header.textContent = this.getDisplayName(entity);
}
/**
* Helper to render a single entity header.
* Can be used by subclasses that override render() but want consistent header creation.
*/
protected createHeader(entity: T, context: IRenderContext): HTMLElement {
const header = document.createElement(this.config.elementTag);
header.dataset[this.config.idAttribute] = entity.id;
this.renderHeader(entity, header, context);
return header;
}
}

View file

@ -0,0 +1,201 @@
import { CalendarOrchestrator } from './CalendarOrchestrator';
import { TimeAxisRenderer } from '../features/timeaxis/TimeAxisRenderer';
import { NavigationAnimator } from './NavigationAnimator';
import { DateService } from './DateService';
import { ScrollManager } from './ScrollManager';
import { HeaderDrawerManager } from './HeaderDrawerManager';
import { ViewConfig } from './ViewConfig';
import { DragDropManager } from '../managers/DragDropManager';
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
import { ResizeManager } from '../managers/ResizeManager';
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { SettingsService } from '../storage/settings/SettingsService';
import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService';
import { IWorkweekPreset } from '../types/SettingsTypes';
import { IEventBus } from '../types/CalendarTypes';
import {
CalendarEvents,
RenderPayload,
WorkweekChangePayload,
ViewUpdatePayload
} from './CalendarEvents';
export class CalendarApp {
private animator!: NavigationAnimator;
private container!: HTMLElement;
private dayOffset = 0;
private currentViewId = 'simple';
private workweekPreset: IWorkweekPreset | null = null;
private groupingOverrides: Map<string, string[]> = new Map();
constructor(
private orchestrator: CalendarOrchestrator,
private timeAxisRenderer: TimeAxisRenderer,
private dateService: DateService,
private scrollManager: ScrollManager,
private headerDrawerManager: HeaderDrawerManager,
private dragDropManager: DragDropManager,
private edgeScrollManager: EdgeScrollManager,
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer,
private eventPersistenceManager: EventPersistenceManager,
private settingsService: SettingsService,
private viewConfigService: ViewConfigService,
private eventBus: IEventBus
) {}
async init(container: HTMLElement): Promise<void> {
this.container = container;
// Load settings
const gridSettings = await this.settingsService.getGridSettings();
if (!gridSettings) {
throw new Error('GridSettings not found');
}
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
// Create NavigationAnimator with DOM elements
this.animator = new NavigationAnimator(
container.querySelector('swp-header-track') as HTMLElement,
container.querySelector('swp-content-track') as HTMLElement,
container.querySelector('swp-header-drawer')
);
// Render time axis from settings
this.timeAxisRenderer.render(
container.querySelector('#time-axis') as HTMLElement,
gridSettings.dayStartHour,
gridSettings.dayEndHour
);
// Init managers
this.scrollManager.init(container);
this.headerDrawerManager.init(container);
this.dragDropManager.init(container);
this.resizeManager.init(container);
const scrollableContent = container.querySelector('swp-scrollable-content') as HTMLElement;
this.edgeScrollManager.init(scrollableContent);
// Setup command event listeners
this.setupEventListeners();
// Emit ready status
this.emitStatus('ready');
}
private setupEventListeners(): void {
// Navigation commands via EventBus
this.eventBus.on(CalendarEvents.CMD_NAVIGATE_PREV, () => {
this.handleNavigatePrev();
});
this.eventBus.on(CalendarEvents.CMD_NAVIGATE_NEXT, () => {
this.handleNavigateNext();
});
// Drawer toggle via EventBus
this.eventBus.on(CalendarEvents.CMD_DRAWER_TOGGLE, () => {
this.headerDrawerManager.toggle();
});
// Render command via EventBus
this.eventBus.on(CalendarEvents.CMD_RENDER, (e: Event) => {
const { viewId } = (e as CustomEvent<RenderPayload>).detail;
this.handleRenderCommand(viewId);
});
// Workweek change via EventBus
this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => {
const { presetId } = (e as CustomEvent<WorkweekChangePayload>).detail;
this.handleWorkweekChange(presetId);
});
// View update via EventBus
this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => {
const { type, values } = (e as CustomEvent<ViewUpdatePayload>).detail;
this.handleViewUpdate(type, values);
});
}
private async handleRenderCommand(viewId: string): Promise<void> {
this.currentViewId = viewId;
await this.render();
this.emitStatus('rendered', { viewId });
}
private async handleNavigatePrev(): Promise<void> {
const step = this.workweekPreset?.periodDays ?? 7;
this.dayOffset -= step;
await this.animator.slide('right', () => this.render());
this.emitStatus('rendered', { viewId: this.currentViewId });
}
private async handleNavigateNext(): Promise<void> {
const step = this.workweekPreset?.periodDays ?? 7;
this.dayOffset += step;
await this.animator.slide('left', () => this.render());
this.emitStatus('rendered', { viewId: this.currentViewId });
}
private async handleWorkweekChange(presetId: string): Promise<void> {
const preset = await this.settingsService.getWorkweekPreset(presetId);
if (preset) {
this.workweekPreset = preset;
await this.render();
this.emitStatus('rendered', { viewId: this.currentViewId });
}
}
private async handleViewUpdate(type: string, values: string[]): Promise<void> {
this.groupingOverrides.set(type, values);
await this.render();
this.emitStatus('rendered', { viewId: this.currentViewId });
}
private async render(): Promise<void> {
const storedConfig = await this.viewConfigService.getById(this.currentViewId);
if (!storedConfig) {
this.emitStatus('error', { message: `ViewConfig not found: ${this.currentViewId}` });
return;
}
// Populate date values based on workweek preset and day offset
const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5];
const periodDays = this.workweekPreset?.periodDays ?? 7;
// For single-day navigation (periodDays=1), show consecutive days from offset
// For week navigation (periodDays=7), show workDays from the week containing offset
const dates = periodDays === 1
? this.dateService.getDatesFromOffset(this.dayOffset, workDays.length)
: this.dateService.getWorkDaysFromOffset(this.dayOffset, workDays);
// Clone config and apply overrides
const viewConfig: ViewConfig = {
...storedConfig,
groupings: storedConfig.groupings.map(g => {
// Apply date values
if (g.type === 'date') {
return { ...g, values: dates };
}
// Apply grouping overrides
const override = this.groupingOverrides.get(g.type);
if (override) {
return { ...g, values: override };
}
return g;
})
};
await this.orchestrator.render(viewConfig, this.container);
}
private emitStatus(status: string, detail?: object): void {
this.container.dispatchEvent(new CustomEvent(`calendar:status:${status}`, {
detail,
bubbles: true
}));
}
}

View file

@ -0,0 +1,28 @@
/**
* CalendarEvents - Command and status events for CalendarApp
*/
export const CalendarEvents = {
// Command events (host → calendar)
CMD_NAVIGATE_PREV: 'calendar:cmd:navigate:prev',
CMD_NAVIGATE_NEXT: 'calendar:cmd:navigate:next',
CMD_DRAWER_TOGGLE: 'calendar:cmd:drawer:toggle',
CMD_RENDER: 'calendar:cmd:render',
CMD_WORKWEEK_CHANGE: 'calendar:cmd:workweek:change',
CMD_VIEW_UPDATE: 'calendar:cmd:view:update'
} as const;
/**
* Payload interfaces for CalendarEvents
*/
export interface RenderPayload {
viewId: string;
}
export interface WorkweekChangePayload {
presetId: string;
}
export interface ViewUpdatePayload {
type: string;
values: string[];
}

View file

@ -0,0 +1,124 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
import { buildPipeline } from './RenderBuilder';
import { EventRenderer } from '../features/event/EventRenderer';
import { ScheduleRenderer } from '../features/schedule/ScheduleRenderer';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { ViewConfig, GroupingConfig } from './ViewConfig';
import { FilterTemplate } from './FilterTemplate';
import { DateService } from './DateService';
import { IEntityService } from '../storage/IEntityService';
import { ISync } from '../types/CalendarTypes';
export class CalendarOrchestrator {
constructor(
private allRenderers: IRenderer[],
private eventRenderer: EventRenderer,
private scheduleRenderer: ScheduleRenderer,
private headerDrawerRenderer: HeaderDrawerRenderer,
private dateService: DateService,
private entityServices: IEntityService<ISync>[]
) {}
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement;
const columnContainer = container.querySelector('swp-day-columns') as HTMLElement;
if (!headerContainer || !columnContainer) {
throw new Error('Missing swp-calendar-header or swp-day-columns');
}
// Byg filter fra viewConfig
const filter: Record<string, string[]> = {};
for (const grouping of viewConfig.groupings) {
filter[grouping.type] = grouping.values;
}
// Byg FilterTemplate fra viewConfig groupings (kun de med idProperty)
const filterTemplate = new FilterTemplate(this.dateService);
for (const grouping of viewConfig.groupings) {
if (grouping.idProperty) {
filterTemplate.addField(grouping.idProperty, grouping.derivedFrom);
}
}
// Resolve belongsTo relations (e.g., team.resourceIds)
const { parentChildMap, childType } = await this.resolveBelongsTo(viewConfig.groupings, filter);
const context: IRenderContext = { headerContainer, columnContainer, filter, groupings: viewConfig.groupings, parentChildMap, childType };
// Clear
headerContainer.innerHTML = '';
columnContainer.innerHTML = '';
// Sæt data-levels attribut for CSS grid-row styling
const levels = viewConfig.groupings.map(g => g.type).join(' ');
headerContainer.dataset.levels = levels;
// Vælg renderers baseret på groupings types
const activeRenderers = this.selectRenderers(viewConfig);
// Byg og kør pipeline
const pipeline = buildPipeline(activeRenderers);
await pipeline.run(context);
// Render schedule unavailable zones (før events)
await this.scheduleRenderer.render(container, filter);
// Render timed events in grid (med filterTemplate til matching)
await this.eventRenderer.render(container, filter, filterTemplate);
// Render allDay events in header drawer (med filterTemplate til matching)
await this.headerDrawerRenderer.render(container, filter, filterTemplate);
}
private selectRenderers(viewConfig: ViewConfig): IRenderer[] {
const types = viewConfig.groupings.map(g => g.type);
// Sortér renderers i samme rækkefølge som viewConfig.groupings
return types
.map(type => this.allRenderers.find(r => r.type === type))
.filter((r): r is IRenderer => r !== undefined);
}
/**
* Resolve belongsTo relations to build parent-child map
* e.g., belongsTo: 'team.resourceIds' { team1: ['EMP001', 'EMP002'], team2: [...] }
* Also returns the childType (the grouping type that has belongsTo)
*/
private async resolveBelongsTo(
groupings: GroupingConfig[],
filter: Record<string, string[]>
): Promise<{ parentChildMap?: Record<string, string[]>; childType?: string }> {
// Find grouping with belongsTo
const childGrouping = groupings.find(g => g.belongsTo);
if (!childGrouping?.belongsTo) return {};
// Parse belongsTo: 'team.resourceIds'
const [entityType, property] = childGrouping.belongsTo.split('.');
if (!entityType || !property) return {};
// Get parent IDs from filter
const parentIds = filter[entityType] || [];
if (parentIds.length === 0) return {};
// Find service dynamisk baseret på entityType (ingen hardcoded type check)
const service = this.entityServices.find(s =>
s.entityType.toLowerCase() === entityType
);
if (!service) return {};
// Hent alle entities og filtrer på parentIds
const allEntities = await service.getAll();
const entities = allEntities.filter(e =>
parentIds.includes((e as unknown as Record<string, unknown>).id as string)
);
// Byg parent-child map
const map: Record<string, string[]> = {};
for (const entity of entities) {
const entityRecord = entity as unknown as Record<string, unknown>;
const children = (entityRecord[property] as string[]) || [];
map[entityRecord.id as string] = children;
}
return { parentChildMap: map, childType: childGrouping.type };
}
}

View file

@ -0,0 +1,195 @@
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 {
private timezone: string;
private baseDate: dayjs.Dayjs;
constructor(private config: ITimeFormatConfig, baseDate?: Date) {
this.timezone = config.timezone;
// Allow setting a fixed base date for demo/testing purposes
this.baseDate = baseDate ? dayjs(baseDate) : dayjs();
}
/**
* Set a fixed base date (useful for demos with static mock data)
*/
setBaseDate(date: Date): void {
this.baseDate = dayjs(date);
}
/**
* Get the current base date (either fixed or today)
*/
getBaseDate(): Date {
return this.baseDate.toDate();
}
parseISO(isoString: string): Date {
return dayjs(isoString).toDate();
}
getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
return new Intl.DateTimeFormat(this.config.locale, { weekday: format }).format(date);
}
/**
* Get dates starting from a day offset
* @param dayOffset - Day offset from base date
* @param count - Number of consecutive days to return
* @returns Array of date strings in YYYY-MM-DD format
*/
getDatesFromOffset(dayOffset: number, count: number): string[] {
const startDate = this.baseDate.add(dayOffset, 'day');
return Array.from({ length: count }, (_, i) =>
startDate.add(i, 'day').format('YYYY-MM-DD')
);
}
/**
* Get specific weekdays from the week containing the offset date
* @param dayOffset - Day offset from base date
* @param workDays - Array of ISO weekday numbers (1=Monday, 7=Sunday)
* @returns Array of date strings in YYYY-MM-DD format
*/
getWorkDaysFromOffset(dayOffset: number, workDays: number[]): string[] {
// Get the date at offset, then find its week's Monday
const targetDate = this.baseDate.add(dayOffset, 'day');
const monday = targetDate.startOf('week').add(1, 'day');
return workDays.map(isoDay => {
// ISO: 1=Monday, 7=Sunday → days from Monday: 0-6
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
return monday.add(daysFromMonday, 'day').format('YYYY-MM-DD');
});
}
// Legacy methods for backwards compatibility
getWeekDates(weekOffset = 0, days = 7): string[] {
return this.getDatesFromOffset(weekOffset * 7, days);
}
getWorkWeekDates(weekOffset: number, workDays: number[]): string[] {
return this.getWorkDaysFromOffset(weekOffset * 7, workDays);
}
// ============================================
// 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);
}
// ============================================
// COLUMN KEY
// ============================================
/**
* Build a uniform columnKey from grouping segments
* Handles any combination of date, resource, team, etc.
*
* @example
* buildColumnKey({ date: '2025-12-09' }) "2025-12-09"
* buildColumnKey({ date: '2025-12-09', resource: 'EMP001' }) "2025-12-09:EMP001"
*/
buildColumnKey(segments: Record<string, string>): string {
// Always put date first if present, then other segments alphabetically
const date = segments.date;
const others = Object.entries(segments)
.filter(([k]) => k !== 'date')
.sort(([a], [b]) => a.localeCompare(b))
.map(([, v]) => v);
return date ? [date, ...others].join(':') : others.join(':');
}
/**
* Parse a columnKey back into segments
* Assumes format: "date:resource:..." or just "date"
*/
parseColumnKey(columnKey: string): { date: string; resource?: string } {
const parts = columnKey.split(':');
return {
date: parts[0],
resource: parts[1]
};
}
/**
* Extract dateKey from columnKey (first segment)
*/
getDateFromColumnKey(columnKey: string): string {
return columnKey.split(':')[0];
}
// ============================================
// 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,48 @@
import { IEntityResolver } from './IEntityResolver';
/**
* EntityResolver - Resolves entities from pre-loaded cache
*
* Entities must be loaded before use (typically at render time).
* This allows synchronous lookups during filtering.
*/
export class EntityResolver implements IEntityResolver {
private cache: Map<string, Map<string, Record<string, unknown>>> = new Map();
/**
* Load entities into cache for a given type
* @param entityType - The entity type (e.g., 'resource')
* @param entities - Array of entities with 'id' property
*/
load<T extends { id: string }>(entityType: string, entities: T[]): void {
const typeCache = new Map<string, Record<string, unknown>>();
for (const entity of entities) {
// Cast to Record for storage while preserving original data
typeCache.set(entity.id, entity as unknown as Record<string, unknown>);
}
this.cache.set(entityType, typeCache);
}
/**
* Resolve an entity by type and ID
*/
resolve(entityType: string, id: string): Record<string, unknown> | undefined {
const typeCache = this.cache.get(entityType);
if (!typeCache) return undefined;
return typeCache.get(id);
}
/**
* Clear all cached entities
*/
clear(): void {
this.cache.clear();
}
/**
* Clear cache for a specific entity type
*/
clearType(entityType: string): void {
this.cache.delete(entityType);
}
}

View file

@ -0,0 +1,174 @@
import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
/**
* Central event dispatcher for calendar using DOM CustomEvents
* Provides logging and debugging capabilities
*/
export class EventBus implements IEventBus {
private eventLog: IEventLogEntry[] = [];
private debug: boolean = false;
private listeners: Set<IListenerEntry> = new Set();
// Log configuration for different categories
private logConfig: { [key: string]: boolean } = {
calendar: true,
grid: true,
event: true,
scroll: true,
navigation: true,
view: true,
default: true
};
/**
* Subscribe to an event via DOM addEventListener
*/
on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void {
document.addEventListener(eventType, handler, options);
// Track for cleanup
this.listeners.add({ eventType, handler, options });
// Return unsubscribe function
return () => this.off(eventType, handler);
}
/**
* Subscribe to an event once
*/
once(eventType: string, handler: EventListener): () => void {
return this.on(eventType, handler, { once: true });
}
/**
* Unsubscribe from an event
*/
off(eventType: string, handler: EventListener): void {
document.removeEventListener(eventType, handler);
// Remove from tracking
for (const listener of this.listeners) {
if (listener.eventType === eventType && listener.handler === handler) {
this.listeners.delete(listener);
break;
}
}
}
/**
* Emit an event via DOM CustomEvent
*/
emit(eventType: string, detail: unknown = {}): boolean {
// Validate eventType
if (!eventType) {
return false;
}
const event = new CustomEvent(eventType, {
detail: detail ?? {},
bubbles: true,
cancelable: true
});
// Log event with grouping
if (this.debug) {
this.logEventWithGrouping(eventType, detail);
}
this.eventLog.push({
type: eventType,
detail: detail ?? {},
timestamp: Date.now()
});
// Emit on document (only DOM events now)
return !document.dispatchEvent(event);
}
/**
* Log event with console grouping
*/
private logEventWithGrouping(eventType: string, _detail: unknown): void {
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
const category = this.extractCategory(eventType);
// Only log if category is enabled
if (!this.logConfig[category]) {
return;
}
// Get category emoji and color (used for future console styling)
this.getCategoryStyle(category);
}
/**
* Extract category from event type
*/
private extractCategory(eventType: string): string {
if (!eventType) {
return 'unknown';
}
if (eventType.includes(':')) {
return eventType.split(':')[0];
}
// Fallback: try to detect category from event name patterns
const lowerType = eventType.toLowerCase();
if (lowerType.includes('grid') || lowerType.includes('rendered')) return 'grid';
if (lowerType.includes('event') || lowerType.includes('sync')) return 'event';
if (lowerType.includes('scroll')) return 'scroll';
if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation';
if (lowerType.includes('view')) return 'view';
return 'default';
}
/**
* Get styling for different categories
*/
private getCategoryStyle(category: string): { emoji: string; color: string } {
const styles: { [key: string]: { emoji: string; color: string } } = {
calendar: { emoji: '📅', color: '#2196F3' },
grid: { emoji: '📊', color: '#4CAF50' },
event: { emoji: '📌', color: '#FF9800' },
scroll: { emoji: '📜', color: '#9C27B0' },
navigation: { emoji: '🧭', color: '#F44336' },
view: { emoji: '👁', color: '#00BCD4' },
default: { emoji: '📢', color: '#607D8B' }
};
return styles[category] || styles.default;
}
/**
* Configure logging for specific categories
*/
setLogConfig(config: { [key: string]: boolean }): void {
this.logConfig = { ...this.logConfig, ...config };
}
/**
* Get current log configuration
*/
getLogConfig(): { [key: string]: boolean } {
return { ...this.logConfig };
}
/**
* Get event history
*/
getEventLog(eventType?: string): IEventLogEntry[] {
if (eventType) {
return this.eventLog.filter(e => e.type === eventType);
}
return this.eventLog;
}
/**
* Enable/disable debug mode
*/
setDebug(enabled: boolean): void {
this.debug = enabled;
}
}

View file

@ -0,0 +1,149 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { DateService } from './DateService';
import { IEntityResolver } from './IEntityResolver';
/**
* Field definition for FilterTemplate
*/
interface IFilterField {
idProperty: string;
derivedFrom?: string;
}
/**
* Parsed dot-notation reference
*/
interface IDotNotation {
entityType: string; // e.g., 'resource'
property: string; // e.g., 'teamId'
foreignKey: string; // e.g., 'resourceId'
}
/**
* FilterTemplate - Bygger nøgler til event-kolonne matching
*
* ViewConfig definerer hvilke felter (idProperties) der indgår i kolonnens nøgle.
* Samme template bruges til at bygge nøgle for både kolonne og event.
*
* Supports dot-notation for hierarchical relations:
* - 'resource.teamId' looks up event.resourceId resource entity teamId
*
* Princip: Kolonnens nøgle-template bestemmer hvad der matches .
*
* @see docs/filter-template.md
*/
export class FilterTemplate {
private fields: IFilterField[] = [];
constructor(
private dateService: DateService,
private entityResolver?: IEntityResolver
) {}
/**
* Tilføj felt til template
* @param idProperty - Property-navn (bruges både event og column.dataset)
* @param derivedFrom - Hvis feltet udledes fra anden property (f.eks. date fra start)
*/
addField(idProperty: string, derivedFrom?: string): this {
this.fields.push({ idProperty, derivedFrom });
return this;
}
/**
* Parse dot-notation string into components
* @example 'resource.teamId' { entityType: 'resource', property: 'teamId', foreignKey: 'resourceId' }
*/
private parseDotNotation(idProperty: string): IDotNotation | null {
if (!idProperty.includes('.')) return null;
const [entityType, property] = idProperty.split('.');
return {
entityType,
property,
foreignKey: entityType + 'Id' // Convention: resource → resourceId
};
}
/**
* Get dataset key for column lookup
* For dot-notation 'resource.teamId', we look for 'teamId' in dataset
*/
private getDatasetKey(idProperty: string): string {
const dotNotation = this.parseDotNotation(idProperty);
if (dotNotation) {
return dotNotation.property; // 'teamId'
}
return idProperty;
}
/**
* Byg nøgle fra kolonne
* Læser værdier fra column.dataset[idProperty]
* For dot-notation, uses the property part (resource.teamId teamId)
*/
buildKeyFromColumn(column: HTMLElement): string {
return this.fields
.map(f => {
const key = this.getDatasetKey(f.idProperty);
return column.dataset[key] || '';
})
.join(':');
}
/**
* Byg nøgle fra event
* Læser værdier fra event[idProperty] eller udleder fra derivedFrom
* For dot-notation, resolves via EntityResolver
*/
buildKeyFromEvent(event: ICalendarEvent): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventRecord = event as any;
return this.fields
.map(f => {
// Check for dot-notation (e.g., 'resource.teamId')
const dotNotation = this.parseDotNotation(f.idProperty);
if (dotNotation) {
return this.resolveDotNotation(eventRecord, dotNotation);
}
if (f.derivedFrom) {
// Udled værdi (f.eks. date fra start)
const sourceValue = eventRecord[f.derivedFrom];
if (sourceValue instanceof Date) {
return this.dateService.getDateKey(sourceValue);
}
return String(sourceValue || '');
}
return String(eventRecord[f.idProperty] || '');
})
.join(':');
}
/**
* Resolve dot-notation reference via EntityResolver
*/
private resolveDotNotation(eventRecord: Record<string, unknown>, dotNotation: IDotNotation): string {
if (!this.entityResolver) {
console.warn(`FilterTemplate: EntityResolver required for dot-notation '${dotNotation.entityType}.${dotNotation.property}'`);
return '';
}
// Get foreign key value from event (e.g., resourceId)
const foreignId = eventRecord[dotNotation.foreignKey];
if (!foreignId) return '';
// Resolve entity
const entity = this.entityResolver.resolve(dotNotation.entityType, String(foreignId));
if (!entity) return '';
// Return property value from entity
return String(entity[dotNotation.property] || '');
}
/**
* Match event mod kolonne
*/
matches(event: ICalendarEvent, column: HTMLElement): boolean {
return this.buildKeyFromEvent(event) === this.buildKeyFromColumn(column);
}
}

View file

@ -0,0 +1,70 @@
export class HeaderDrawerManager {
private drawer!: HTMLElement;
private expanded = false;
private currentRows = 0;
private readonly rowHeight = 25;
private readonly duration = 200;
init(container: HTMLElement): void {
this.drawer = container.querySelector('swp-header-drawer')!;
if (!this.drawer) console.error('HeaderDrawerManager: swp-header-drawer not found');
}
toggle(): void {
this.expanded ? this.collapse() : this.expand();
}
/**
* Expand drawer to single row (legacy support)
*/
expand(): void {
this.expandToRows(1);
}
/**
* Expand drawer to fit specified number of rows
*/
expandToRows(rowCount: number): void {
const targetHeight = rowCount * this.rowHeight;
const currentHeight = this.expanded ? this.currentRows * this.rowHeight : 0;
// Skip if already at target
if (this.expanded && this.currentRows === rowCount) return;
this.currentRows = rowCount;
this.expanded = true;
this.animate(currentHeight, targetHeight);
}
collapse(): void {
if (!this.expanded) return;
const currentHeight = this.currentRows * this.rowHeight;
this.expanded = false;
this.currentRows = 0;
this.animate(currentHeight, 0);
}
private animate(from: number, to: number): void {
const keyframes = [
{ height: `${from}px` },
{ height: `${to}px` }
];
const options: KeyframeAnimationOptions = {
duration: this.duration,
easing: 'ease',
fill: 'forwards'
};
// Kun animér drawer - ScrollManager synkroniserer header-spacer via ResizeObserver
this.drawer.animate(keyframes, options);
}
isExpanded(): boolean {
return this.expanded;
}
getRowCount(): number {
return this.currentRows;
}
}

View file

@ -0,0 +1,15 @@
/**
* IEntityResolver - Resolves entities by type and ID
*
* Used by FilterTemplate to resolve dot-notation references like 'resource.teamId'
* where the value needs to be looked up from a related entity.
*/
export interface IEntityResolver {
/**
* Resolve an entity by type and ID
* @param entityType - The entity type (e.g., 'resource', 'booking', 'customer')
* @param id - The entity ID
* @returns The entity record or undefined if not found
*/
resolve(entityType: string, id: string): Record<string, unknown> | undefined;
}

View file

@ -0,0 +1,7 @@
export interface IGridConfig {
hourHeight: number; // pixels per hour
dayStartHour: number; // e.g. 6
dayEndHour: number; // e.g. 18
snapInterval: number; // minutes, e.g. 15
gridStartThresholdMinutes?: number; // threshold for GRID grouping (default 10)
}

View file

@ -0,0 +1,15 @@
import { GroupingConfig } from './ViewConfig';
export interface IRenderContext {
headerContainer: HTMLElement;
columnContainer: HTMLElement;
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
groupings?: GroupingConfig[]; // Full grouping configs (for hideHeader etc.)
parentChildMap?: Record<string, string[]>; // { team1: ['EMP001', 'EMP002'], team2: ['EMP003', 'EMP004'] }
childType?: string; // The type of the child grouping (e.g., 'resource' when team has belongsTo)
}
export interface IRenderer {
readonly type: string;
render(context: IRenderContext): void | Promise<void>;
}

View file

@ -0,0 +1,4 @@
export interface IGroupingStore<T = unknown> {
readonly type: string;
getByIds(ids: string[]): T[];
}

View file

@ -0,0 +1,7 @@
export interface ITimeFormatConfig {
timezone: string;
use24HourFormat: boolean;
locale: string;
dateFormat: 'locale' | 'technical';
showSeconds: boolean;
}

View file

@ -0,0 +1,64 @@
export class NavigationAnimator {
constructor(
private headerTrack: HTMLElement,
private contentTrack: HTMLElement,
private headerDrawer: HTMLElement | null
) {}
async slide(direction: 'left' | 'right', renderFn: () => Promise<void>): Promise<void> {
const out = direction === 'left' ? '-100%' : '100%';
const into = direction === 'left' ? '100%' : '-100%';
await this.animateOut(out);
await renderFn();
await this.animateIn(into);
}
private async animateOut(translate: string): Promise<void> {
const animations = [
this.headerTrack.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
{ duration: 200, easing: 'ease-in' }
).finished,
this.contentTrack.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
{ duration: 200, easing: 'ease-in' }
).finished
];
if (this.headerDrawer) {
animations.push(
this.headerDrawer.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
{ duration: 200, easing: 'ease-in' }
).finished
);
}
await Promise.all(animations);
}
private async animateIn(translate: string): Promise<void> {
const animations = [
this.headerTrack.animate(
[{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
{ duration: 200, easing: 'ease-out' }
).finished,
this.contentTrack.animate(
[{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
{ duration: 200, easing: 'ease-out' }
).finished
];
if (this.headerDrawer) {
animations.push(
this.headerDrawer.animate(
[{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
{ duration: 200, easing: 'ease-out' }
).finished
);
}
await Promise.all(animations);
}
}

View file

@ -0,0 +1,15 @@
import { IRenderer, IRenderContext } from './IGroupingRenderer';
export interface Pipeline {
run(context: IRenderContext): Promise<void>;
}
export function buildPipeline(renderers: IRenderer[]): Pipeline {
return {
async run(context: IRenderContext) {
for (const renderer of renderers) {
await renderer.render(context);
}
}
};
}

View file

@ -0,0 +1,42 @@
export class ScrollManager {
private scrollableContent!: HTMLElement;
private timeAxisContent!: HTMLElement;
private calendarHeader!: HTMLElement;
private headerDrawer!: HTMLElement;
private headerViewport!: HTMLElement;
private headerSpacer!: HTMLElement;
private resizeObserver!: ResizeObserver;
init(container: HTMLElement): void {
this.scrollableContent = container.querySelector('swp-scrollable-content')!;
this.timeAxisContent = container.querySelector('swp-time-axis-content')!;
this.calendarHeader = container.querySelector('swp-calendar-header')!;
this.headerDrawer = container.querySelector('swp-header-drawer')!;
this.headerViewport = container.querySelector('swp-header-viewport')!;
this.headerSpacer = container.querySelector('swp-header-spacer')!;
this.scrollableContent.addEventListener('scroll', () => this.onScroll());
// Synkroniser header-spacer højde med header-viewport
this.resizeObserver = new ResizeObserver(() => this.syncHeaderSpacerHeight());
this.resizeObserver.observe(this.headerViewport);
this.syncHeaderSpacerHeight();
}
private syncHeaderSpacerHeight(): void {
// Kopier den faktiske computed height direkte fra header-viewport
const computedHeight = getComputedStyle(this.headerViewport).height;
this.headerSpacer.style.height = computedHeight;
}
private onScroll(): void {
const { scrollTop, scrollLeft } = this.scrollableContent;
// Synkroniser time-axis vertikalt
this.timeAxisContent.style.transform = `translateY(-${scrollTop}px)`;
// Synkroniser header og drawer horisontalt
this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`;
this.headerDrawer.style.transform = `translateX(-${scrollLeft}px)`;
}
}

View file

@ -0,0 +1,21 @@
import { ISync } from '../types/CalendarTypes';
export interface ViewTemplate {
id: string;
name: string;
groupingTypes: string[];
}
export interface ViewConfig extends ISync {
id: string; // templateId (e.g. 'day', 'simple', 'resource')
groupings: GroupingConfig[];
}
export interface GroupingConfig {
type: string;
values: string[];
idProperty?: string; // Property-navn på event (f.eks. 'resourceId') - kun for event matching
derivedFrom?: string; // Hvis feltet udledes fra anden property (f.eks. 'date' fra 'start')
belongsTo?: string; // Parent-child relation (f.eks. 'team.resourceIds')
hideHeader?: boolean; // Skjul header-rækken for denne grouping (f.eks. dato i day-view)
}

View file

@ -0,0 +1,167 @@
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes';
import { CoreEvents } from '../../constants/CoreEvents';
/**
* AuditService - Entity service for audit entries
*
* RESPONSIBILITIES:
* - Store audit entries in IndexedDB
* - Listen for ENTITY_SAVED/ENTITY_DELETED events
* - Create audit entries for all entity changes
* - Emit AUDIT_LOGGED after saving (for SyncManager to listen)
*
* OVERRIDE PATTERN:
* - Overrides save() to NOT emit events (prevents infinite loops)
* - AuditService saves audit entries without triggering more audits
*
* EVENT CHAIN:
* Entity change ENTITY_SAVED/DELETED AuditService AUDIT_LOGGED SyncManager
*/
export class AuditService extends BaseEntityService<IAuditEntry> {
readonly storeName = 'audit';
readonly entityType: EntityType = 'Audit';
// Hardcoded userId for now - will come from session later
private static readonly DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
this.setupEventListeners();
}
/**
* Setup listeners for ENTITY_SAVED and ENTITY_DELETED events
*/
private setupEventListeners(): void {
// Listen for entity saves (create/update)
this.eventBus.on(CoreEvents.ENTITY_SAVED, (event: Event) => {
const detail = (event as CustomEvent).detail;
this.handleEntitySaved(detail);
});
// Listen for entity deletes
this.eventBus.on(CoreEvents.ENTITY_DELETED, (event: Event) => {
const detail = (event as CustomEvent).detail;
this.handleEntityDeleted(detail);
});
}
/**
* Handle ENTITY_SAVED event - create audit entry
*/
private async handleEntitySaved(payload: IEntitySavedPayload): Promise<void> {
// Don't audit audit entries (prevent infinite loops)
if (payload.entityType === 'Audit') return;
const auditEntry: IAuditEntry = {
id: crypto.randomUUID(),
entityType: payload.entityType,
entityId: payload.entityId,
operation: payload.operation,
userId: AuditService.DEFAULT_USER_ID,
timestamp: payload.timestamp,
changes: payload.changes,
synced: false,
syncStatus: 'pending'
};
await this.save(auditEntry);
}
/**
* Handle ENTITY_DELETED event - create audit entry
*/
private async handleEntityDeleted(payload: IEntityDeletedPayload): Promise<void> {
// Don't audit audit entries (prevent infinite loops)
if (payload.entityType === 'Audit') return;
const auditEntry: IAuditEntry = {
id: crypto.randomUUID(),
entityType: payload.entityType,
entityId: payload.entityId,
operation: 'delete',
userId: AuditService.DEFAULT_USER_ID,
timestamp: payload.timestamp,
changes: { id: payload.entityId }, // For delete, just store the ID
synced: false,
syncStatus: 'pending'
};
await this.save(auditEntry);
}
/**
* Override save to NOT trigger ENTITY_SAVED event
* Instead, emits AUDIT_LOGGED for SyncManager to listen
*
* This prevents infinite loops:
* - BaseEntityService.save() emits ENTITY_SAVED
* - AuditService listens to ENTITY_SAVED and creates audit
* - If AuditService.save() also emitted ENTITY_SAVED, it would loop
*/
async save(entity: IAuditEntry): Promise<void> {
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(serialized);
request.onsuccess = () => {
// Emit AUDIT_LOGGED instead of ENTITY_SAVED
const payload: IAuditLoggedPayload = {
auditId: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
operation: entity.operation,
timestamp: entity.timestamp
};
this.eventBus.emit(CoreEvents.AUDIT_LOGGED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save audit entry ${entity.id}: ${request.error}`));
};
});
}
/**
* Override delete to NOT trigger ENTITY_DELETED event
* Audit entries should never be deleted (compliance requirement)
*/
async delete(_id: string): Promise<void> {
throw new Error('Audit entries cannot be deleted (compliance requirement)');
}
/**
* Get pending audit entries (for sync)
*/
async getPendingAudits(): Promise<IAuditEntry[]> {
return this.getBySyncStatus('pending');
}
/**
* Get audit entries for a specific entity
*/
async getByEntityId(entityId: string): Promise<IAuditEntry[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('entityId');
const request = index.getAll(entityId);
request.onsuccess = () => {
const entries = request.result as IAuditEntry[];
resolve(entries);
};
request.onerror = () => {
reject(new Error(`Failed to get audit entries for entity ${entityId}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,27 @@
import { IStore } from '../../storage/IStore';
/**
* AuditStore - IndexedDB store configuration for audit entries
*
* Stores all entity changes for:
* - Compliance and audit trail
* - Sync tracking with backend
* - Change history
*
* Indexes:
* - syncStatus: For finding pending entries to sync
* - synced: Boolean flag for quick sync queries
* - entityId: For getting all audits for a specific entity
* - timestamp: For chronological queries
*/
export class AuditStore implements IStore {
readonly storeName = 'audit';
create(db: IDBDatabase): void {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('synced', 'synced', { unique: false });
store.createIndex('entityId', 'entityId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
}

View file

@ -0,0 +1,14 @@
export { AuditService } from './AuditService';
export { AuditStore } from './AuditStore';
export type { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IStore } from '../../storage/IStore';
import { AuditStore } from './AuditStore';
import { AuditService } from './AuditService';
export function registerAudit(builder: Builder): void {
builder.registerType(AuditStore).as<IStore>();
builder.registerType(AuditService).as<AuditService>();
}

View file

@ -0,0 +1,75 @@
import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
/**
* BookingService - CRUD operations for bookings in IndexedDB
*/
export class BookingService extends BaseEntityService<IBooking> {
readonly storeName = BookingStore.STORE_NAME;
readonly entityType: EntityType = 'Booking';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(booking: IBooking): unknown {
return {
...booking,
createdAt: booking.createdAt.toISOString()
};
}
protected deserialize(data: unknown): IBooking {
const raw = data as Record<string, unknown>;
return {
...raw,
createdAt: new Date(raw.createdAt as string)
} as IBooking;
}
/**
* Get bookings for a customer
*/
async getByCustomer(customerId: string): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('customerId');
const request = index.getAll(customerId);
request.onsuccess = () => {
const data = request.result as unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings for customer ${customerId}: ${request.error}`));
};
});
}
/**
* Get bookings by status
*/
async getByStatus(status: BookingStatus): Promise<IBooking[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('status');
const request = index.getAll(status);
request.onsuccess = () => {
const data = request.result as unknown[];
const bookings = data.map(item => this.deserialize(item));
resolve(bookings);
};
request.onerror = () => {
reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,18 @@
import { IStore } from '../../storage/IStore';
/**
* BookingStore - IndexedDB ObjectStore definition for bookings
*/
export class BookingStore implements IStore {
static readonly STORE_NAME = 'bookings';
readonly storeName = BookingStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('customerId', 'customerId', { unique: false });
store.createIndex('status', 'status', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
}

View file

@ -0,0 +1,18 @@
export { BookingService } from './BookingService';
export { BookingStore } from './BookingStore';
export type { IBooking, BookingStatus, IBookingService } from '../../types/CalendarTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IStore } from '../../storage/IStore';
import { IEntityService } from '../../storage/IEntityService';
import type { IBooking, ISync } from '../../types/CalendarTypes';
import { BookingStore } from './BookingStore';
import { BookingService } from './BookingService';
export function registerBookings(builder: Builder): void {
builder.registerType(BookingStore).as<IStore>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(BookingService).as<IEntityService<ISync>>();
builder.registerType(BookingService).as<BookingService>();
}

View file

@ -0,0 +1,46 @@
import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
/**
* CustomerService - CRUD operations for customers in IndexedDB
*/
export class CustomerService extends BaseEntityService<ICustomer> {
readonly storeName = CustomerStore.STORE_NAME;
readonly entityType: EntityType = 'Customer';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Search customers by name (case-insensitive contains)
*/
async searchByName(query: string): Promise<ICustomer[]> {
const all = await this.getAll();
const lowerQuery = query.toLowerCase();
return all.filter(c => c.name.toLowerCase().includes(lowerQuery));
}
/**
* Find customer by phone
*/
async getByPhone(phone: string): Promise<ICustomer | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('phone');
const request = index.get(phone);
request.onsuccess = () => {
const data = request.result;
resolve(data ? (data as ICustomer) : null);
};
request.onerror = () => {
reject(new Error(`Failed to find customer by phone ${phone}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IStore } from '../../storage/IStore';
/**
* CustomerStore - IndexedDB ObjectStore definition for customers
*/
export class CustomerStore implements IStore {
static readonly STORE_NAME = 'customers';
readonly storeName = CustomerStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('phone', 'phone', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
}
}

View file

@ -0,0 +1,18 @@
export { CustomerService } from './CustomerService';
export { CustomerStore } from './CustomerStore';
export type { ICustomer } from '../../types/CalendarTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IStore } from '../../storage/IStore';
import { IEntityService } from '../../storage/IEntityService';
import type { ICustomer, ISync } from '../../types/CalendarTypes';
import { CustomerStore } from './CustomerStore';
import { CustomerService } from './CustomerService';
export function registerCustomers(builder: Builder): void {
builder.registerType(CustomerStore).as<IStore>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(CustomerService).as<IEntityService<ISync>>();
builder.registerType(CustomerService).as<CustomerService>();
}

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { DepartmentService } from './DepartmentService';
import { IDepartment } from '../../types/CalendarTypes';
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
readonly type = 'department';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-department-header',
idAttribute: 'departmentId',
colspanVar: '--department-cols'
};
constructor(private departmentService: DepartmentService) {
super();
}
protected getEntities(ids: string[]): Promise<IDepartment[]> {
return this.departmentService.getByIds(ids);
}
protected getDisplayName(entity: IDepartment): string {
return entity.name;
}
}

View file

@ -0,0 +1,25 @@
import { IDepartment, EntityType, IEventBus } from '../../types/CalendarTypes';
import { DepartmentStore } from './DepartmentStore';
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
/**
* DepartmentService - CRUD operations for departments in IndexedDB
*/
export class DepartmentService extends BaseEntityService<IDepartment> {
readonly storeName = DepartmentStore.STORE_NAME;
readonly entityType: EntityType = 'Department';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get departments by IDs
*/
async getByIds(ids: string[]): Promise<IDepartment[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((d): d is IDepartment => d !== null);
}
}

View file

@ -0,0 +1,13 @@
import { IStore } from '../../storage/IStore';
/**
* DepartmentStore - IndexedDB ObjectStore definition for departments
*/
export class DepartmentStore implements IStore {
static readonly STORE_NAME = 'departments';
readonly storeName = DepartmentStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(DepartmentStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,22 @@
export { DepartmentRenderer } from './DepartmentRenderer';
export { DepartmentService } from './DepartmentService';
export { DepartmentStore } from './DepartmentStore';
export type { IDepartment } from '../../types/CalendarTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IRenderer } from '../../core/IGroupingRenderer';
import { IStore } from '../../storage/IStore';
import { IEntityService } from '../../storage/IEntityService';
import type { IDepartment, ISync } from '../../types/CalendarTypes';
import { DepartmentStore } from './DepartmentStore';
import { DepartmentService } from './DepartmentService';
import { DepartmentRenderer } from './DepartmentRenderer';
export function registerDepartments(builder: Builder): void {
builder.registerType(DepartmentStore).as<IStore>();
builder.registerType(DepartmentService).as<IEntityService<IDepartment>>();
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
builder.registerType(DepartmentService).as<DepartmentService>();
builder.registerType(DepartmentRenderer).as<IRenderer>();
}

View file

@ -0,0 +1,84 @@
import { ITimeSlot } from '../../types/ScheduleTypes';
import { ResourceService } from '../../storage/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 '../../storage/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 '../../storage/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

@ -0,0 +1,17 @@
export { ScheduleOverrideService } from './ScheduleOverrideService';
export { ScheduleOverrideStore } from './ScheduleOverrideStore';
export { ResourceScheduleService } from './ResourceScheduleService';
export type { IScheduleOverride, ITimeSlot, IWeekSchedule, WeekDay } from '../../types/ScheduleTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IStore } from '../../storage/IStore';
import { ScheduleOverrideStore } from './ScheduleOverrideStore';
import { ScheduleOverrideService } from './ScheduleOverrideService';
import { ResourceScheduleService } from './ResourceScheduleService';
export function registerSchedules(builder: Builder): void {
builder.registerType(ScheduleOverrideStore).as<IStore>();
builder.registerType(ScheduleOverrideService).as<ScheduleOverrideService>();
builder.registerType(ResourceScheduleService).as<ResourceScheduleService>();
}

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { TeamService } from './TeamService';
import { ITeam } from '../../types/CalendarTypes';
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
readonly type = 'team';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-team-header',
idAttribute: 'teamId',
colspanVar: '--team-cols'
};
constructor(private teamService: TeamService) {
super();
}
protected getEntities(ids: string[]): Promise<ITeam[]> {
return this.teamService.getByIds(ids);
}
protected getDisplayName(entity: ITeam): string {
return entity.name;
}
}

View file

@ -0,0 +1,44 @@
import { ITeam, EntityType, IEventBus } from '../../types/CalendarTypes';
import { TeamStore } from './TeamStore';
import { BaseEntityService } from '../../storage/BaseEntityService';
import { IndexedDBContext } from '../../storage/IndexedDBContext';
/**
* TeamService - CRUD operations for teams in IndexedDB
*
* Teams define which resources belong together for hierarchical grouping.
* Extends BaseEntityService for standard entity operations.
*/
export class TeamService extends BaseEntityService<ITeam> {
readonly storeName = TeamStore.STORE_NAME;
readonly entityType: EntityType = 'Team';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get teams by IDs
*/
async getByIds(ids: string[]): Promise<ITeam[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((t): t is ITeam => t !== null);
}
/**
* Build reverse lookup: resourceId teamId
*/
async buildResourceToTeamMap(): Promise<Record<string, string>> {
const teams = await this.getAll();
const map: Record<string, string> = {};
for (const team of teams) {
for (const resourceId of team.resourceIds) {
map[resourceId] = team.id;
}
}
return map;
}
}

View file

@ -0,0 +1,13 @@
import { IStore } from '../../storage/IStore';
/**
* TeamStore - IndexedDB ObjectStore definition for teams
*/
export class TeamStore implements IStore {
static readonly STORE_NAME = 'teams';
readonly storeName = TeamStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(TeamStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,22 @@
export { TeamRenderer } from './TeamRenderer';
export { TeamService } from './TeamService';
export { TeamStore } from './TeamStore';
export type { ITeam } from '../../types/CalendarTypes';
// DI registration helper
import type { Builder } from '@novadi/core';
import { IRenderer } from '../../core/IGroupingRenderer';
import { IStore } from '../../storage/IStore';
import { IEntityService } from '../../storage/IEntityService';
import type { ITeam, ISync } from '../../types/CalendarTypes';
import { TeamStore } from './TeamStore';
import { TeamService } from './TeamService';
import { TeamRenderer } from './TeamRenderer';
export function registerTeams(builder: Builder): void {
builder.registerType(TeamStore).as<IStore>();
builder.registerType(TeamService).as<IEntityService<ITeam>>();
builder.registerType(TeamService).as<IEntityService<ISync>>();
builder.registerType(TeamService).as<TeamService>();
builder.registerType(TeamRenderer).as<IRenderer>();
}

View file

@ -0,0 +1,68 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { DateService } from '../../core/DateService';
export class DateRenderer implements IRenderer {
readonly type = 'date';
constructor(private dateService: DateService) {}
render(context: IRenderContext): void {
const dates = context.filter['date'] || [];
const resourceIds = context.filter['resource'] || [];
// Check if date headers should be hidden (e.g., in day view)
const dateGrouping = context.groupings?.find(g => g.type === 'date');
const hideHeader = dateGrouping?.hideHeader === true;
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
const iterations = resourceIds.length || 1;
let columnCount = 0;
for (let r = 0; r < iterations; r++) {
const resourceId = resourceIds[r]; // undefined hvis ingen resources
for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr);
// Build columnKey for uniform identification
const segments: Record<string, string> = { date: dateStr };
if (resourceId) segments.resource = resourceId;
const columnKey = this.dateService.buildColumnKey(segments);
// Header
const header = document.createElement('swp-day-header');
header.dataset.date = dateStr;
header.dataset.columnKey = columnKey;
if (resourceId) {
header.dataset.resourceId = resourceId;
}
if (hideHeader) {
header.dataset.hidden = 'true';
}
header.innerHTML = `
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
context.headerContainer.appendChild(header);
// Column
const column = document.createElement('swp-day-column');
column.dataset.date = dateStr;
column.dataset.columnKey = columnKey;
if (resourceId) {
column.dataset.resourceId = resourceId;
}
column.innerHTML = '<swp-events-layer></swp-events-layer>';
context.columnContainer.appendChild(column);
columnCount++;
}
}
// Set grid columns on container
const container = context.columnContainer.closest('swp-calendar-container');
if (container) {
(container as HTMLElement).style.setProperty('--grid-columns', String(columnCount));
}
}
}

View file

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

View file

@ -0,0 +1,279 @@
/**
* EventLayoutEngine - Simplified stacking/grouping algorithm
*
* Supports two layout modes:
* - GRID: Events starting at same time rendered side-by-side
* - STACKING: Overlapping events with margin-left offset (15px per level)
*
* No prev/next chains, single-pass greedy algorithm
*/
import { ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
/**
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
* This matches Scenario 8: end===start is NOT overlap
*/
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
return a.start < b.end && a.end > b.start;
}
/**
* Check if two events are within threshold for grid grouping.
* This includes:
* 1. Start-to-start: Events start within threshold of each other
* 2. End-to-start: One event starts within threshold before another ends
*/
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
const thresholdMs = thresholdMinutes * 60 * 1000;
// Start-to-start: both events start within threshold
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
if (startToStartDiff <= thresholdMs) return true;
// End-to-start: one event starts within threshold before the other ends
// B starts within threshold before A ends
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
// A starts within threshold before B ends
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
return false;
}
/**
* Check if all events in a group start within threshold of each other
*/
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
if (events.length <= 1) return true;
// Find earliest and latest start times
let earliest = events[0].start.getTime();
let latest = events[0].start.getTime();
for (const event of events) {
const time = event.start.getTime();
if (time < earliest) earliest = time;
if (time > latest) latest = time;
}
const diffMinutes = (latest - earliest) / (1000 * 60);
return diffMinutes <= thresholdMinutes;
}
/**
* Find groups of overlapping events (connected by overlap chain)
* Events are grouped if they overlap with any event in the group
*/
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
// Start a new group with this event
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand group by finding all connected events (via overlap)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
// Check if candidate overlaps with any event in group
const connects = group.some(member => eventsOverlap(member, candidate));
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Find grid candidates within a group - events connected via threshold chain
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
*/
function findGridCandidates(
events: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
const connects = group.some(member =>
eventsWithinThreshold(member, candidate, thresholdMinutes)
);
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Calculate stack levels for overlapping events using greedy algorithm
* For each event: level = max(overlapping already-processed events) + 1
*/
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
const levels = new Map<string, number>();
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
for (const event of sorted) {
let maxOverlappingLevel = -1;
// Find max level among overlapping events already processed
for (const [id, level] of levels) {
const other = events.find(e => e.id === id);
if (other && eventsOverlap(event, other)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
}
}
levels.set(event.id, maxOverlappingLevel + 1);
}
return levels;
}
/**
* Allocate events to columns for GRID layout using greedy algorithm
* Non-overlapping events can share a column to minimize total columns
*/
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const columns: ICalendarEvent[][] = [];
for (const event of sorted) {
// Find first column where event doesn't overlap with existing events
let placed = false;
for (const column of columns) {
const canFit = !column.some(e => eventsOverlap(event, e));
if (canFit) {
column.push(event);
placed = true;
break;
}
}
// No suitable column found, create new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
/**
* Main entry point: Calculate complete layout for a column's events
*
* Algorithm:
* 1. Find overlap groups (events connected by overlap chain)
* 2. For each overlap group, find grid candidates (events within threshold chain)
* 3. If all events in overlap group form a single grid candidate GRID mode
* 4. Otherwise STACKING mode with calculated levels
*/
export function calculateColumnLayout(
events: ICalendarEvent[],
config: IGridConfig
): IColumnLayout {
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
const result: IColumnLayout = {
grids: [],
stacked: []
};
if (events.length === 0) return result;
// Find all overlapping event groups
const overlapGroups = findOverlapGroups(events);
for (const overlapGroup of overlapGroups) {
if (overlapGroup.length === 1) {
// Single event - no grouping needed
result.stacked.push({
event: overlapGroup[0],
stackLevel: 0
});
continue;
}
// Within this overlap group, find grid candidates (threshold-connected subgroups)
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
// Check if the ENTIRE overlap group forms a single grid candidate
// This happens when all events are connected via threshold chain
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
g.length > max.length ? g : max, gridSubgroups[0]);
if (largestGridCandidate.length === overlapGroup.length) {
// All events in overlap group are connected via threshold chain → GRID mode
const columns = allocateColumns(overlapGroup);
const earliest = overlapGroup.reduce((min, e) =>
e.start < min.start ? e : min, overlapGroup[0]);
const position = calculateEventPosition(earliest.start, earliest.end, config);
result.grids.push({
events: overlapGroup,
columns,
stackLevel: 0,
position: { top: position.top }
});
} else {
// Not all events connected via threshold → STACKING mode
const levels = calculateStackLevels(overlapGroup);
for (const event of overlapGroup) {
result.stacked.push({
event,
stackLevel: levels.get(event.id) ?? 0
});
}
}
}
return result;
}

View file

@ -0,0 +1,35 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* Stack link metadata stored on event elements
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
*/
export interface IStackLink {
stackLevel: number;
}
/**
* Layout result for a stacked event (overlapping events with margin offset)
*/
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLevel: number;
}
/**
* Layout result for a grid group (simultaneous events side-by-side)
*/
export interface IGridGroupLayout {
events: ICalendarEvent[];
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
stackLevel: number; // Stack level for entire group (if nested in another event)
position: { top: number }; // Top position of earliest event in pixels
}
/**
* Complete layout result for a column's events
*/
export interface IColumnLayout {
grids: IGridGroupLayout[];
stacked: IStackedEventLayout[];
}

View file

@ -0,0 +1,434 @@
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes';
import { FilterTemplate } from '../../core/FilterTemplate';
/**
* EventRenderer - Renders calendar events to the DOM
*
* CLEAN approach:
* - Only data-id attribute on event element
* - innerHTML contains only visible content
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private container: HTMLElement | null = null;
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig,
private eventBus: IEventBus
) {
this.setupListeners();
}
/**
* Setup listeners for drag-drop and update events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
this.handleColumnChange(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => {
const payload = (e as CustomEvent<IDragMovePayload>).detail;
this.updateDragTimestamp(payload);
});
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
this.handleEventUpdated(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeaveHeader(payload);
});
}
/**
* Handle EVENT_DRAG_END - remove element if dropped in header
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Event was dropped in header drawer - remove from grid
const element = this.container?.querySelector(`swp-content-viewport swp-event[data-event-id="${payload.swpEvent.eventId}"]`);
element?.remove();
}
}
/**
* Handle header item leaving header - create swp-event in grid
*/
private handleDragLeaveHeader(payload: IDragLeaveHeaderPayload): void {
// Only handle when source is header (header item dragged to grid)
if (payload.source !== 'header') return;
if (!payload.targetColumn || !payload.start || !payload.end) return;
// Turn header item into ghost (stays visible but faded)
if (payload.element) {
payload.element.classList.add('drag-ghost');
payload.element.style.opacity = '0.3';
payload.element.style.pointerEvents = 'none';
}
// Create event object from header item data
const event: ICalendarEvent = {
id: payload.eventId,
title: payload.title || '',
description: '',
start: payload.start,
end: payload.end,
type: 'customer',
allDay: false,
syncStatus: 'pending'
};
// Create swp-event element using existing method
const element = this.createEventElement(event);
// Add to target column
let eventsLayer = payload.targetColumn.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
payload.targetColumn.appendChild(eventsLayer);
}
eventsLayer.appendChild(element);
// Mark as dragging so DragDropManager can continue with it
element.classList.add('dragging');
}
/**
* Handle EVENT_UPDATED - re-render affected columns
*/
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target)
if (payload.sourceColumnKey !== payload.targetColumnKey) {
await this.rerenderColumn(payload.sourceColumnKey);
}
// Re-render target column
await this.rerenderColumn(payload.targetColumnKey);
}
/**
* Re-render a single column with fresh data from IndexedDB
*/
private async rerenderColumn(columnKey: string): Promise<void> {
const column = this.findColumn(columnKey);
if (!column) return;
// Read date and resourceId directly from column attributes (columnKey is opaque)
const date = column.dataset.date;
const resourceId = column.dataset.resourceId;
if (!date) return;
// Get date range for this day
const startDate = new Date(date);
const endDate = new Date(date);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = resourceId
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
: await this.eventService.getByDateRange(startDate, endDate);
// Filter to timed events and match date exactly
const timedEvents = events.filter(event =>
!event.allDay && this.dateService.getDateKey(event.start) === date
);
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
}
/**
* Find a column element by columnKey
*/
private findColumn(columnKey: string): HTMLElement | null {
if (!this.container) return null;
return this.container.querySelector(`swp-day-column[data-column-key="${columnKey}"]`) as HTMLElement;
}
/**
* Handle event moving to a new column during drag
*/
private handleColumnChange(payload: IDragColumnChangePayload): void {
const eventsLayer = payload.newColumn.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Move element to new column
eventsLayer.appendChild(payload.element);
// Preserve Y position
payload.element.style.top = `${payload.currentY}px`;
}
/**
* Update timestamp display during drag (snapped to grid)
*/
private updateDragTimestamp(payload: IDragMovePayload): void {
const timeEl = payload.element.querySelector('swp-event-time');
if (!timeEl) return;
// Snap position to grid interval
const snappedY = snapToGrid(payload.currentY, this.gridConfig);
// Calculate new start time
const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromGridStart;
// Keep original duration (from element height)
const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight;
const durationMinutes = pixelsToMinutes(height, this.gridConfig);
// Create Date objects for consistent formatting via DateService
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(startMinutes + durationMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to a Date object (today)
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
}
/**
* Render events for visible dates into day columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and optionally 'resource' arrays
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store container reference for later re-renders
this.container = container;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get date range for query
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = await this.eventService.getByDateRange(startDate, endDate);
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
// Render events into each column based on FilterTemplate matching
columns.forEach(column => {
const columnEl = column as HTMLElement;
// Use FilterTemplate for matching - only fields in template are checked
const columnEvents = events.filter(event => filterTemplate.matches(event, columnEl));
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Filter to timed events only
const timedEvents = columnEvents.filter(event => !event.allDay);
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups (simultaneous events side-by-side)
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events (overlapping with margin offset)
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
});
}
/**
* Create a single event element
*
* CLEAN approach:
* - Only data-id for lookup
* - Visible content in innerHTML only
*/
private createEventElement(event: ICalendarEvent): HTMLElement {
const element = document.createElement('swp-event');
// Data attributes for SwpEvent compatibility
element.dataset.eventId = event.id;
if (event.resourceId) {
element.dataset.resourceId = event.resourceId;
}
// Calculate position
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
element.style.top = `${position.top}px`;
element.style.height = `${position.height}px`;
// Color class based on event type
const colorClass = this.getColorClass(event);
if (colorClass) {
element.classList.add(colorClass);
}
// Visible content only
element.innerHTML = `
<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>` : ''}
`;
return element;
}
/**
* Get color class based on metadata.color or event type
*/
private getColorClass(event: ICalendarEvent): string {
// Check metadata.color first
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
// Fallback to type-based color
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Render a GRID group with side-by-side columns
* Used when multiple events start at the same time
*/
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
const group = document.createElement('swp-event-group');
group.classList.add(`cols-${layout.columns.length}`);
group.style.top = `${layout.position.top}px`;
// Stack level styling for entire group (if nested in another event)
if (layout.stackLevel > 0) {
group.style.marginLeft = `${layout.stackLevel * 15}px`;
group.style.zIndex = `${100 + layout.stackLevel}`;
}
// Calculate the height needed for the group (tallest event)
let maxBottom = 0;
for (const event of layout.events) {
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
const eventBottom = pos.top + pos.height;
if (eventBottom > maxBottom) maxBottom = eventBottom;
}
const groupHeight = maxBottom - layout.position.top;
group.style.height = `${groupHeight}px`;
// Create wrapper div for each column
layout.columns.forEach(columnEvents => {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
columnEvents.forEach(event => {
const eventEl = this.createEventElement(event);
// Position relative to group top
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
eventEl.style.top = `${pos.top - layout.position.top}px`;
eventEl.style.position = 'absolute';
eventEl.style.left = '0';
eventEl.style.right = '0';
wrapper.appendChild(eventEl);
});
group.appendChild(wrapper);
});
return group;
}
/**
* Render a STACKED event with margin-left offset
* Used for overlapping events that don't start at the same time
*/
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
const element = this.createEventElement(event);
// Add stack metadata for drag-drop and other features
element.dataset.stackLink = JSON.stringify({ stackLevel });
// Visual styling based on stack level
if (stackLevel > 0) {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
return element;
}
}

View file

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

View file

@ -0,0 +1,135 @@
/**
* HeaderDrawerLayoutEngine - Calculates row placement for header items
*
* Prevents visual overlap by assigning items to different rows when
* they occupy the same columns. Uses a track-based algorithm similar
* to V1's AllDayLayoutEngine.
*
* Each row can hold multiple items as long as they don't overlap in columns.
* When an item spans columns that are already occupied, it's placed in the
* next available row.
*/
export interface IHeaderItemLayout {
itemId: string;
gridArea: string; // "row / col-start / row+1 / col-end"
startColumn: number;
endColumn: number;
row: number;
}
export interface IHeaderItemInput {
id: string;
columnStart: number; // 0-based column index
columnEnd: number; // 0-based end column (inclusive)
}
export class HeaderDrawerLayoutEngine {
private tracks: boolean[][] = [];
private columnCount: number;
constructor(columnCount: number) {
this.columnCount = columnCount;
this.reset();
}
/**
* Reset tracks for new layout calculation
*/
reset(): void {
this.tracks = [new Array(this.columnCount).fill(false)];
}
/**
* Calculate layout for all items
* Items should be sorted by start column for optimal packing
*/
calculateLayout(items: IHeaderItemInput[]): IHeaderItemLayout[] {
this.reset();
const layouts: IHeaderItemLayout[] = [];
for (const item of items) {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied in this row
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
// gridArea format: "row / col-start / row+1 / col-end"
// CSS grid uses 1-based indices
layouts.push({
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1 // 1-based for CSS
});
}
return layouts;
}
/**
* Calculate layout for a single new item
* Useful for real-time drag operations
*/
calculateSingleLayout(item: IHeaderItemInput): IHeaderItemLayout {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
return {
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1
};
}
/**
* Find the first row where all columns in range are available
*/
private findAvailableRow(startCol: number, endCol: number): number {
for (let row = 0; row < this.tracks.length; row++) {
if (this.isRowAvailable(row, startCol, endCol)) {
return row;
}
}
// Add new row if all existing rows are occupied
this.tracks.push(new Array(this.columnCount).fill(false));
return this.tracks.length - 1;
}
/**
* Check if columns in range are all available in given row
*/
private isRowAvailable(row: number, startCol: number, endCol: number): boolean {
for (let col = startCol; col <= endCol; col++) {
if (this.tracks[row][col]) {
return false;
}
}
return true;
}
/**
* Get the number of rows currently in use
*/
getRowCount(): number {
return this.tracks.length;
}
/**
* Update column count (e.g., when view changes)
*/
setColumnCount(count: number): void {
this.columnCount = count;
this.reset();
}
}

View file

@ -0,0 +1,419 @@
import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { CoreEvents } from '../../constants/CoreEvents';
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { FilterTemplate } from '../../core/FilterTemplate';
import {
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload,
IDragEndPayload
} from '../../types/DragTypes';
/**
* Layout information for a header item
*/
interface IHeaderItemLayout {
event: ICalendarEvent;
columnKey: string; // Opaque column identifier
row: number; // 1-indexed
colStart: number; // 1-indexed
colEnd: number; // exclusive
}
/**
* HeaderDrawerRenderer - Handles rendering of items in the header drawer
*
* Listens to drag events from DragDropManager and creates/manages
* swp-header-item elements in the header drawer.
*
* Uses subgrid for column alignment with parent swp-calendar-header.
* Position items via gridArea for explicit row/column placement.
*/
export class HeaderDrawerRenderer {
private currentItem: HTMLElement | null = null;
private container: HTMLElement | null = null;
private sourceElement: HTMLElement | null = null;
private wasExpandedBeforeDrag = false;
private filterTemplate: FilterTemplate | null = null;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private headerDrawerManager: HeaderDrawerManager,
private eventService: EventService,
private dateService: DateService
) {
this.setupListeners();
}
/**
* Render allDay events into the header drawer with row stacking
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store filterTemplate for buildColumnKeyFromEvent
this.filterTemplate = filterTemplate;
const drawer = container.querySelector('swp-header-drawer');
if (!drawer) return;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get column keys from DOM for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Fetch events for date range
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
const events = await this.eventService.getByDateRange(startDate, endDate);
// Filter to allDay events only (allDay !== false)
const allDayEvents = events.filter(event => event.allDay !== false);
// Clear existing items
drawer.innerHTML = '';
if (allDayEvents.length === 0) return;
// Calculate layout with row stacking using columnKeys
const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys);
const rowCount = Math.max(1, ...layouts.map(l => l.row));
// Render each item with layout
layouts.forEach(layout => {
const item = this.createHeaderItem(layout);
drawer.appendChild(item);
});
// Expand drawer to fit all rows
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Create a header item element from layout
*/
private createHeaderItem(layout: IHeaderItemLayout): HTMLElement {
const { event, columnKey, row, colStart, colEnd } = layout;
const item = document.createElement('swp-header-item');
item.dataset.eventId = event.id;
item.dataset.itemType = 'event';
item.dataset.start = event.start.toISOString();
item.dataset.end = event.end.toISOString();
item.dataset.columnKey = columnKey;
item.textContent = event.title;
// Color class
const colorClass = this.getColorClass(event);
if (colorClass) item.classList.add(colorClass);
// Grid position from layout
item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`;
return item;
}
/**
* Calculate layout for all events with row stacking
* Uses track-based algorithm to find available rows for overlapping events
*/
private calculateLayout(events: ICalendarEvent[], visibleColumnKeys: string[]): IHeaderItemLayout[] {
// tracks[row][col] = occupied
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
const layouts: IHeaderItemLayout[] = [];
for (const event of events) {
// Build columnKey from event fields (only place we need to construct it)
const columnKey = this.buildColumnKeyFromEvent(event);
const startCol = visibleColumnKeys.indexOf(columnKey);
const endColumnKey = this.buildColumnKeyFromEvent(event, event.end);
const endCol = visibleColumnKeys.indexOf(endColumnKey);
if (startCol === -1 && endCol === -1) continue;
// Clamp til synlige kolonner
const colStart = Math.max(0, startCol);
const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.length - 1) + 1;
// Find ledig række
const row = this.findAvailableRow(tracks, colStart, colEnd);
// Marker som optaget
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
layouts.push({ event, columnKey, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 });
}
return layouts;
}
/**
* Build columnKey from event using FilterTemplate
* Uses the same template that columns use for matching
*/
private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string {
if (!this.filterTemplate) {
// Fallback if no template - shouldn't happen in normal flow
const dateStr = this.dateService.getDateKey(date || event.start);
return dateStr;
}
// For multi-day events, we need to override the date in the event
if (date && date.getTime() !== event.start.getTime()) {
// Create temporary event with overridden start for key generation
const tempEvent = { ...event, start: date };
return this.filterTemplate.buildKeyFromEvent(tempEvent);
}
return this.filterTemplate.buildKeyFromEvent(event);
}
/**
* Find available row for event spanning columns [colStart, colEnd)
*/
private findAvailableRow(tracks: boolean[][], colStart: number, colEnd: number): number {
for (let row = 0; row < tracks.length; row++) {
let available = true;
for (let c = colStart; c < colEnd; c++) {
if (tracks[row][c]) { available = false; break; }
}
if (available) return row;
}
// Ny række
tracks.push(new Array(tracks[0].length).fill(false));
return tracks.length - 1;
}
/**
* Get color class based on event metadata or type
*/
private getColorClass(event: ICalendarEvent): string {
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Setup event listeners for drag events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
this.handleDragEnter(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
this.handleDragMove(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeave(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => {
this.cleanup();
});
}
/**
* Handle drag entering header zone - create preview item
*/
private handleDragEnter(payload: IDragEnterHeaderPayload): void {
this.container = document.querySelector('swp-header-drawer');
if (!this.container) return;
// Remember if drawer was already expanded
this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded();
// Expand to at least 1 row if collapsed, otherwise keep current height
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.expandToRows(1);
}
// Store reference to source element
this.sourceElement = payload.element;
// Create header item
const item = document.createElement('swp-header-item');
item.dataset.eventId = payload.eventId;
item.dataset.itemType = payload.itemType;
item.dataset.duration = String(payload.duration);
item.dataset.columnKey = payload.sourceColumnKey;
item.textContent = payload.title;
// Apply color class if present
if (payload.colorClass) {
item.classList.add(payload.colorClass);
}
// Add dragging state
item.classList.add('dragging');
// Initial placement (duration determines column span)
// gridArea format: "row / col-start / row+1 / col-end"
const col = payload.sourceColumnIndex + 1;
const endCol = col + payload.duration;
item.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
this.container.appendChild(item);
this.currentItem = item;
// Hide original element while in header
payload.element.style.visibility = 'hidden';
}
/**
* Handle drag moving within header - update column position
*/
private handleDragMove(payload: IDragMoveHeaderPayload): void {
if (!this.currentItem) return;
// Update column position
const col = payload.columnIndex + 1;
const duration = parseInt(this.currentItem.dataset.duration || '1', 10);
const endCol = col + duration;
this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
// Update columnKey to new position
this.currentItem.dataset.columnKey = payload.columnKey;
}
/**
* Handle drag leaving header - cleanup for gridheader drag only
*/
private handleDragLeave(payload: IDragLeaveHeaderPayload): void {
// Only cleanup for grid→header drag (when grid event leaves header back to grid)
// For header→grid drag, the header item stays as ghost until drop
if (payload.source === 'grid') {
this.cleanup();
}
// For header source, do nothing - ghost stays until EVENT_DRAG_END
}
/**
* Handle drag end - finalize based on drop target
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Grid→Header: Finalize the header item (it stays in header)
if (this.currentItem) {
this.currentItem.classList.remove('dragging');
this.recalculateDrawerLayout();
this.currentItem = null;
this.sourceElement = null;
}
} else {
// Header→Grid: Remove ghost header item and recalculate
const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`);
ghost?.remove();
this.recalculateDrawerLayout();
}
}
/**
* Recalculate layout for all items currently in the drawer
* Called after drop to reposition items and adjust height
*/
private recalculateDrawerLayout(): void {
const drawer = document.querySelector('swp-header-drawer');
if (!drawer) return;
const items = Array.from(drawer.querySelectorAll('swp-header-item')) as HTMLElement[];
if (items.length === 0) return;
// Get visible column keys for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Build layout data from DOM items - use columnKey directly (opaque matching)
const itemData = items.map(item => ({
element: item,
columnKey: item.dataset.columnKey || '',
duration: parseInt(item.dataset.duration || '1', 10)
}));
// Calculate new layout using track algorithm
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
for (const item of itemData) {
// Direct columnKey matching - no parsing or construction needed
const startCol = visibleColumnKeys.indexOf(item.columnKey);
if (startCol === -1) continue;
const colStart = startCol;
const colEnd = Math.min(startCol + item.duration, visibleColumnKeys.length);
const row = this.findAvailableRow(tracks, colStart, colEnd);
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
// Update element position
item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`;
}
// Update drawer height
const rowCount = tracks.length;
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Get visible column keys from DOM (preserves order for multi-resource views)
* Uses filterTemplate.buildKeyFromColumn() for consistent key format with events
*/
private getVisibleColumnKeysFromDOM(): string[] {
if (!this.filterTemplate) return [];
const columns = document.querySelectorAll('swp-day-column');
const columnKeys: string[] = [];
columns.forEach(col => {
const columnKey = this.filterTemplate!.buildKeyFromColumn(col as HTMLElement);
if (columnKey) columnKeys.push(columnKey);
});
return columnKeys;
}
/**
* Cleanup preview item and restore source visibility
*/
private cleanup(): void {
// Remove preview item
this.currentItem?.remove();
this.currentItem = null;
// Restore source element visibility
if (this.sourceElement) {
this.sourceElement.style.visibility = '';
this.sourceElement = null;
}
// Collapse drawer if it wasn't expanded before drag
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.collapse();
}
}
}

View file

@ -0,0 +1,2 @@
export { HeaderDrawerRenderer } from './HeaderDrawerRenderer';
export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine';

View file

@ -0,0 +1,69 @@
import { IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { ResourceService } from '../../storage/resources/ResourceService';
import { IResource } from '../../types/CalendarTypes';
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
readonly type = 'resource';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-resource-header',
idAttribute: 'resourceId',
colspanVar: '--resource-cols'
};
constructor(private resourceService: ResourceService) {
super();
}
protected getEntities(ids: string[]): Promise<IResource[]> {
return this.resourceService.getByIds(ids);
}
protected getDisplayName(entity: IResource): string {
return entity.displayName;
}
/**
* Override render to handle:
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
*/
async render(context: IRenderContext): Promise<void> {
const resourceIds = context.filter['resource'] || [];
const dateCount = context.filter['date']?.length || 1;
// Determine render order based on parentChildMap
// If parentChildMap exists, render resources grouped by parent (e.g., team)
// Otherwise, render in filter order
let orderedResourceIds: string[];
if (context.parentChildMap) {
// Render resources in parent-child order
orderedResourceIds = [];
for (const childIds of Object.values(context.parentChildMap)) {
for (const childId of childIds) {
if (resourceIds.includes(childId)) {
orderedResourceIds.push(childId);
}
}
}
} else {
orderedResourceIds = resourceIds;
}
const resources = await this.getEntities(orderedResourceIds);
// Create a map for quick lookup to preserve order
const resourceMap = new Map(resources.map(r => [r.id, r]));
for (const resourceId of orderedResourceIds) {
const resource = resourceMap.get(resourceId);
if (!resource) continue;
const header = this.createHeader(resource, context);
header.style.gridColumn = `span ${dateCount}`;
context.headerContainer.appendChild(header);
}
}
}

View file

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

View file

@ -0,0 +1,106 @@
import { ResourceScheduleService } from '../../extensions/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 date = (column as HTMLElement).dataset.date;
const resourceId = (column as HTMLElement).dataset.resourceId;
if (!date || !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, date);
// 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

@ -0,0 +1,10 @@
export class TimeAxisRenderer {
render(container: HTMLElement, startHour = 6, endHour = 20): void {
container.innerHTML = '';
for (let hour = startHour; hour <= endHour; hour++) {
const marker = document.createElement('swp-hour-marker');
marker.textContent = `${hour.toString().padStart(2, '0')}:00`;
container.appendChild(marker);
}
}
}

View file

@ -0,0 +1,164 @@
// === CORE ===
// App
export { CalendarApp } from './core/CalendarApp';
export { CalendarOrchestrator } from './core/CalendarOrchestrator';
export { CalendarEvents } from './core/CalendarEvents';
export type {
RenderPayload,
WorkweekChangePayload,
ViewUpdatePayload
} from './core/CalendarEvents';
// Infrastructure
export { EventBus } from './core/EventBus';
export { DateService } from './core/DateService';
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
export { IRenderer, IRenderContext } from './core/IGroupingRenderer';
export { IGroupingStore } from './core/IGroupingStore';
export { BaseGroupingRenderer, IGroupingRendererConfig } from './core/BaseGroupingRenderer';
export { buildPipeline, Pipeline } from './core/RenderBuilder';
export { NavigationAnimator } from './core/NavigationAnimator';
export { ScrollManager } from './core/ScrollManager';
export { HeaderDrawerManager } from './core/HeaderDrawerManager';
export { FilterTemplate } from './core/FilterTemplate';
export { EntityResolver } from './core/EntityResolver';
export type { IEntityResolver } from './core/IEntityResolver';
// Configuration interfaces
export type { ITimeFormatConfig } from './core/ITimeFormatConfig';
export type { IGridConfig } from './core/IGridConfig';
// Core Features
export { DateRenderer } from './features/date';
export { ResourceRenderer } from './features/resource';
export { EventRenderer } from './features/event';
export { eventsOverlap, calculateColumnLayout } from './features/event/EventLayoutEngine';
export type {
IStackLink,
IStackedEventLayout,
IGridGroupLayout,
IColumnLayout
} from './features/event/EventLayoutTypes';
export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
export { HeaderDrawerRenderer, HeaderDrawerLayoutEngine } from './features/headerdrawer';
export { ScheduleRenderer } from './features/schedule';
// Core Storage
export { IndexedDBContext, defaultDBConfig } from './storage/IndexedDBContext';
export type { IDBConfig } from './storage/IndexedDBContext';
export { BaseEntityService } from './storage/BaseEntityService';
export type { IEntityService } from './storage/IEntityService';
export type { IStore } from './storage/IStore';
export { SyncPlugin } from './storage/SyncPlugin';
// Event storage
export { EventService } from './storage/events/EventService';
export { EventStore } from './storage/events/EventStore';
export { EventSerialization } from './storage/events/EventSerialization';
// Resource storage
export { ResourceService } from './storage/resources/ResourceService';
export { ResourceStore } from './storage/resources/ResourceStore';
// Settings storage
export { SettingsService } from './storage/settings/SettingsService';
export { SettingsStore } from './storage/settings/SettingsStore';
// ViewConfig storage
export { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
export { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
// Core Managers
export { DragDropManager } from './managers/DragDropManager';
export { EdgeScrollManager } from './managers/EdgeScrollManager';
export { ResizeManager } from './managers/ResizeManager';
export { EventPersistenceManager } from './managers/EventPersistenceManager';
// Position utilities
export {
calculateEventPosition,
minutesToPixels,
pixelsToMinutes,
snapToGrid
} from './utils/PositionUtils';
export type { EventPosition } from './utils/PositionUtils';
// Types
export type {
ICalendarEvent,
IResource,
IEventBus,
ISync,
SyncStatus,
EntityType,
CalendarEventType,
ResourceType,
ITeam,
IDepartment,
IBooking,
BookingStatus,
IBookingService,
ICustomer,
IDataEntity,
IEventLogEntry,
IListenerEntry,
IEntitySavedPayload,
IEntityDeletedPayload,
IEventUpdatedPayload
} from './types/CalendarTypes';
export type {
TenantSetting,
IGridSettings,
IWorkweekPreset
} from './types/SettingsTypes';
export type {
IScheduleOverride,
ITimeSlot,
IWeekSchedule,
WeekDay
} from './types/ScheduleTypes';
// Drag types
export type {
IMousePosition,
IDragStartPayload,
IDragMovePayload,
IDragEndPayload,
IDragCancelPayload,
IDragColumnChangePayload,
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from './types/DragTypes';
// Resize types
export type {
IResizeStartPayload,
IResizeEndPayload
} from './types/ResizeTypes';
// Audit types
export type {
IAuditEntry,
IAuditLoggedPayload
} from './types/AuditTypes';
export { SwpEvent } from './types/SwpEvent';
// Core Events constants
export { CoreEvents } from './constants/CoreEvents';
// Repository interface (for custom implementations)
export type { IApiRepository } from './repositories/IApiRepository';
// DI helpers
export {
createCalendarContainer,
registerCoreServices,
defaultTimeFormatConfig,
defaultGridConfig
} from './CompositionRoot';
export type { ICalendarOptions } from './CompositionRoot';

View file

@ -0,0 +1,581 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { CoreEvents } from '../constants/CoreEvents';
import { snapToGrid } from '../utils/PositionUtils';
import {
IMousePosition,
IDragStartPayload,
IDragMovePayload,
IDragEndPayload,
IDragCancelPayload,
IDragColumnChangePayload,
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from '../types/DragTypes';
import { SwpEvent } from '../types/SwpEvent';
interface DragState {
eventId: string;
element: HTMLElement;
ghostElement: HTMLElement | null; // Null for header items
startY: number;
mouseOffset: IMousePosition;
columnElement: HTMLElement | null; // Null when starting from header
currentColumn: HTMLElement | null; // Null when in header
targetY: number;
currentY: number;
animationId: number;
sourceColumnKey: string; // Source column key (where drag started)
dragSource: 'grid' | 'header'; // Where drag originated
}
/**
* DragDropManager - Handles drag-drop for calendar events
*
* Strategy: Drag original element, leave ghost-clone in place
* - mousedown: Store initial state, wait for movement
* - mousemove (>5px): Create ghost, start dragging original
* - mouseup: Snap to grid, remove ghost, emit drag:end
* - cancel: Animate back to startY, remove ghost
*/
export class DragDropManager {
private dragState: DragState | null = null;
private mouseDownPosition: IMousePosition | null = null;
private pendingElement: HTMLElement | null = null;
private pendingMouseOffset: IMousePosition | null = null;
private container: HTMLElement | null = null;
private inHeader = false;
private readonly DRAG_THRESHOLD = 5;
private readonly INTERPOLATION_FACTOR = 0.3;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig
) {
this.setupScrollListener();
}
private setupScrollListener(): void {
this.eventBus.on(CoreEvents.EDGE_SCROLL_TICK, (e) => {
if (!this.dragState) return;
const { scrollDelta } = (e as CustomEvent<{ scrollDelta: number }>).detail;
// Element skal flytte med scroll for at forblive under musen
// (elementets top er relativ til kolonnen, som scroller med viewport)
this.dragState.targetY += scrollDelta;
this.dragState.currentY += scrollDelta;
this.dragState.element.style.top = `${this.dragState.currentY}px`;
});
}
/**
* Initialize drag-drop on a container element
*/
init(container: HTMLElement): void {
this.container = container;
container.addEventListener('pointerdown', this.handlePointerDown);
document.addEventListener('pointermove', this.handlePointerMove);
document.addEventListener('pointerup', this.handlePointerUp);
}
private handlePointerDown = (e: PointerEvent): void => {
const target = e.target as HTMLElement;
// Ignore if clicking on resize handle
if (target.closest('swp-resize-handle')) return;
// Match both swp-event and swp-header-item
const eventElement = target.closest('swp-event') as HTMLElement;
const headerItem = target.closest('swp-header-item') as HTMLElement;
const draggable = eventElement || headerItem;
if (!draggable) return;
// Store for potential drag
this.mouseDownPosition = { x: e.clientX, y: e.clientY };
this.pendingElement = draggable;
// Calculate mouse offset within element
const rect = draggable.getBoundingClientRect();
this.pendingMouseOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
// Capture pointer for reliable tracking
draggable.setPointerCapture(e.pointerId);
};
private handlePointerMove = (e: PointerEvent): void => {
// Not in potential drag state
if (!this.mouseDownPosition || !this.pendingElement) {
// Already dragging - update target
if (this.dragState) {
this.updateDragTarget(e);
}
return;
}
// Check threshold
const deltaX = Math.abs(e.clientX - this.mouseDownPosition.x);
const deltaY = Math.abs(e.clientY - this.mouseDownPosition.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance < this.DRAG_THRESHOLD) return;
// Start drag
this.initializeDrag(this.pendingElement, this.pendingMouseOffset!, e);
this.mouseDownPosition = null;
this.pendingElement = null;
this.pendingMouseOffset = null;
};
private handlePointerUp = (_e: PointerEvent): void => {
// Clear pending state
this.mouseDownPosition = null;
this.pendingElement = null;
this.pendingMouseOffset = null;
if (!this.dragState) return;
// Stop animation
cancelAnimationFrame(this.dragState.animationId);
// Handle based on drag source and target
if (this.dragState.dragSource === 'header') {
// Header item drag end
this.handleHeaderItemDragEnd();
} else {
// Grid event drag end
this.handleGridEventDragEnd();
}
// Cleanup
this.dragState.element.classList.remove('dragging');
this.dragState = null;
this.inHeader = false;
};
/**
* Handle drag end for header items
*/
private handleHeaderItemDragEnd(): void {
if (!this.dragState) return;
// If dropped in grid (not in header), the swp-event was already created
// by EventRenderer listening to EVENT_DRAG_LEAVE_HEADER
// Just emit drag:end for persistence
if (!this.inHeader && this.dragState.currentColumn) {
// Dropped in grid - emit drag:end with the new swp-event element
const gridEvent = this.dragState.currentColumn.querySelector(
`swp-event[data-event-id="${this.dragState.eventId}"]`
) as HTMLElement;
if (gridEvent) {
const columnKey = this.dragState.currentColumn.dataset.columnKey || '';
const date = this.dragState.currentColumn.dataset.date || '';
const swpEvent = SwpEvent.fromElement(gridEvent, columnKey, date, this.gridConfig);
const payload: IDragEndPayload = {
swpEvent,
sourceColumnKey: this.dragState.sourceColumnKey,
target: 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
}
}
// If still in header, no persistence needed (stayed in header)
}
/**
* Handle drag end for grid events
*/
private handleGridEventDragEnd(): void {
if (!this.dragState || !this.dragState.columnElement) return;
// Snap to grid
const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig);
this.dragState.element.style.top = `${snappedY}px`;
// Remove ghost
this.dragState.ghostElement?.remove();
// Get columnKey and date from target column
const columnKey = this.dragState.columnElement.dataset.columnKey || '';
const date = this.dragState.columnElement.dataset.date || '';
// Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement(
this.dragState.element,
columnKey,
date,
this.gridConfig
);
// Emit drag:end
const payload: IDragEndPayload = {
swpEvent,
sourceColumnKey: this.dragState.sourceColumnKey,
target: this.inHeader ? 'header' : 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
}
private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void {
const eventId = element.dataset.eventId || '';
const isHeaderItem = element.tagName.toLowerCase() === 'swp-header-item';
const columnElement = element.closest('swp-day-column') as HTMLElement;
// For grid events, we need a column
if (!isHeaderItem && !columnElement) return;
if (isHeaderItem) {
// Header item drag initialization
this.initializeHeaderItemDrag(element, mouseOffset, eventId);
} else {
// Grid event drag initialization
this.initializeGridEventDrag(element, mouseOffset, e, columnElement, eventId);
}
}
/**
* Initialize drag for a header item (allDay event)
*/
private initializeHeaderItemDrag(element: HTMLElement, mouseOffset: IMousePosition, eventId: string): void {
// Mark as dragging
element.classList.add('dragging');
// Initialize drag state for header item
this.dragState = {
eventId,
element,
ghostElement: null, // No ghost for header items
startY: 0,
mouseOffset,
columnElement: null,
currentColumn: null,
targetY: 0,
currentY: 0,
animationId: 0,
sourceColumnKey: '', // Will be set from header item data
dragSource: 'header'
};
// Start in header mode
this.inHeader = true;
}
/**
* Initialize drag for a grid event
*/
private initializeGridEventDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent, columnElement: HTMLElement, eventId: string): void {
// Calculate absolute Y position using getBoundingClientRect
const elementRect = element.getBoundingClientRect();
const columnRect = columnElement.getBoundingClientRect();
const startY = elementRect.top - columnRect.top;
// If event is inside a group, move it to events-layer for correct positioning during drag
const group = element.closest('swp-event-group');
if (group) {
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (eventsLayer) {
eventsLayer.appendChild(element);
}
}
// Set consistent positioning for drag (works for both grouped and stacked events)
element.style.position = 'absolute';
element.style.top = `${startY}px`;
element.style.left = '2px';
element.style.right = '2px';
element.style.marginLeft = '0'; // Reset stacking margin
// Create ghost clone
const ghostElement = element.cloneNode(true) as HTMLElement;
ghostElement.classList.add('drag-ghost');
ghostElement.style.opacity = '0.3';
ghostElement.style.pointerEvents = 'none';
// Insert ghost before original
element.parentNode?.insertBefore(ghostElement, element);
// Setup element for dragging
element.classList.add('dragging');
// Calculate initial target from mouse position
const targetY = e.clientY - columnRect.top - mouseOffset.y;
// Initialize drag state
this.dragState = {
eventId,
element,
ghostElement,
startY,
mouseOffset,
columnElement,
currentColumn: columnElement,
targetY: Math.max(0, targetY),
currentY: startY,
animationId: 0,
sourceColumnKey: columnElement.dataset.columnKey || '',
dragSource: 'grid'
};
// Emit drag:start
const payload: IDragStartPayload = {
eventId,
element,
ghostElement,
startY,
mouseOffset,
columnElement
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_START, payload);
// Start animation loop
this.animateDrag();
}
private updateDragTarget(e: PointerEvent): void {
if (!this.dragState) return;
// Check header zone first
this.checkHeaderZone(e);
// Skip normal grid handling if in header
if (this.inHeader) return;
// Check for column change
const columnAtPoint = this.getColumnAtPoint(e.clientX);
// For header items entering grid, set initial column
if (this.dragState.dragSource === 'header' && columnAtPoint && !this.dragState.currentColumn) {
this.dragState.currentColumn = columnAtPoint;
this.dragState.columnElement = columnAtPoint;
}
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn && this.dragState.currentColumn) {
const payload: IDragColumnChangePayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
previousColumn: this.dragState.currentColumn,
newColumn: columnAtPoint,
currentY: this.dragState.currentY
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, payload);
this.dragState.currentColumn = columnAtPoint;
this.dragState.columnElement = columnAtPoint;
}
// Skip grid position updates if no column yet
if (!this.dragState.columnElement) return;
const columnRect = this.dragState.columnElement.getBoundingClientRect();
const targetY = e.clientY - columnRect.top - this.dragState.mouseOffset.y;
this.dragState.targetY = Math.max(0, targetY);
// Start animation if not running
if (!this.dragState.animationId) {
this.animateDrag();
}
}
/**
* Check if pointer is in header zone and emit appropriate events
*/
private checkHeaderZone(e: PointerEvent): void {
if (!this.dragState) return;
const headerViewport = document.querySelector('swp-header-viewport');
if (!headerViewport) return;
const rect = headerViewport.getBoundingClientRect();
const isInHeader = e.clientY < rect.bottom;
if (isInHeader && !this.inHeader) {
// Entered header (from grid)
this.inHeader = true;
if (this.dragState.dragSource === 'grid' && this.dragState.columnElement) {
const payload: IDragEnterHeaderPayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement),
sourceColumnKey: this.dragState.columnElement.dataset.columnKey || '',
title: this.dragState.element.querySelector('swp-event-title')?.textContent || '',
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')),
itemType: 'event',
duration: 1
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload);
}
// For header source re-entering header, just update inHeader flag
} else if (!isInHeader && this.inHeader) {
// Left header (entering grid)
this.inHeader = false;
const targetColumn = this.getColumnAtPoint(e.clientX);
if (this.dragState.dragSource === 'header') {
// Header item leaving header → create swp-event in grid
const payload: IDragLeaveHeaderPayload = {
eventId: this.dragState.eventId,
source: 'header',
element: this.dragState.element,
targetColumn: targetColumn || undefined,
start: this.dragState.element.dataset.start ? new Date(this.dragState.element.dataset.start) : undefined,
end: this.dragState.element.dataset.end ? new Date(this.dragState.element.dataset.end) : undefined,
title: this.dragState.element.textContent || '',
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-'))
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
// Re-attach to the new swp-event created by EventRenderer
if (targetColumn) {
const newElement = targetColumn.querySelector(
`swp-event[data-event-id="${this.dragState.eventId}"]`
) as HTMLElement;
if (newElement) {
this.dragState.element = newElement;
this.dragState.columnElement = targetColumn;
this.dragState.currentColumn = targetColumn;
// Start animation for the new element
this.animateDrag();
}
}
} else {
// Grid event leaving header → restore to grid
const payload: IDragLeaveHeaderPayload = {
eventId: this.dragState.eventId,
source: 'grid'
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
}
} else if (isInHeader) {
// Moving within header
const column = this.getColumnAtX(e.clientX);
if (column) {
const payload: IDragMoveHeaderPayload = {
eventId: this.dragState.eventId,
columnIndex: this.getColumnIndex(column),
columnKey: column.dataset.columnKey || ''
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload);
}
}
}
/**
* Get column index (0-based) for a column element
*/
private getColumnIndex(column: HTMLElement | null): number {
if (!this.container || !column) return 0;
const columns = Array.from(this.container.querySelectorAll('swp-day-column'));
return columns.indexOf(column);
}
/**
* Get column at X coordinate (alias for getColumnAtPoint)
*/
private getColumnAtX(clientX: number): HTMLElement | null {
return this.getColumnAtPoint(clientX);
}
/**
* Find column element at given X coordinate
*/
private getColumnAtPoint(clientX: number): HTMLElement | null {
if (!this.container) return null;
const columns = this.container.querySelectorAll('swp-day-column');
for (const col of columns) {
const rect = col.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right) {
return col as HTMLElement;
}
}
return null;
}
private animateDrag = (): void => {
if (!this.dragState) return;
const diff = this.dragState.targetY - this.dragState.currentY;
// Stop animation when close enough to target
if (Math.abs(diff) <= 0.5) {
this.dragState.animationId = 0;
return;
}
// Interpolate towards target
this.dragState.currentY += diff * this.INTERPOLATION_FACTOR;
// Update element position
this.dragState.element.style.top = `${this.dragState.currentY}px`;
// Emit drag:move (only if we have a column)
if (this.dragState.columnElement) {
const payload: IDragMovePayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
currentY: this.dragState.currentY,
columnElement: this.dragState.columnElement
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload);
}
// Continue animation
this.dragState.animationId = requestAnimationFrame(this.animateDrag);
};
/**
* Cancel drag and animate back to start position
*/
cancelDrag(): void {
if (!this.dragState) return;
// Stop animation
cancelAnimationFrame(this.dragState.animationId);
const { element, ghostElement, startY, eventId } = this.dragState;
// Animate back to start
element.style.transition = 'top 200ms ease-out';
element.style.top = `${startY}px`;
// Remove ghost after animation (if exists)
setTimeout(() => {
ghostElement?.remove();
element.style.transition = '';
element.classList.remove('dragging');
}, 200);
// Emit drag:cancel
const payload: IDragCancelPayload = {
eventId,
element,
startY
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload);
this.dragState = null;
this.inHeader = false;
}
}

View file

@ -0,0 +1,140 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near viewport edges
*
* 2-zone system:
* - Inner zone (0-50px): Fast scroll (640 px/sec)
* - Outer zone (50-100px): Slow scroll (140 px/sec)
*/
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedElement: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false;
private lastTs = 0;
private rect: DOMRect | null = null;
private initialScrollTop = 0;
private readonly OUTER_ZONE = 100;
private readonly INNER_ZONE = 50;
private readonly SLOW_SPEED = 140;
private readonly FAST_SPEED = 640;
constructor(private eventBus: IEventBus) {
this.subscribeToEvents();
document.addEventListener('pointermove', this.trackMouse);
}
init(scrollableContent: HTMLElement): void {
this.scrollableContent = scrollableContent;
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
this.scrollableContent.style.scrollBehavior = 'auto';
}
private trackMouse = (e: PointerEvent): void => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
};
private subscribeToEvents(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedElement = payload.element;
this.startDrag();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
}
private startDrag(): void {
this.isDragging = true;
this.isScrolling = false;
this.lastTs = 0;
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame(this.scrollTick);
}
}
private stopDrag(): void {
this.isDragging = false;
this.setScrollingState(false);
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private calculateVelocity(): number {
if (!this.rect) return 0;
const distTop = this.mouseY - this.rect.top;
const distBot = this.rect.bottom - this.mouseY;
if (distTop < this.INNER_ZONE) return -this.FAST_SPEED;
if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED;
if (distBot < this.INNER_ZONE) return this.FAST_SPEED;
if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED;
return 0;
}
private isAtBoundary(velocity: number): boolean {
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
const atBottom = velocity > 0 &&
this.draggedElement.getBoundingClientRect().bottom >=
this.timeGrid.getBoundingClientRect().bottom;
return atTop || atBottom;
}
private setScrollingState(scrolling: boolean): void {
if (this.isScrolling === scrolling) return;
this.isScrolling = scrolling;
if (scrolling) {
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
} else {
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
}
private scrollTick = (ts: number): void => {
if (!this.isDragging || !this.scrollableContent) return;
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
this.lastTs = ts;
this.rect ??= this.scrollableContent.getBoundingClientRect();
const velocity = this.calculateVelocity();
if (velocity !== 0 && !this.isAtBoundary(velocity)) {
const scrollDelta = velocity * dt;
this.scrollableContent.scrollTop += scrollDelta;
this.rect = null;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
this.setScrollingState(true);
} else {
this.setScrollingState(false);
}
this.scrollRAF = requestAnimationFrame(this.scrollTick);
};
}

View file

@ -0,0 +1,102 @@
/**
* EventPersistenceManager - Persists event changes to IndexedDB
*
* Listens to drag/resize events and updates IndexedDB via EventService.
* This bridges the gap between UI interactions and data persistence.
*/
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
import { EventService } from '../storage/events/EventService';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IDragEndPayload } from '../types/DragTypes';
import { IResizeEndPayload } from '../types/ResizeTypes';
export class EventPersistenceManager {
constructor(
private eventService: EventService,
private eventBus: IEventBus,
private dateService: DateService
) {
this.setupListeners();
}
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
}
/**
* Handle drag end - update event position in IndexedDB
*/
private handleDragEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Parse resourceId from columnKey if present
const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey);
// Update and save - start/end already calculated in SwpEvent
// Set allDay based on drop target:
// - header: allDay = true
// - grid: allDay = false (converts allDay event to timed)
const updatedEvent: ICalendarEvent = {
...event,
start: swpEvent.start,
end: swpEvent.end,
resourceId: resource ?? event.resourceId,
allDay: payload.target === 'header',
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: payload.sourceColumnKey,
targetColumnKey: swpEvent.columnKey
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
/**
* Handle resize end - update event duration in IndexedDB
*/
private handleResizeEnd = async (e: Event): Promise<void> => {
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
const { swpEvent } = payload;
// Get existing event to merge with
const event = await this.eventService.get(swpEvent.eventId);
if (!event) {
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
return;
}
// Update and save - end already calculated in SwpEvent
const updatedEvent: ICalendarEvent = {
...event,
end: swpEvent.end,
syncStatus: 'pending'
};
await this.eventService.save(updatedEvent);
// Emit EVENT_UPDATED for EventRenderer to re-render the column
// Resize stays in same column, so source and target are the same
const updatePayload: IEventUpdatedPayload = {
eventId: updatedEvent.id,
sourceColumnKey: swpEvent.columnKey,
targetColumnKey: swpEvent.columnKey
};
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
};
}

View file

@ -0,0 +1,290 @@
import { IEventBus } from '../types/CalendarTypes';
import { IGridConfig } from '../core/IGridConfig';
import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils';
import { DateService } from '../core/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes';
import { SwpEvent } from '../types/SwpEvent';
/**
* ResizeManager - Handles resize of calendar events
*
* Step 1: Handle creation on mouseover (CSS handles visibility)
* Step 2: Pointer events + resize start
* Step 3: RAF animation for smooth height update
* Step 4: Grid snapping + timestamp update
*/
interface ResizeState {
eventId: string;
element: HTMLElement;
handleElement: HTMLElement;
startY: number;
startHeight: number;
startDurationMinutes: number;
pointerId: number;
prevZIndex: string;
// Animation state
currentHeight: number;
targetHeight: number;
animationId: number | null;
}
export class ResizeManager {
private container: HTMLElement | null = null;
private resizeState: ResizeState | null = null;
private readonly Z_INDEX_RESIZING = '1000';
private readonly ANIMATION_SPEED = 0.35;
private readonly MIN_HEIGHT_MINUTES = 15;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private dateService: DateService
) {}
/**
* Initialize resize functionality on container
*/
init(container: HTMLElement): void {
this.container = container;
// Mouseover listener for handle creation (capture phase like V1)
container.addEventListener('mouseover', this.handleMouseOver, true);
// Pointer listeners for resize (capture phase like V1)
document.addEventListener('pointerdown', this.handlePointerDown, true);
document.addEventListener('pointermove', this.handlePointerMove, true);
document.addEventListener('pointerup', this.handlePointerUp, true);
}
/**
* Create resize handle element
*/
private createResizeHandle(): HTMLElement {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
/**
* Handle mouseover - create resize handle if not exists
*/
private handleMouseOver = (e: Event): void => {
const target = e.target as HTMLElement;
const eventElement = target.closest('swp-event') as HTMLElement;
if (!eventElement || this.resizeState) return;
// Check if handle already exists
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
const handle = this.createResizeHandle();
eventElement.appendChild(handle);
}
};
/**
* Handle pointerdown - start resize if on handle
*/
private handlePointerDown = (e: PointerEvent): void => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement;
if (!handle) return;
const element = handle.parentElement as HTMLElement;
if (!element) return;
const eventId = element.dataset.eventId || '';
const startHeight = element.offsetHeight;
const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig);
// Store previous z-index
const container = element.closest('swp-event-group') as HTMLElement ?? element;
const prevZIndex = container.style.zIndex;
// Set resize state
this.resizeState = {
eventId,
element,
handleElement: handle,
startY: e.clientY,
startHeight,
startDurationMinutes,
pointerId: e.pointerId,
prevZIndex,
// Animation state
currentHeight: startHeight,
targetHeight: startHeight,
animationId: null
};
// Elevate z-index
container.style.zIndex = this.Z_INDEX_RESIZING;
// Capture pointer for smooth tracking
try {
handle.setPointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer capture failed:', err);
}
// Add global resizing class
document.documentElement.classList.add('swp--resizing');
// Emit resize start event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, {
eventId,
element,
startHeight
} as IResizeStartPayload);
e.preventDefault();
};
/**
* Handle pointermove - update target height during resize
*/
private handlePointerMove = (e: PointerEvent): void => {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight;
const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY);
// Set target height for animation
this.resizeState.targetHeight = newHeight;
// Start animation if not running
if (this.resizeState.animationId === null) {
this.animateHeight();
}
};
/**
* RAF animation loop for smooth height interpolation
*/
private animateHeight = (): void => {
if (!this.resizeState) return;
const diff = this.resizeState.targetHeight - this.resizeState.currentHeight;
// Stop animation when close enough
if (Math.abs(diff) < 0.5) {
this.resizeState.animationId = null;
return;
}
// Interpolate towards target (35% per frame like V1)
this.resizeState.currentHeight += diff * this.ANIMATION_SPEED;
this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`;
// Update timestamp display (snapped)
this.updateTimestampDisplay();
// Continue animation
this.resizeState.animationId = requestAnimationFrame(this.animateHeight);
};
/**
* Update timestamp display with snapped end time
*/
private updateTimestampDisplay(): void {
if (!this.resizeState) return;
const timeEl = this.resizeState.element.querySelector('swp-event-time');
if (!timeEl) return;
// Get start time from element position
const top = parseFloat(this.resizeState.element.style.top) || 0;
const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid;
// Calculate snapped end time from current height
const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig);
const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig);
const endMinutes = startMinutes + durationMinutes;
// Format and update
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(endMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to Date
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
};
/**
* Handle pointerup - finish resize
*/
private handlePointerUp = (e: PointerEvent): void => {
if (!this.resizeState) return;
// Cancel any pending animation
if (this.resizeState.animationId !== null) {
cancelAnimationFrame(this.resizeState.animationId);
}
// Release pointer capture
try {
this.resizeState.handleElement.releasePointerCapture(e.pointerId);
} catch (err) {
console.warn('Pointer release failed:', err);
}
// Snap final height to grid
this.snapToGridFinal();
// Update timestamp one final time
this.updateTimestampDisplay();
// Restore z-index
const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element;
container.style.zIndex = this.resizeState.prevZIndex;
// Remove global resizing class
document.documentElement.classList.remove('swp--resizing');
// Get columnKey and date from parent column
const column = this.resizeState.element.closest('swp-day-column') as HTMLElement;
const columnKey = column?.dataset.columnKey || '';
const date = column?.dataset.date || '';
// Create SwpEvent from element (reads top/height/eventId from element)
const swpEvent = SwpEvent.fromElement(
this.resizeState.element,
columnKey,
date,
this.gridConfig
);
// Emit resize end event
this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, {
swpEvent
} as IResizeEndPayload);
// Reset state
this.resizeState = null;
};
/**
* Snap final height to grid interval
*/
private snapToGridFinal(): void {
if (!this.resizeState) return;
const currentHeight = this.resizeState.element.offsetHeight;
const snappedHeight = snapToGrid(currentHeight, this.gridConfig);
const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig);
const finalHeight = Math.max(minHeight, snappedHeight);
this.resizeState.element.style.height = `${finalHeight}px`;
this.resizeState.currentHeight = finalHeight;
}
}

View file

@ -0,0 +1,33 @@
import { EntityType } from '../types/CalendarTypes';
/**
* IApiRepository<T> - Generic interface for backend API communication
*
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
*/
export interface IApiRepository<T> {
/**
* Entity type discriminator - used for runtime routing
*/
readonly entityType: EntityType;
/**
* Send create operation to backend API
*/
sendCreate(data: T): Promise<T>;
/**
* Send update operation to backend API
*/
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
/**
* Send delete operation to backend API
*/
sendDelete(id: string): Promise<void>;
/**
* Fetch all entities from backend API
*/
fetchAll(): Promise<T[]>;
}

View file

@ -0,0 +1,181 @@
import { ISync, EntityType, SyncStatus, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../types/CalendarTypes';
import { IEntityService } from './IEntityService';
import { SyncPlugin } from './SyncPlugin';
import { IndexedDBContext } from './IndexedDBContext';
import { CoreEvents } from '../constants/CoreEvents';
import { diff } from 'json-diff-ts';
/**
* BaseEntityService<T extends ISync> - Abstract base class for all entity services
*
* PROVIDES:
* - Generic CRUD operations (get, getAll, save, delete)
* - Sync status management (delegates to SyncPlugin)
* - Serialization hooks (override in subclass if needed)
*/
export abstract class BaseEntityService<T extends ISync> implements IEntityService<T> {
abstract readonly storeName: string;
abstract readonly entityType: EntityType;
private syncPlugin: SyncPlugin<T>;
private context: IndexedDBContext;
protected eventBus: IEventBus;
constructor(context: IndexedDBContext, eventBus: IEventBus) {
this.context = context;
this.eventBus = eventBus;
this.syncPlugin = new SyncPlugin<T>(this);
}
protected get db(): IDBDatabase {
return this.context.getDatabase();
}
/**
* Serialize entity before storing in IndexedDB
*/
protected serialize(entity: T): unknown {
return entity;
}
/**
* Deserialize data from IndexedDB back to entity
*/
protected deserialize(data: unknown): T {
return data as T;
}
/**
* Get a single entity by ID
*/
async get(id: string): Promise<T | null> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onsuccess = () => {
const data = request.result;
resolve(data ? this.deserialize(data) : null);
};
request.onerror = () => {
reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`));
};
});
}
/**
* Get all entities
*/
async getAll(): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
const data = request.result as unknown[];
const entities = data.map(item => this.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get all ${this.entityType}s: ${request.error}`));
};
});
}
/**
* Save an entity (create or update)
* Emits ENTITY_SAVED event with operation type and changes (diff for updates)
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
async save(entity: T, silent = false): Promise<void> {
const entityId = (entity as unknown as { id: string }).id;
const existingEntity = await this.get(entityId);
const isCreate = existingEntity === null;
// Calculate changes: full entity for create, diff for update
let changes: unknown;
if (isCreate) {
changes = entity;
} else {
const existingSerialized = this.serialize(existingEntity);
const newSerialized = this.serialize(entity);
changes = diff(existingSerialized, newSerialized);
}
const serialized = this.serialize(entity);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(serialized);
request.onsuccess = () => {
// Only emit event if not silent (silent used for seeding)
if (!silent) {
const payload: IEntitySavedPayload = {
entityType: this.entityType,
entityId,
operation: isCreate ? 'create' : 'update',
changes,
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_SAVED, payload);
}
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to save ${this.entityType} ${entityId}: ${request.error}`));
};
});
}
/**
* Delete an entity
* Emits ENTITY_DELETED event
*/
async delete(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => {
const payload: IEntityDeletedPayload = {
entityType: this.entityType,
entityId: id,
operation: 'delete',
timestamp: Date.now()
};
this.eventBus.emit(CoreEvents.ENTITY_DELETED, payload);
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete ${this.entityType} ${id}: ${request.error}`));
};
});
}
// Sync methods - delegate to SyncPlugin
async markAsSynced(id: string): Promise<void> {
return this.syncPlugin.markAsSynced(id);
}
async markAsError(id: string): Promise<void> {
return this.syncPlugin.markAsError(id);
}
async getSyncStatus(id: string): Promise<SyncStatus | null> {
return this.syncPlugin.getSyncStatus(id);
}
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return this.syncPlugin.getBySyncStatus(syncStatus);
}
}

View file

@ -0,0 +1,40 @@
import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
/**
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services implement this interface to enable polymorphic operations.
*/
export interface IEntityService<T extends ISync> {
/**
* Entity type discriminator for runtime routing
*/
readonly entityType: EntityType;
/**
* Get all entities from IndexedDB
*/
getAll(): Promise<T[]>;
/**
* Save an entity (create or update) to IndexedDB
* @param entity - Entity to save
* @param silent - If true, skip event emission (used for seeding)
*/
save(entity: T, silent?: boolean): Promise<void>;
/**
* Mark entity as successfully synced
*/
markAsSynced(id: string): Promise<void>;
/**
* Mark entity as sync error
*/
markAsError(id: string): Promise<void>;
/**
* Get current sync status for an entity
*/
getSyncStatus(id: string): Promise<SyncStatus | null>;
}

View file

@ -0,0 +1,18 @@
/**
* IStore - Interface for IndexedDB ObjectStore definitions
*
* Each entity store implements this interface to define its schema.
* Enables Open/Closed Principle: IndexedDBContext works with any IStore.
*/
export interface IStore {
/**
* The name of the ObjectStore in IndexedDB
*/
readonly storeName: string;
/**
* Create the ObjectStore with its schema (indexes, keyPath, etc.)
* Called during database upgrade (onupgradeneeded event)
*/
create(db: IDBDatabase): void;
}

View file

@ -0,0 +1,108 @@
import { IStore } from './IStore';
/**
* Database configuration
*/
export interface IDBConfig {
dbName: string;
dbVersion?: number;
}
export const defaultDBConfig: IDBConfig = {
dbName: 'CalendarDB',
dbVersion: 4
};
/**
* IndexedDBContext - Database connection manager
*
* RESPONSIBILITY:
* - Opens and manages IDBDatabase connection lifecycle
* - Creates object stores via injected IStore implementations
* - Provides shared IDBDatabase instance to all services
*/
export class IndexedDBContext {
private db: IDBDatabase | null = null;
private initialized: boolean = false;
private stores: IStore[];
private config: IDBConfig;
constructor(stores: IStore[], config: IDBConfig) {
this.stores = stores;
this.config = config;
}
get dbName(): string {
return this.config.dbName;
}
/**
* Initialize and open the database
*/
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.config.dbName, this.config.dbVersion);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all entity stores via injected IStore implementations
this.stores.forEach(store => {
if (!db.objectStoreNames.contains(store.storeName)) {
store.create(db);
}
});
};
});
}
/**
* Check if database is initialized
*/
public isInitialized(): boolean {
return this.initialized;
}
/**
* Get IDBDatabase instance
*/
public getDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('IndexedDB not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.initialized = false;
}
}
/**
* Delete entire database (for testing/reset)
*/
static async deleteDatabase(dbName: string = defaultDBConfig.dbName): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`));
});
}
}

View file

@ -0,0 +1,64 @@
import { ISync, SyncStatus } from '../types/CalendarTypes';
/**
* SyncPlugin<T extends ISync> - Pluggable sync functionality for entity services
*
* COMPOSITION PATTERN:
* - Encapsulates all sync-related logic in separate class
* - Composed into BaseEntityService (not inheritance)
*/
export class SyncPlugin<T extends ISync> {
constructor(private service: any) {}
/**
* Mark entity as successfully synced
*/
async markAsSynced(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'synced';
await this.service.save(entity);
}
}
/**
* Mark entity as sync error
*/
async markAsError(id: string): Promise<void> {
const entity = await this.service.get(id);
if (entity) {
entity.syncStatus = 'error';
await this.service.save(entity);
}
}
/**
* Get current sync status for an entity
*/
async getSyncStatus(id: string): Promise<SyncStatus | null> {
const entity = await this.service.get(id);
return entity ? entity.syncStatus : null;
}
/**
* Get entities by sync status using IndexedDB index
*/
async getBySyncStatus(syncStatus: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const transaction = this.service.db.transaction([this.service.storeName], 'readonly');
const store = transaction.objectStore(this.service.storeName);
const index = store.index('syncStatus');
const request = index.getAll(syncStatus);
request.onsuccess = () => {
const data = request.result as unknown[];
const entities = data.map(item => this.service.deserialize(item));
resolve(entities);
};
request.onerror = () => {
reject(new Error(`Failed to get by sync status ${syncStatus}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,32 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* EventSerialization - Handles Date field serialization for IndexedDB
*
* IndexedDB doesn't store Date objects directly, so we convert:
* - Date ISO string (serialize) when writing
* - ISO string Date (deserialize) when reading
*/
export class EventSerialization {
/**
* Serialize event for IndexedDB storage
*/
static serialize(event: ICalendarEvent): unknown {
return {
...event,
start: event.start instanceof Date ? event.start.toISOString() : event.start,
end: event.end instanceof Date ? event.end.toISOString() : event.end
};
}
/**
* Deserialize event from IndexedDB storage
*/
static deserialize(data: Record<string, unknown>): ICalendarEvent {
return {
...data,
start: typeof data.start === 'string' ? new Date(data.start) : data.start,
end: typeof data.end === 'string' ? new Date(data.end) : data.end
} as ICalendarEvent;
}
}

View file

@ -0,0 +1,84 @@
import { ICalendarEvent, EntityType, IEventBus } from '../../types/CalendarTypes';
import { EventStore } from './EventStore';
import { EventSerialization } from './EventSerialization';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* EventService - CRUD operations for calendar events in IndexedDB
*
* Extends BaseEntityService for shared CRUD and sync logic.
* Provides event-specific query methods.
*/
export class EventService extends BaseEntityService<ICalendarEvent> {
readonly storeName = EventStore.STORE_NAME;
readonly entityType: EntityType = 'Event';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
protected serialize(event: ICalendarEvent): unknown {
return EventSerialization.serialize(event);
}
protected deserialize(data: unknown): ICalendarEvent {
return EventSerialization.deserialize(data as Record<string, unknown>);
}
/**
* Get events within a date range
*/
async getByDateRange(start: Date, end: Date): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('start');
const range = IDBKeyRange.lowerBound(start.toISOString());
const request = index.getAll(range);
request.onsuccess = () => {
const data = request.result as unknown[];
const events = data
.map(item => this.deserialize(item))
.filter(event => event.start <= end);
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events by date range: ${request.error}`));
};
});
}
/**
* Get events for a specific resource
*/
async getByResource(resourceId: string): Promise<ICalendarEvent[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('resourceId');
const request = index.getAll(resourceId);
request.onsuccess = () => {
const data = request.result as unknown[];
const events = data.map(item => this.deserialize(item));
resolve(events);
};
request.onerror = () => {
reject(new Error(`Failed to get events for resource ${resourceId}: ${request.error}`));
};
});
}
/**
* Get events for a resource within a date range
*/
async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise<ICalendarEvent[]> {
const resourceEvents = await this.getByResource(resourceId);
return resourceEvents.filter(event => event.start >= start && event.start <= end);
}
}

View file

@ -0,0 +1,37 @@
import { IStore } from '../IStore';
/**
* EventStore - IndexedDB ObjectStore definition for calendar events
*/
export class EventStore implements IStore {
static readonly STORE_NAME = 'events';
readonly storeName = EventStore.STORE_NAME;
/**
* Create the events ObjectStore with indexes
*/
create(db: IDBDatabase): void {
const store = db.createObjectStore(EventStore.STORE_NAME, { keyPath: 'id' });
// Index: start (for date range queries)
store.createIndex('start', 'start', { unique: false });
// Index: end (for date range queries)
store.createIndex('end', 'end', { unique: false });
// Index: syncStatus (for filtering by sync state)
store.createIndex('syncStatus', 'syncStatus', { unique: false });
// Index: resourceId (for resource-mode filtering)
store.createIndex('resourceId', 'resourceId', { unique: false });
// Index: customerId (for customer-centric queries)
store.createIndex('customerId', 'customerId', { unique: false });
// Index: bookingId (for event-to-booking lookups)
store.createIndex('bookingId', 'bookingId', { unique: false });
// Compound index: startEnd (for optimized range queries)
store.createIndex('startEnd', ['start', 'end'], { unique: false });
}
}

View file

@ -0,0 +1,55 @@
import { IResource, EntityType, IEventBus } from '../../types/CalendarTypes';
import { ResourceStore } from './ResourceStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* ResourceService - CRUD operations for resources in IndexedDB
*/
export class ResourceService extends BaseEntityService<IResource> {
readonly storeName = ResourceStore.STORE_NAME;
readonly entityType: EntityType = 'Resource';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get all active resources
*/
async getActive(): Promise<IResource[]> {
const all = await this.getAll();
return all.filter(r => r.isActive !== false);
}
/**
* Get resources by IDs
*/
async getByIds(ids: string[]): Promise<IResource[]> {
if (ids.length === 0) return [];
const results = await Promise.all(ids.map(id => this.get(id)));
return results.filter((r): r is IResource => r !== null);
}
/**
* Get resources by type
*/
async getByType(type: string): Promise<IResource[]> {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('type');
const request = index.getAll(type);
request.onsuccess = () => {
const data = request.result as IResource[];
resolve(data);
};
request.onerror = () => {
reject(new Error(`Failed to get resources by type ${type}: ${request.error}`));
};
});
}
}

View file

@ -0,0 +1,17 @@
import { IStore } from '../IStore';
/**
* ResourceStore - IndexedDB ObjectStore definition for resources
*/
export class ResourceStore implements IStore {
static readonly STORE_NAME = 'resources';
readonly storeName = ResourceStore.STORE_NAME;
create(db: IDBDatabase): void {
const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' });
store.createIndex('type', 'type', { unique: false });
store.createIndex('syncStatus', 'syncStatus', { unique: false });
store.createIndex('isActive', 'isActive', { unique: false });
}
}

View file

@ -0,0 +1,83 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import {
TenantSetting,
IWorkweekSettings,
IGridSettings,
ITimeFormatSettings,
IViewSettings,
IWorkweekPreset,
SettingsIds
} from '../../types/SettingsTypes';
import { SettingsStore } from './SettingsStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* SettingsService - CRUD operations for tenant settings
*
* Settings are stored as separate records per section.
* This service provides typed methods for accessing specific settings.
*/
export class SettingsService extends BaseEntityService<TenantSetting> {
readonly storeName = SettingsStore.STORE_NAME;
readonly entityType: EntityType = 'Settings';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get workweek settings
*/
async getWorkweekSettings(): Promise<IWorkweekSettings | null> {
return this.get(SettingsIds.WORKWEEK) as Promise<IWorkweekSettings | null>;
}
/**
* Get grid settings
*/
async getGridSettings(): Promise<IGridSettings | null> {
return this.get(SettingsIds.GRID) as Promise<IGridSettings | null>;
}
/**
* Get time format settings
*/
async getTimeFormatSettings(): Promise<ITimeFormatSettings | null> {
return this.get(SettingsIds.TIME_FORMAT) as Promise<ITimeFormatSettings | null>;
}
/**
* Get view settings
*/
async getViewSettings(): Promise<IViewSettings | null> {
return this.get(SettingsIds.VIEWS) as Promise<IViewSettings | null>;
}
/**
* Get workweek preset by ID
*/
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[presetId] || null;
}
/**
* Get the default workweek preset
*/
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
const settings = await this.getWorkweekSettings();
if (!settings) return null;
return settings.presets[settings.defaultPreset] || null;
}
/**
* Get all available workweek presets
*/
async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
const settings = await this.getWorkweekSettings();
if (!settings) return [];
return Object.values(settings.presets);
}
}

View file

@ -0,0 +1,16 @@
import { IStore } from '../IStore';
/**
* SettingsStore - IndexedDB ObjectStore definition for tenant settings
*
* Single store for all settings sections. Settings are stored as one document
* per tenant with id='tenant-settings'.
*/
export class SettingsStore implements IStore {
static readonly STORE_NAME = 'settings';
readonly storeName = SettingsStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(SettingsStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,18 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ViewConfig } from '../../core/ViewConfig';
import { ViewConfigStore } from './ViewConfigStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
export class ViewConfigService extends BaseEntityService<ViewConfig> {
readonly storeName = ViewConfigStore.STORE_NAME;
readonly entityType: EntityType = 'ViewConfig';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
async getById(id: string): Promise<ViewConfig | null> {
return this.get(id);
}
}

View file

@ -0,0 +1,10 @@
import { IStore } from '../IStore';
export class ViewConfigStore implements IStore {
static readonly STORE_NAME = 'viewconfigs';
readonly storeName = ViewConfigStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(ViewConfigStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -0,0 +1,46 @@
import { ISync, EntityType } from './CalendarTypes';
/**
* IAuditEntry - Audit log entry for tracking all entity changes
*
* Used for:
* - Compliance and audit trail
* - Sync tracking with backend
* - Change history
*/
export interface IAuditEntry extends ISync {
/** Unique audit entry ID */
id: string;
/** Type of entity that was changed */
entityType: EntityType;
/** ID of the entity that was changed */
entityId: string;
/** Type of operation performed */
operation: 'create' | 'update' | 'delete';
/** User who made the change */
userId: string;
/** Timestamp when change was made */
timestamp: number;
/** Changes made (full entity for create, diff for update, { id } for delete) */
changes: unknown;
/** Whether this audit entry has been synced to backend */
synced: boolean;
}
/**
* IAuditLoggedPayload - Event payload when audit entry is logged
*/
export interface IAuditLoggedPayload {
auditId: string;
entityType: EntityType;
entityId: string;
operation: 'create' | 'update' | 'delete';
timestamp: number;
}

View file

@ -0,0 +1,170 @@
/**
* Calendar Type Definitions
*/
import { IWeekSchedule } from './ScheduleTypes';
export type SyncStatus = 'synced' | 'pending' | 'error';
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings' | 'ViewConfig';
/**
* CalendarEventType - Used by ICalendarEvent.type
* Note: Only 'customer' events have associated IBooking
*/
export type CalendarEventType =
| 'customer' // Customer appointment (HAS booking)
| 'vacation' // Vacation/time off (NO booking)
| 'break' // Lunch/break (NO booking)
| 'meeting' // Meeting (NO booking)
| 'blocked'; // Blocked time (NO booking)
/**
* ISync - Interface for sync status tracking
* All syncable entities should extend this interface
*/
export interface ISync {
syncStatus: SyncStatus;
}
/**
* IDataEntity - Wrapper for entity data with typename discriminator
*/
export interface IDataEntity {
typename: EntityType;
data: unknown;
}
export interface ICalendarEvent extends ISync {
id: string;
title: string;
description?: string;
start: Date;
end: Date;
type: CalendarEventType;
allDay: boolean;
// References (denormalized for IndexedDB performance)
bookingId?: string; // Reference to booking (only if type = 'customer')
resourceId?: string; // Resource who owns this time slot
customerId?: string; // Denormalized from Booking.customerId
recurringId?: string;
metadata?: Record<string, unknown>;
}
// EventBus types
export interface IEventLogEntry {
type: string;
detail: unknown;
timestamp: number;
}
export interface IListenerEntry {
eventType: string;
handler: EventListener;
options?: AddEventListenerOptions;
}
export interface IEventBus {
on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void;
once(eventType: string, handler: EventListener): () => void;
off(eventType: string, handler: EventListener): void;
emit(eventType: string, detail?: unknown): boolean;
getEventLog(eventType?: string): IEventLogEntry[];
setDebug(enabled: boolean): void;
}
// Entity event payloads
export interface IEntitySavedPayload {
entityType: EntityType;
entityId: string;
operation: 'create' | 'update';
changes: unknown;
timestamp: number;
}
export interface IEntityDeletedPayload {
entityType: EntityType;
entityId: string;
operation: 'delete';
timestamp: number;
}
// Event update payload (for re-rendering columns after drag/resize)
export interface IEventUpdatedPayload {
eventId: string;
sourceColumnKey: string; // Source column key (where event came from)
targetColumnKey: string; // Target column key (where event landed)
}
// Resource types
export type ResourceType =
| 'person'
| 'room'
| 'equipment'
| 'vehicle'
| 'custom';
export interface IResource extends ISync {
id: string;
name: string;
displayName: string;
type: ResourceType;
avatarUrl?: string;
color?: string;
isActive?: boolean;
defaultSchedule?: IWeekSchedule; // Default arbejdstider per ugedag
metadata?: Record<string, unknown>;
}
// Team types
export interface ITeam extends ISync {
id: string;
name: string;
resourceIds: string[];
}
// Department types
export interface IDepartment extends ISync {
id: string;
name: string;
resourceIds: string[];
}
// Booking types
export type BookingStatus =
| 'created'
| 'arrived'
| 'paid'
| 'noshow'
| 'cancelled';
export interface IBookingService {
serviceId: string;
serviceName: string;
baseDuration: number;
basePrice: number;
customPrice?: number;
resourceId: string;
}
export interface IBooking extends ISync {
id: string;
customerId: string;
status: BookingStatus;
createdAt: Date;
services: IBookingService[];
totalPrice?: number;
tags?: string[];
notes?: string;
}
// Customer types
export interface ICustomer extends ISync {
id: string;
name: string;
phone: string;
email?: string;
metadata?: Record<string, unknown>;
}

View file

@ -0,0 +1,76 @@
/**
* DragTypes - Event payloads for drag-drop operations
*/
import { SwpEvent } from './SwpEvent';
export interface IMousePosition {
x: number;
y: number;
}
export interface IDragStartPayload {
eventId: string;
element: HTMLElement; // Original element (being dragged)
ghostElement: HTMLElement; // Ghost clone (stays in place)
startY: number; // Original Y position
mouseOffset: IMousePosition; // Click position within element
columnElement: HTMLElement;
}
export interface IDragMovePayload {
eventId: string;
element: HTMLElement;
currentY: number; // Interpolated Y position (smooth)
columnElement: HTMLElement;
}
export interface IDragEndPayload {
swpEvent: SwpEvent; // Wrapper with element, start, end, eventId, columnKey
sourceColumnKey: string; // Source column key (where drag started)
target: 'grid' | 'header'; // Where the event was dropped
}
export interface IDragCancelPayload {
eventId: string;
element: HTMLElement;
startY: number; // Position to animate back to
}
export interface IDragColumnChangePayload {
eventId: string;
element: HTMLElement;
previousColumn: HTMLElement;
newColumn: HTMLElement;
currentY: number;
}
// Header drag payloads
export interface IDragEnterHeaderPayload {
eventId: string;
element: HTMLElement; // Original dragged element
sourceColumnIndex: number;
sourceColumnKey: string; // Opaque column identifier (for matching only)
title: string;
colorClass?: string;
itemType: 'event' | 'reminder';
duration: number; // Antal dage (default 1)
}
export interface IDragMoveHeaderPayload {
eventId: string;
columnIndex: number;
columnKey: string; // Opaque column identifier (for matching only)
}
export interface IDragLeaveHeaderPayload {
eventId: string;
source: 'grid' | 'header'; // Where drag originated
// Header→grid fields (when source === 'header')
element?: HTMLElement; // Header item element
targetColumn?: HTMLElement; // Target column in grid
start?: Date; // Event start from header item
end?: Date; // Event end from header item
title?: string;
colorClass?: string;
}

View file

@ -0,0 +1,15 @@
/**
* ResizeTypes - Event payloads for resize operations
*/
import { SwpEvent } from './SwpEvent';
export interface IResizeStartPayload {
eventId: string;
element: HTMLElement;
startHeight: number;
}
export interface IResizeEndPayload {
swpEvent: SwpEvent; // Wrapper with element, start, end, eventId, resourceId
}

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

@ -0,0 +1,78 @@
/**
* Tenant Settings Type Definitions
*
* Settings are tenant-specific configuration that comes from the backend
* and is stored in IndexedDB for offline access.
*
* Each settings section is stored as a separate record with its own id.
*/
import { ISync } from './CalendarTypes';
/**
* Workweek preset - defines which ISO weekdays to display
* ISO: 1=Monday, 7=Sunday
*/
export interface IWorkweekPreset {
id: string;
workDays: number[];
label: string;
periodDays: number; // Navigation step in days (1 = day, 7 = week)
}
/**
* Workweek settings - stored as separate record
*/
export interface IWorkweekSettings extends ISync {
id: 'workweek';
presets: Record<string, IWorkweekPreset>;
defaultPreset: string;
firstDayOfWeek: number; // ISO: 1=Monday
}
/**
* Grid display settings - stored as separate record
*/
export interface IGridSettings extends ISync {
id: 'grid';
dayStartHour: number;
dayEndHour: number;
workStartHour: number;
workEndHour: number;
hourHeight: number;
snapInterval: number;
}
/**
* Time format settings - stored as separate record
*/
export interface ITimeFormatSettings extends ISync {
id: 'timeFormat';
timezone: string;
locale: string;
use24HourFormat: boolean;
}
/**
* View settings - stored as separate record
*/
export interface IViewSettings extends ISync {
id: 'views';
availableViews: string[];
defaultView: string;
}
/**
* Union type for all tenant settings records
*/
export type TenantSetting = IWorkweekSettings | IGridSettings | ITimeFormatSettings | IViewSettings;
/**
* Settings IDs as const for type safety
*/
export const SettingsIds = {
WORKWEEK: 'workweek',
GRID: 'grid',
TIME_FORMAT: 'timeFormat',
VIEWS: 'views'
} as const;

View file

@ -0,0 +1,79 @@
import { IGridConfig } from '../core/IGridConfig';
/**
* SwpEvent - Wrapper class for calendar event elements
*
* Encapsulates an HTMLElement and provides computed properties
* for start/end times based on element position and grid config.
*
* Usage:
* - eventId is read from element.dataset
* - columnKey identifies the column uniformly
* - Position (top, height) is read from element.style
* - Factory method `fromElement()` calculates Date objects
*/
export class SwpEvent {
readonly element: HTMLElement;
readonly columnKey: string;
private _start: Date;
private _end: Date;
constructor(element: HTMLElement, columnKey: string, start: Date, end: Date) {
this.element = element;
this.columnKey = columnKey;
this._start = start;
this._end = end;
}
/** Event ID from element.dataset.eventId */
get eventId(): string {
return this.element.dataset.eventId || '';
}
get start(): Date {
return this._start;
}
get end(): Date {
return this._end;
}
/** Duration in minutes */
get durationMinutes(): number {
return (this._end.getTime() - this._start.getTime()) / (1000 * 60);
}
/** Duration in milliseconds */
get durationMs(): number {
return this._end.getTime() - this._start.getTime();
}
/**
* Factory: Create SwpEvent from element + columnKey
* Reads top/height from element.style to calculate start/end
* @param columnKey - Opaque column identifier (do NOT parse - use only for matching)
* @param date - Date string (YYYY-MM-DD) for time calculations
*/
static fromElement(
element: HTMLElement,
columnKey: string,
date: string,
gridConfig: IGridConfig
): SwpEvent {
const topPixels = parseFloat(element.style.top) || 0;
const heightPixels = parseFloat(element.style.height) || 0;
// Calculate start from top position
const startMinutesFromGrid = (topPixels / gridConfig.hourHeight) * 60;
const totalMinutes = (gridConfig.dayStartHour * 60) + startMinutesFromGrid;
const start = new Date(date);
start.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
// Calculate end from height
const durationMinutes = (heightPixels / gridConfig.hourHeight) * 60;
const end = new Date(start.getTime() + durationMinutes * 60 * 1000);
return new SwpEvent(element, columnKey, start, end);
}
}

View file

@ -0,0 +1,55 @@
/**
* PositionUtils - Pixel/position calculations for calendar grid
*
* 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
}
/**
* Calculate pixel position for an event based on its times
*/
export function calculateEventPosition(
start: Date,
end: Date,
config: IGridConfig
): EventPosition {
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const dayStartMinutes = config.dayStartHour * 60;
const minuteHeight = config.hourHeight / 60;
const top = (startMinutes - dayStartMinutes) * minuteHeight;
const height = (endMinutes - startMinutes) * minuteHeight;
return { top, height };
}
/**
* Convert minutes to pixels
*/
export function minutesToPixels(minutes: number, config: IGridConfig): number {
return (minutes / 60) * config.hourHeight;
}
/**
* Convert pixels to minutes
*/
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;
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

23
test-package/build.js Normal file
View file

@ -0,0 +1,23 @@
import esbuild from 'esbuild';
import { NovadiUnplugin } from '@novadi/core/unplugin';
import { copyFileSync, mkdirSync } from 'fs';
// Ensure dist/css directory exists
mkdirSync('dist/css', { recursive: true });
// Copy calendar CSS
copyFileSync(
'node_modules/calendar/dist/css/calendar.css',
'dist/css/calendar.css'
);
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
format: 'esm',
platform: 'browser',
plugins: [NovadiUnplugin.esbuild()]
});
console.log('Build complete');

5289
test-package/dist/bundle.js vendored Normal file

File diff suppressed because it is too large Load diff

877
test-package/dist/css/calendar.css vendored Normal file
View file

@ -0,0 +1,877 @@
/* V2 Base - Shared variables */
:root {
/* Grid measurements */
--hour-height: 64px;
--time-axis-width: 60px;
--grid-columns: 7;
--day-column-min-width: 200px;
--day-start-hour: 6;
--day-end-hour: 18;
--header-height: 70px;
/* Colors - UI */
--color-border: #e0e0e0;
--color-surface: #fff;
--color-background: #f5f5f5;
--color-background-hover: #f0f0f0;
--color-background-alt: #fafafa;
--color-text: #333333;
--color-text-secondary: #666;
--color-primary: #1976d2;
--color-team-bg: #e3f2fd;
--color-team-text: #1565c0;
/* 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;
--b-color-pink: #d81b60;
--b-color-magenta: #c200c2;
--b-color-purple: #8e24aa;
--b-color-violet: #5e35b1;
--b-color-deep-purple: #4527a0;
--b-color-indigo: #3949ab;
--b-color-blue: #1e88e5;
--b-color-light-blue: #03a9f4;
--b-color-cyan: #3bc9db;
--b-color-teal: #00897b;
--b-color-green: #43a047;
--b-color-light-green: #8bc34a;
--b-color-lime: #c0ca33;
--b-color-yellow: #fdd835;
--b-color-amber: #ffb300;
--b-color-orange: #fb8c00;
--b-color-deep-orange: #f4511e;
/* Base mix for color-mix() function */
--b-mix: #fff;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 150ms ease;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-background);
}
/* V2 Layout - Calendar structure, grid, navigation */
.calendar-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
}
swp-calendar {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
background: var(--color-surface);
}
/* Nav */
swp-calendar-nav {
display: flex;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid var(--color-border);
align-items: center;
font-size: 13px;
}
/* View switcher - small chips */
swp-view-switcher {
display: flex;
gap: 4px;
background: var(--color-background-alt);
padding: 3px;
border-radius: 6px;
}
.view-chip {
padding: 4px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
transition: all 0.15s ease;
&:hover {
background: var(--color-surface);
color: var(--color-text);
}
&.active {
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
}
/* Workweek dropdown */
.workweek-dropdown {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
font-size: 12px;
cursor: pointer;
&:hover { border-color: var(--color-text-secondary); }
&:focus { outline: 2px solid var(--color-primary); outline-offset: 1px; }
}
/* Resource selector (picker view) */
swp-resource-selector {
&.hidden { display: none; }
fieldset {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 6px 12px;
margin: 0;
}
legend {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
padding: 0 6px;
}
.resource-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&:hover { color: var(--color-primary); }
}
input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
}
}
/* Navigation group */
swp-nav-group {
display: flex;
gap: 2px;
}
swp-nav-button {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
background: var(--color-surface);
font-size: 12px;
&:hover { background: var(--color-background-hover); }
&.btn-small {
padding: 4px 8px;
font-size: 11px;
}
}
swp-week-info {
margin-left: auto;
text-align: right;
swp-week-number {
font-weight: 600;
font-size: 12px;
display: block;
}
swp-date-range {
font-size: 11px;
color: var(--color-text-secondary);
}
}
/* Container */
swp-calendar-container {
display: grid;
grid-template-columns: var(--time-axis-width) 1fr;
grid-template-rows: auto 1fr;
overflow: hidden;
height: 100%;
}
/* Time axis */
swp-time-axis {
grid-column: 1;
grid-row: 1 / 3;
display: grid;
grid-template-rows: auto 1fr;
border-right: 1px solid var(--color-border);
background: var(--color-surface);
overflow: hidden;
user-select: none;
}
swp-header-spacer {
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
z-index: 1;
}
swp-header-drawer {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
grid-row: 2;
overflow: hidden;
background: var(--color-background-alt);
border-bottom: 1px solid var(--color-border);
}
swp-time-axis-content {
display: flex;
flex-direction: column;
position: relative;
}
swp-hour-marker {
height: var(--hour-height);
padding: 4px 8px;
font-size: 11px;
color: var(--color-text-secondary);
text-align: right;
position: relative;
&::after {
content: '';
position: absolute;
top: -1px;
right: 0;
width: 5px;
height: 1px;
background: var(--color-hour-line);
}
&:first-child::after {
display: none;
}
}
/* Grid container */
swp-grid-container {
grid-column: 2;
grid-row: 1 / 3;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: subgrid;
overflow: hidden;
}
/* Viewport/Track for slide animation */
swp-header-viewport {
display: grid;
grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr));
grid-template-rows: auto auto;
min-width: calc(var(--grid-columns) * var(--day-column-min-width));
overflow-y: scroll;
overflow-x: hidden;
&::-webkit-scrollbar { background: transparent; }
&::-webkit-scrollbar-thumb { background: transparent; }
}
swp-content-viewport {
overflow: hidden;
min-height: 0;
width: 100%;
}
swp-header-track {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
grid-row: 1;
}
swp-content-track {
display: flex;
height: 100%;
> swp-scrollable-content {
flex: 0 0 100%;
height: 100%;
}
}
/* Header */
swp-calendar-header {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
grid-auto-rows: auto;
background: var(--color-surface);
&[data-levels="date"] > swp-day-header { grid-row: 1; }
&[data-levels="resource date"] {
> swp-resource-header { grid-row: 1; }
> swp-day-header { grid-row: 2; }
}
&[data-levels="team resource date"] {
> swp-team-header { grid-row: 1; }
> swp-resource-header { grid-row: 2; }
> swp-day-header { grid-row: 3; }
}
&[data-levels="department resource date"] {
> swp-department-header { grid-row: 1; }
> swp-resource-header { grid-row: 2; }
> swp-day-header { grid-row: 3; }
}
}
swp-day-header,
swp-resource-header,
swp-team-header,
swp-department-header {
padding: 8px;
text-align: center;
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
user-select: none;
}
swp-team-header {
background: var(--color-team-bg);
color: var(--color-team-text);
font-weight: 500;
grid-column: span var(--team-cols, 1);
}
swp-department-header {
background: var(--color-team-bg);
color: var(--color-team-text);
font-weight: 500;
grid-column: span var(--department-cols, 1);
}
swp-resource-header {
background: var(--color-background-alt);
font-size: 13px;
}
swp-day-header {
swp-day-name {
display: block;
font-size: 11px;
color: var(--color-text-secondary);
text-transform: uppercase;
}
swp-day-date {
display: block;
font-size: 24px;
font-weight: 300;
}
&[data-hidden="true"] {
display: none;
}
}
/* Scrollable content */
swp-scrollable-content {
display: block;
overflow: auto;
}
swp-time-grid {
display: block;
position: relative;
min-height: calc((var(--day-end-hour) - var(--day-start-hour)) * var(--hour-height));
min-width: calc(var(--grid-columns) * var(--day-column-min-width));
/* Timelinjer */
&::after {
content: '';
position: absolute;
inset: 0;
z-index: 2;
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent calc(var(--hour-height) - 1px),
var(--color-hour-line) calc(var(--hour-height) - 1px),
var(--color-hour-line) var(--hour-height)
);
pointer-events: none;
}
}
/* Kvarterlinjer - 3 linjer per time (15, 30, 45 min) */
swp-grid-lines {
display: block;
position: absolute;
inset: 0;
z-index: 1;
background-image: repeating-linear-gradient(
to bottom,
transparent 0,
transparent calc(var(--hour-height) / 4 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) / 4 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) / 4),
transparent calc(var(--hour-height) / 4),
transparent calc(var(--hour-height) / 2 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) / 2 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) / 2),
transparent calc(var(--hour-height) / 2),
transparent calc(var(--hour-height) * 3 / 4 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) * 3 / 4 - 1px),
var(--color-grid-line-light) calc(var(--hour-height) * 3 / 4),
transparent calc(var(--hour-height) * 3 / 4),
transparent var(--hour-height)
);
}
swp-day-columns {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr));
min-width: calc(var(--grid-columns) * var(--day-column-min-width));
}
swp-day-column {
position: relative;
border-right: 1px solid var(--color-border);
}
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;
}
/* V2 Events - Event styling (from V1 calendar-events-css.css) */
/* Event base styles */
swp-day-columns swp-event {
--b-text: var(--color-text);
position: absolute;
border-radius: 3px;
overflow: hidden;
cursor: pointer;
transition: background-color 200ms ease, box-shadow 150ms ease, transform 150ms ease;
z-index: 10;
left: 2px;
right: 2px;
font-size: 12px;
padding: 4px 6px;
user-select: none;
/* Color system using color-mix() */
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
color: var(--b-text);
border-left: 4px solid var(--b-primary);
/* Enable container queries for responsive layout */
container-type: size;
container-name: event;
/* CSS Grid layout for time, title, and description */
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
gap: 2px 4px;
align-items: start;
/* Dragging state */
&.dragging {
position: absolute;
z-index: 999999;
left: 2px;
right: 2px;
width: auto;
cursor: grabbing;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Ghost clone (stays in original position during drag) */
&.drag-ghost {
opacity: 0.3;
pointer-events: none;
}
/* Hover state */
&:hover {
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
}
}
swp-day-columns swp-event:hover {
z-index: 20;
}
/* Resize handle - actual draggable element */
swp-resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 15px;
cursor: ns-resize;
z-index: 25;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 150ms ease;
}
/* Show handle on hover */
swp-day-columns swp-event:hover swp-resize-handle {
opacity: 1;
}
/* Handle visual indicator (grip lines) */
swp-resize-handle::before {
content: '';
width: 30px;
height: 4px;
background: rgba(255, 255, 255, 0.9);
border-radius: 2px;
box-shadow:
0 -2px 0 rgba(255, 255, 255, 0.9),
0 2px 0 rgba(255, 255, 255, 0.9),
0 0 4px rgba(0, 0, 0, 0.2);
}
/* Global resizing state */
.swp--resizing {
user-select: none !important;
cursor: ns-resize !important;
}
.swp--resizing * {
cursor: ns-resize !important;
}
swp-day-columns swp-event-time {
grid-column: 1;
grid-row: 1;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
}
swp-day-columns swp-event-title {
grid-column: 2;
grid-row: 1;
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
swp-day-columns swp-event-description {
grid-column: 1 / -1;
grid-row: 2;
display: block;
font-size: 0.875rem;
opacity: 0.8;
line-height: 1.3;
overflow: hidden;
word-wrap: break-word;
/* Ensure description fills available height for gradient effect */
min-height: 100%;
align-self: stretch;
/* Fade-out effect for long descriptions */
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
/* Container queries for height-based layout */
/* Hide description when event is too short (< 30px) */
@container event (height < 30px) {
swp-day-columns swp-event-description {
display: none;
}
}
/* Full description for tall events (>= 100px) */
@container event (height >= 100px) {
swp-day-columns swp-event-description {
max-height: none;
}
}
/* Multi-day events */
swp-multi-day-event {
position: relative;
height: 28px;
margin: 2px 4px;
padding: 0 8px;
border-radius: 4px;
display: flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
/* Color system using color-mix() */
--b-text: var(--color-text);
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
color: var(--b-text);
border-left: 4px solid var(--b-primary);
&:hover {
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
}
/* Continuation indicators */
&[data-continues-before="true"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 0;
padding-left: 20px;
&::before {
content: '\25C0';
position: absolute;
left: 4px;
opacity: 0.6;
font-size: 0.75rem;
}
}
&[data-continues-after="true"] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin-right: 0;
padding-right: 20px;
&::after {
content: '\25B6';
position: absolute;
right: 4px;
opacity: 0.6;
font-size: 0.75rem;
}
}
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
}
/* All-day events */
swp-allday-event {
--b-text: var(--color-text);
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
color: var(--b-text);
border-left: 4px solid var(--b-primary);
cursor: pointer;
transition: background-color 200ms ease;
user-select: none;
&:hover {
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
}
}
/* Event creation preview */
swp-event-preview {
position: absolute;
left: 8px;
right: 8px;
background: rgba(33, 150, 243, 0.1);
border: 2px dashed var(--color-primary);
border-radius: 4px;
/* Position via CSS variables */
top: calc(var(--preview-start) * var(--minute-height));
height: calc(var(--preview-duration) * var(--minute-height));
}
/* Event filtering styles */
/* When filter is active, all events are dimmed by default */
swp-events-layer[data-filter-active="true"] swp-event {
opacity: 0.2;
transition: opacity 200ms ease;
}
/* Events that match the filter stay normal */
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
opacity: 1;
}
/* Event overlap styling */
/* Event group container for column sharing */
swp-event-group {
position: absolute;
display: grid;
gap: 2px;
left: 2px;
right: 2px;
z-index: 10;
}
/* Grid column configurations */
swp-event-group.cols-2 {
grid-template-columns: 1fr 1fr;
}
swp-event-group.cols-3 {
grid-template-columns: 1fr 1fr 1fr;
}
swp-event-group.cols-4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
/* Stack levels using margin-left */
swp-event-group.stack-level-0 {
margin-left: 0px;
}
swp-event-group.stack-level-1 {
margin-left: 15px;
}
swp-event-group.stack-level-2 {
margin-left: 30px;
}
swp-event-group.stack-level-3 {
margin-left: 45px;
}
swp-event-group.stack-level-4 {
margin-left: 60px;
}
/* Shadow for stacked events (level 1+) */
swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0']),
swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event {
box-shadow:
0 -1px 2px rgba(0, 0, 0, 0.1),
0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Child events within grid */
swp-event-group swp-event {
position: relative;
left: 0;
right: 0;
}
/* All-day event transition for smooth repositioning */
swp-allday-container swp-event.transitioning {
transition: grid-area 200ms ease-out, grid-row 200ms ease-out, grid-column 200ms ease-out;
}
/* Color utility classes */
.is-red { --b-primary: var(--b-color-red); }
.is-pink { --b-primary: var(--b-color-pink); }
.is-magenta { --b-primary: var(--b-color-magenta); }
.is-purple { --b-primary: var(--b-color-purple); }
.is-violet { --b-primary: var(--b-color-violet); }
.is-deep-purple { --b-primary: var(--b-color-deep-purple); }
.is-indigo { --b-primary: var(--b-color-indigo); }
.is-blue { --b-primary: var(--b-color-blue); }
.is-light-blue { --b-primary: var(--b-color-light-blue); }
.is-cyan { --b-primary: var(--b-color-cyan); }
.is-teal { --b-primary: var(--b-color-teal); }
.is-green { --b-primary: var(--b-color-green); }
.is-light-green { --b-primary: var(--b-color-light-green); }
.is-lime { --b-primary: var(--b-color-lime); }
.is-yellow { --b-primary: var(--b-color-yellow); }
.is-amber { --b-primary: var(--b-color-amber); }
.is-orange { --b-primary: var(--b-color-orange); }
.is-deep-orange { --b-primary: var(--b-color-deep-orange); }
/* Header drawer items */
swp-header-item {
--b-text: var(--color-text);
/* Positioneres via style.gridArea */
height: 22px;
margin: 1px 4px;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
transition: background-color 200ms ease;
/* Color system - inverted from swp-event */
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
color: var(--b-text);
&:hover {
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
}
/* Dragging state */
&.dragging {
opacity: 0.7;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}

41
test-package/index.html Normal file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Package Test</title>
<link rel="stylesheet" href="dist/css/calendar.css">
</head>
<body>
<h1>Calendar Package Test</h1>
<div class="calendar-wrapper">
<swp-calendar-container>
<swp-time-axis>
<swp-header-spacer></swp-header-spacer>
<swp-time-axis-content id="time-axis"></swp-time-axis-content>
</swp-time-axis>
<swp-grid-container>
<swp-header-viewport>
<swp-header-track>
<swp-calendar-header></swp-calendar-header>
</swp-header-track>
<swp-header-drawer></swp-header-drawer>
</swp-header-viewport>
<swp-content-viewport>
<swp-content-track>
<swp-scrollable-content>
<swp-time-grid>
<swp-grid-lines></swp-grid-lines>
<swp-day-columns></swp-day-columns>
</swp-time-grid>
</swp-scrollable-content>
</swp-content-track>
</swp-content-viewport>
</swp-grid-container>
</swp-calendar-container>
</div>
<script type="module" src="dist/bundle.js"></script>
</body>
</html>

654
test-package/package-lock.json generated Normal file
View file

@ -0,0 +1,654 @@
{
"name": "test-package",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "test-package",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@novadi/core": "^0.6.0",
"calendar": "^0.1.6",
"dayjs": "^1.11.19"
},
"devDependencies": {
"esbuild": "^0.27.2",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@novadi/core": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@novadi/core/-/core-0.6.0.tgz",
"integrity": "sha512-CU1134Nd7ULMg9OQbID5oP+FLtrMkNiLJ17+dmy4jjmPDcPK/dVzKTFxvJmbBvEfZEc9WtmkmJjqw11ABU7Jxw==",
"license": "MIT",
"dependencies": {
"unplugin": "^2.3.10"
},
"optionalDependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.52.5"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.56.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/calendar": {
"version": "0.1.6",
"resolved": "http://npm.jarjarbinks:4873/calendar/-/calendar-0.1.6.tgz",
"integrity": "sha512-dZKOg6gHTAexklxsBGnszTWDi0rkV68XXV9epaHxZP6RlMvys155dpitq6q3aWCGbSw8xKeTF7FTHaz5yJoT6A==",
"dependencies": {
"dayjs": "^1.11.0"
},
"peerDependencies": {
"@novadi/core": "^0.6.0"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.2",
"@esbuild/android-arm": "0.27.2",
"@esbuild/android-arm64": "0.27.2",
"@esbuild/android-x64": "0.27.2",
"@esbuild/darwin-arm64": "0.27.2",
"@esbuild/darwin-x64": "0.27.2",
"@esbuild/freebsd-arm64": "0.27.2",
"@esbuild/freebsd-x64": "0.27.2",
"@esbuild/linux-arm": "0.27.2",
"@esbuild/linux-arm64": "0.27.2",
"@esbuild/linux-ia32": "0.27.2",
"@esbuild/linux-loong64": "0.27.2",
"@esbuild/linux-mips64el": "0.27.2",
"@esbuild/linux-ppc64": "0.27.2",
"@esbuild/linux-riscv64": "0.27.2",
"@esbuild/linux-s390x": "0.27.2",
"@esbuild/linux-x64": "0.27.2",
"@esbuild/netbsd-arm64": "0.27.2",
"@esbuild/netbsd-x64": "0.27.2",
"@esbuild/openbsd-arm64": "0.27.2",
"@esbuild/openbsd-x64": "0.27.2",
"@esbuild/openharmony-arm64": "0.27.2",
"@esbuild/sunos-x64": "0.27.2",
"@esbuild/win32-arm64": "0.27.2",
"@esbuild/win32-ia32": "0.27.2",
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
}
}
}

21
test-package/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "test-package",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@novadi/core": "^0.6.0",
"calendar": "^0.1.6",
"dayjs": "^1.11.19"
},
"devDependencies": {
"esbuild": "^0.27.2",
"typescript": "^5.9.3"
}
}

139
test-package/src/index.ts Normal file
View file

@ -0,0 +1,139 @@
import { Container } from '@novadi/core';
import {
registerCoreServices,
CalendarApp,
IndexedDBContext,
SettingsService,
ViewConfigService,
EventService,
EventBus,
CalendarEvents,
EventPersistenceManager
} from 'calendar';
import { registerSchedules } from 'calendar/schedules';
async function init() {
// Check if database already exists
const databases = await indexedDB.databases();
const dbExists = databases.some(db => db.name === 'CalendarTestDB');
const container = new Container();
const builder = container.builder();
registerCoreServices(builder, {
dbConfig: { dbName: 'CalendarTestDB', dbVersion: 4 }
});
registerSchedules(builder);
const app = builder.build();
console.log('Container created');
// Initialize IndexedDB
const dbContext = app.resolveType<IndexedDBContext>();
await dbContext.initialize();
console.log('IndexedDB initialized');
const settingsService = app.resolveType<SettingsService>();
const viewConfigService = app.resolveType<ViewConfigService>();
const eventService = app.resolveType<EventService>();
if (dbExists) {
console.log('Database exists, skipping seed');
} else {
console.log('Seeding data...');
await seedData(settingsService, viewConfigService, eventService);
}
// Initialize and render
const calendarApp = app.resolveType<CalendarApp>();
const containerEl = document.querySelector('swp-calendar-container') as HTMLElement;
await calendarApp.init(containerEl);
const eventBus = app.resolveType<EventBus>();
eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: 'simple' });
console.log('Calendar rendered');
// Debug listeners
document.addEventListener('event:drag-end', (e) => {
console.log('event:drag-end:', (e as CustomEvent).detail);
});
document.addEventListener('event:updated', (e) => {
console.log('event:updated:', (e as CustomEvent).detail);
});
const persistenceManager = app.resolveType<EventPersistenceManager>();
console.log('EventPersistenceManager resolved:', persistenceManager);
}
async function seedData(
settingsService: SettingsService,
viewConfigService: ViewConfigService,
eventService: EventService
) {
// Grid settings
await settingsService.save({
id: 'grid',
dayStartHour: 8,
dayEndHour: 17,
workStartHour: 9,
workEndHour: 16,
hourHeight: 64,
snapInterval: 15,
syncStatus: 'synced'
});
// Workweek settings
await settingsService.save({
id: 'workweek',
presets: {
standard: { id: 'standard', label: 'Standard', workDays: [1, 2, 3, 4, 5], periodDays: 7 }
},
defaultPreset: 'standard',
firstDayOfWeek: 1,
syncStatus: 'synced'
});
// Simple view config
await viewConfigService.save({
id: 'simple',
groupings: [{ type: 'date', values: [], idProperty: 'date', derivedFrom: 'start' }],
syncStatus: 'synced'
});
// Add test events
const today = new Date();
today.setHours(0, 0, 0, 0);
console.log('Event date:', today.toISOString());
const start1 = new Date(today);
start1.setHours(9, 0, 0, 0);
const end1 = new Date(today);
end1.setHours(10, 0, 0, 0);
await eventService.save({
id: '1',
title: 'Morgenmøde',
start: start1,
end: end1,
type: 'meeting',
allDay: false,
syncStatus: 'synced'
});
const start2 = new Date(today);
start2.setHours(12, 0, 0, 0);
const end2 = new Date(today);
end2.setHours(13, 0, 0, 0);
await eventService.save({
id: '2',
title: 'Frokost',
start: start2,
end: end2,
type: 'break',
allDay: false,
syncStatus: 'synced'
});
}
init().catch(console.error);

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"]
}