From ceb44446f02d3388299329bfcec90167f77fca52 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Wed, 28 Jan 2026 15:24:03 +0100 Subject: [PATCH] Sets up calendar package with core infrastructure Adds core calendar package components including: - Base services for events, resources, and settings - Calendar app and orchestrator - Build and bundling configuration - IndexedDB storage setup Prepares foundational architecture for calendar functionality --- .claude/settings.local.json | 9 +- .gitignore | 2 + packages/calendar/build.js | 47 + packages/calendar/package-lock.json | 167 + packages/calendar/package.json | 57 + packages/calendar/src/CompositionRoot.ts | 163 + packages/calendar/src/constants/CoreEvents.ts | 71 + .../calendar/src/core/BaseGroupingRenderer.ts | 91 + packages/calendar/src/core/CalendarApp.ts | 201 + packages/calendar/src/core/CalendarEvents.ts | 28 + .../calendar/src/core/CalendarOrchestrator.ts | 124 + packages/calendar/src/core/DateService.ts | 195 + packages/calendar/src/core/EntityResolver.ts | 48 + packages/calendar/src/core/EventBus.ts | 174 + packages/calendar/src/core/FilterTemplate.ts | 149 + .../calendar/src/core/HeaderDrawerManager.ts | 70 + packages/calendar/src/core/IEntityResolver.ts | 15 + packages/calendar/src/core/IGridConfig.ts | 7 + .../calendar/src/core/IGroupingRenderer.ts | 15 + packages/calendar/src/core/IGroupingStore.ts | 4 + .../calendar/src/core/ITimeFormatConfig.ts | 7 + .../calendar/src/core/NavigationAnimator.ts | 64 + packages/calendar/src/core/RenderBuilder.ts | 15 + packages/calendar/src/core/ScrollManager.ts | 42 + packages/calendar/src/core/ViewConfig.ts | 21 + .../src/extensions/audit/AuditService.ts | 167 + .../src/extensions/audit/AuditStore.ts | 27 + .../calendar/src/extensions/audit/index.ts | 14 + .../src/extensions/bookings/BookingService.ts | 75 + .../src/extensions/bookings/BookingStore.ts | 18 + .../calendar/src/extensions/bookings/index.ts | 18 + .../extensions/customers/CustomerService.ts | 46 + .../src/extensions/customers/CustomerStore.ts | 17 + .../src/extensions/customers/index.ts | 18 + .../departments/DepartmentRenderer.ts | 25 + .../departments/DepartmentService.ts | 25 + .../extensions/departments/DepartmentStore.ts | 13 + .../src/extensions/departments/index.ts | 22 + .../schedules/ResourceScheduleService.ts | 84 + .../schedules/ScheduleOverrideService.ts | 100 + .../schedules/ScheduleOverrideStore.ts | 21 + .../src/extensions/schedules/index.ts | 17 + .../src/extensions/teams/TeamRenderer.ts | 25 + .../src/extensions/teams/TeamService.ts | 44 + .../src/extensions/teams/TeamStore.ts | 13 + .../calendar/src/extensions/teams/index.ts | 22 + .../src/features/date/DateRenderer.ts | 68 + packages/calendar/src/features/date/index.ts | 1 + .../src/features/event/EventLayoutEngine.ts | 279 + .../src/features/event/EventLayoutTypes.ts | 35 + .../src/features/event/EventRenderer.ts | 434 ++ packages/calendar/src/features/event/index.ts | 1 + .../headerdrawer/HeaderDrawerLayoutEngine.ts | 135 + .../headerdrawer/HeaderDrawerRenderer.ts | 419 ++ .../src/features/headerdrawer/index.ts | 2 + .../src/features/resource/ResourceRenderer.ts | 69 + .../calendar/src/features/resource/index.ts | 1 + .../src/features/schedule/ScheduleRenderer.ts | 106 + .../calendar/src/features/schedule/index.ts | 1 + .../src/features/timeaxis/TimeAxisRenderer.ts | 10 + packages/calendar/src/index.ts | 164 + .../calendar/src/managers/DragDropManager.ts | 581 ++ .../src/managers/EdgeScrollManager.ts | 140 + .../src/managers/EventPersistenceManager.ts | 102 + .../calendar/src/managers/ResizeManager.ts | 290 + .../src/repositories/IApiRepository.ts | 33 + .../calendar/src/storage/BaseEntityService.ts | 181 + .../calendar/src/storage/IEntityService.ts | 40 + packages/calendar/src/storage/IStore.ts | 18 + .../calendar/src/storage/IndexedDBContext.ts | 108 + packages/calendar/src/storage/SyncPlugin.ts | 64 + .../src/storage/events/EventSerialization.ts | 32 + .../src/storage/events/EventService.ts | 84 + .../calendar/src/storage/events/EventStore.ts | 37 + .../src/storage/resources/ResourceService.ts | 55 + .../src/storage/resources/ResourceStore.ts | 17 + .../src/storage/settings/SettingsService.ts | 83 + .../src/storage/settings/SettingsStore.ts | 16 + .../storage/viewconfigs/ViewConfigService.ts | 18 + .../storage/viewconfigs/ViewConfigStore.ts | 10 + packages/calendar/src/types/AuditTypes.ts | 46 + packages/calendar/src/types/CalendarTypes.ts | 170 + packages/calendar/src/types/DragTypes.ts | 76 + packages/calendar/src/types/ResizeTypes.ts | 15 + packages/calendar/src/types/ScheduleTypes.ts | 27 + packages/calendar/src/types/SettingsTypes.ts | 78 + packages/calendar/src/types/SwpEvent.ts | 79 + packages/calendar/src/utils/PositionUtils.ts | 55 + packages/calendar/tsconfig.json | 21 + test-package/build.js | 23 + test-package/dist/bundle.js | 5289 +++++++++++++++++ test-package/dist/css/calendar.css | 877 +++ test-package/index.html | 41 + test-package/package-lock.json | 654 ++ test-package/package.json | 21 + test-package/src/index.ts | 139 + test-package/tsconfig.json | 17 + 97 files changed, 13858 insertions(+), 1 deletion(-) create mode 100644 packages/calendar/build.js create mode 100644 packages/calendar/package-lock.json create mode 100644 packages/calendar/package.json create mode 100644 packages/calendar/src/CompositionRoot.ts create mode 100644 packages/calendar/src/constants/CoreEvents.ts create mode 100644 packages/calendar/src/core/BaseGroupingRenderer.ts create mode 100644 packages/calendar/src/core/CalendarApp.ts create mode 100644 packages/calendar/src/core/CalendarEvents.ts create mode 100644 packages/calendar/src/core/CalendarOrchestrator.ts create mode 100644 packages/calendar/src/core/DateService.ts create mode 100644 packages/calendar/src/core/EntityResolver.ts create mode 100644 packages/calendar/src/core/EventBus.ts create mode 100644 packages/calendar/src/core/FilterTemplate.ts create mode 100644 packages/calendar/src/core/HeaderDrawerManager.ts create mode 100644 packages/calendar/src/core/IEntityResolver.ts create mode 100644 packages/calendar/src/core/IGridConfig.ts create mode 100644 packages/calendar/src/core/IGroupingRenderer.ts create mode 100644 packages/calendar/src/core/IGroupingStore.ts create mode 100644 packages/calendar/src/core/ITimeFormatConfig.ts create mode 100644 packages/calendar/src/core/NavigationAnimator.ts create mode 100644 packages/calendar/src/core/RenderBuilder.ts create mode 100644 packages/calendar/src/core/ScrollManager.ts create mode 100644 packages/calendar/src/core/ViewConfig.ts create mode 100644 packages/calendar/src/extensions/audit/AuditService.ts create mode 100644 packages/calendar/src/extensions/audit/AuditStore.ts create mode 100644 packages/calendar/src/extensions/audit/index.ts create mode 100644 packages/calendar/src/extensions/bookings/BookingService.ts create mode 100644 packages/calendar/src/extensions/bookings/BookingStore.ts create mode 100644 packages/calendar/src/extensions/bookings/index.ts create mode 100644 packages/calendar/src/extensions/customers/CustomerService.ts create mode 100644 packages/calendar/src/extensions/customers/CustomerStore.ts create mode 100644 packages/calendar/src/extensions/customers/index.ts create mode 100644 packages/calendar/src/extensions/departments/DepartmentRenderer.ts create mode 100644 packages/calendar/src/extensions/departments/DepartmentService.ts create mode 100644 packages/calendar/src/extensions/departments/DepartmentStore.ts create mode 100644 packages/calendar/src/extensions/departments/index.ts create mode 100644 packages/calendar/src/extensions/schedules/ResourceScheduleService.ts create mode 100644 packages/calendar/src/extensions/schedules/ScheduleOverrideService.ts create mode 100644 packages/calendar/src/extensions/schedules/ScheduleOverrideStore.ts create mode 100644 packages/calendar/src/extensions/schedules/index.ts create mode 100644 packages/calendar/src/extensions/teams/TeamRenderer.ts create mode 100644 packages/calendar/src/extensions/teams/TeamService.ts create mode 100644 packages/calendar/src/extensions/teams/TeamStore.ts create mode 100644 packages/calendar/src/extensions/teams/index.ts create mode 100644 packages/calendar/src/features/date/DateRenderer.ts create mode 100644 packages/calendar/src/features/date/index.ts create mode 100644 packages/calendar/src/features/event/EventLayoutEngine.ts create mode 100644 packages/calendar/src/features/event/EventLayoutTypes.ts create mode 100644 packages/calendar/src/features/event/EventRenderer.ts create mode 100644 packages/calendar/src/features/event/index.ts create mode 100644 packages/calendar/src/features/headerdrawer/HeaderDrawerLayoutEngine.ts create mode 100644 packages/calendar/src/features/headerdrawer/HeaderDrawerRenderer.ts create mode 100644 packages/calendar/src/features/headerdrawer/index.ts create mode 100644 packages/calendar/src/features/resource/ResourceRenderer.ts create mode 100644 packages/calendar/src/features/resource/index.ts create mode 100644 packages/calendar/src/features/schedule/ScheduleRenderer.ts create mode 100644 packages/calendar/src/features/schedule/index.ts create mode 100644 packages/calendar/src/features/timeaxis/TimeAxisRenderer.ts create mode 100644 packages/calendar/src/index.ts create mode 100644 packages/calendar/src/managers/DragDropManager.ts create mode 100644 packages/calendar/src/managers/EdgeScrollManager.ts create mode 100644 packages/calendar/src/managers/EventPersistenceManager.ts create mode 100644 packages/calendar/src/managers/ResizeManager.ts create mode 100644 packages/calendar/src/repositories/IApiRepository.ts create mode 100644 packages/calendar/src/storage/BaseEntityService.ts create mode 100644 packages/calendar/src/storage/IEntityService.ts create mode 100644 packages/calendar/src/storage/IStore.ts create mode 100644 packages/calendar/src/storage/IndexedDBContext.ts create mode 100644 packages/calendar/src/storage/SyncPlugin.ts create mode 100644 packages/calendar/src/storage/events/EventSerialization.ts create mode 100644 packages/calendar/src/storage/events/EventService.ts create mode 100644 packages/calendar/src/storage/events/EventStore.ts create mode 100644 packages/calendar/src/storage/resources/ResourceService.ts create mode 100644 packages/calendar/src/storage/resources/ResourceStore.ts create mode 100644 packages/calendar/src/storage/settings/SettingsService.ts create mode 100644 packages/calendar/src/storage/settings/SettingsStore.ts create mode 100644 packages/calendar/src/storage/viewconfigs/ViewConfigService.ts create mode 100644 packages/calendar/src/storage/viewconfigs/ViewConfigStore.ts create mode 100644 packages/calendar/src/types/AuditTypes.ts create mode 100644 packages/calendar/src/types/CalendarTypes.ts create mode 100644 packages/calendar/src/types/DragTypes.ts create mode 100644 packages/calendar/src/types/ResizeTypes.ts create mode 100644 packages/calendar/src/types/ScheduleTypes.ts create mode 100644 packages/calendar/src/types/SettingsTypes.ts create mode 100644 packages/calendar/src/types/SwpEvent.ts create mode 100644 packages/calendar/src/utils/PositionUtils.ts create mode 100644 packages/calendar/tsconfig.json create mode 100644 test-package/build.js create mode 100644 test-package/dist/bundle.js create mode 100644 test-package/dist/css/calendar.css create mode 100644 test-package/index.html create mode 100644 test-package/package-lock.json create mode 100644 test-package/package.json create mode 100644 test-package/src/index.ts create mode 100644 test-package/tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ada38c0..84b43fa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/.gitignore b/.gitignore index a0905c1..de20219 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ Thumbs.db *.userosscache *.sln.docstates js/ + +packages/calendar/dist/ diff --git a/packages/calendar/build.js b/packages/calendar/build.js new file mode 100644 index 0000000..0570894 --- /dev/null +++ b/packages/calendar/build.js @@ -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(); diff --git a/packages/calendar/package-lock.json b/packages/calendar/package-lock.json new file mode 100644 index 0000000..c00dabb --- /dev/null +++ b/packages/calendar/package-lock.json @@ -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 + } + } +} diff --git a/packages/calendar/package.json b/packages/calendar/package.json new file mode 100644 index 0000000..8c83e0f --- /dev/null +++ b/packages/calendar/package.json @@ -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" + } +} diff --git a/packages/calendar/src/CompositionRoot.ts b/packages/calendar/src/CompositionRoot.ts new file mode 100644 index 0000000..e2b429a --- /dev/null +++ b/packages/calendar/src/CompositionRoot.ts @@ -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(); + builder.registerInstance(gridConfig).as(); + builder.registerInstance(dbConfig).as(); + + // Core - EventBus (singleton pattern via dual registration) + builder.registerType(EventBus).as(); + builder.registerType(EventBus).as(); + + // Core Services + builder.registerType(DateService).as(); + + // Storage infrastructure + builder.registerType(IndexedDBContext).as(); + + // Core Stores (for IndexedDB schema creation via IStore[] array injection) + builder.registerType(EventStore).as(); + builder.registerType(ResourceStore).as(); + builder.registerType(SettingsStore).as(); + builder.registerType(ViewConfigStore).as(); + + // Core Entity Services (polymorphic via IEntityService) + builder.registerType(EventService).as>(); + builder.registerType(EventService).as>(); + builder.registerType(EventService).as(); + + builder.registerType(ResourceService).as>(); + builder.registerType(ResourceService).as>(); + builder.registerType(ResourceService).as(); + + builder.registerType(SettingsService).as>(); + builder.registerType(SettingsService).as>(); + builder.registerType(SettingsService).as(); + + builder.registerType(ViewConfigService).as>(); + builder.registerType(ViewConfigService).as>(); + builder.registerType(ViewConfigService).as(); + + // Core Renderers + builder.registerType(EventRenderer).as(); + builder.registerType(ScheduleRenderer).as(); + builder.registerType(HeaderDrawerRenderer).as(); + builder.registerType(TimeAxisRenderer).as(); + + // Grouping Renderers (registered as IRenderer[] for CalendarOrchestrator) + builder.registerType(DateRenderer).as(); + builder.registerType(ResourceRenderer).as(); + + // Core Managers + builder.registerType(ScrollManager).as(); + builder.registerType(HeaderDrawerManager).as(); + builder.registerType(DragDropManager).as(); + builder.registerType(EdgeScrollManager).as(); + builder.registerType(ResizeManager).as(); + builder.registerType(EventPersistenceManager).as(); + + // Orchestrator and App + builder.registerType(CalendarOrchestrator).as(); + builder.registerType(CalendarApp).as(); +} diff --git a/packages/calendar/src/constants/CoreEvents.ts b/packages/calendar/src/constants/CoreEvents.ts new file mode 100644 index 0000000..7363138 --- /dev/null +++ b/packages/calendar/src/constants/CoreEvents.ts @@ -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; diff --git a/packages/calendar/src/core/BaseGroupingRenderer.ts b/packages/calendar/src/core/BaseGroupingRenderer.ts new file mode 100644 index 0000000..60c9abf --- /dev/null +++ b/packages/calendar/src/core/BaseGroupingRenderer.ts @@ -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 implements IRenderer { + abstract readonly type: string; + protected abstract readonly config: IGroupingRendererConfig; + + /** + * Fetch entities from service + */ + protected abstract getEntities(ids: string[]): Promise; + + /** + * Get display name for entity + */ + protected abstract getDisplayName(entity: T): string; + + /** + * Main render method - handles common logic + */ + async render(context: IRenderContext): Promise { + 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; + } +} diff --git a/packages/calendar/src/core/CalendarApp.ts b/packages/calendar/src/core/CalendarApp.ts new file mode 100644 index 0000000..246b257 --- /dev/null +++ b/packages/calendar/src/core/CalendarApp.ts @@ -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 = 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 { + 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).detail; + this.handleRenderCommand(viewId); + }); + + // Workweek change via EventBus + this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => { + const { presetId } = (e as CustomEvent).detail; + this.handleWorkweekChange(presetId); + }); + + // View update via EventBus + this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => { + const { type, values } = (e as CustomEvent).detail; + this.handleViewUpdate(type, values); + }); + } + + private async handleRenderCommand(viewId: string): Promise { + this.currentViewId = viewId; + await this.render(); + this.emitStatus('rendered', { viewId }); + } + + private async handleNavigatePrev(): Promise { + 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 { + 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 { + 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 { + this.groupingOverrides.set(type, values); + await this.render(); + this.emitStatus('rendered', { viewId: this.currentViewId }); + } + + private async render(): Promise { + 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 + })); + } +} diff --git a/packages/calendar/src/core/CalendarEvents.ts b/packages/calendar/src/core/CalendarEvents.ts new file mode 100644 index 0000000..4cf553e --- /dev/null +++ b/packages/calendar/src/core/CalendarEvents.ts @@ -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[]; +} diff --git a/packages/calendar/src/core/CalendarOrchestrator.ts b/packages/calendar/src/core/CalendarOrchestrator.ts new file mode 100644 index 0000000..933e8a5 --- /dev/null +++ b/packages/calendar/src/core/CalendarOrchestrator.ts @@ -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[] + ) {} + + async render(viewConfig: ViewConfig, container: HTMLElement): Promise { + 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 = {}; + 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 + ): Promise<{ parentChildMap?: Record; 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).id as string) + ); + + // Byg parent-child map + const map: Record = {}; + for (const entity of entities) { + const entityRecord = entity as unknown as Record; + const children = (entityRecord[property] as string[]) || []; + map[entityRecord.id as string] = children; + } + + return { parentChildMap: map, childType: childGrouping.type }; + } +} diff --git a/packages/calendar/src/core/DateService.ts b/packages/calendar/src/core/DateService.ts new file mode 100644 index 0000000..1d3c44d --- /dev/null +++ b/packages/calendar/src/core/DateService.ts @@ -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 { + // 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 + } +} diff --git a/packages/calendar/src/core/EntityResolver.ts b/packages/calendar/src/core/EntityResolver.ts new file mode 100644 index 0000000..7161c30 --- /dev/null +++ b/packages/calendar/src/core/EntityResolver.ts @@ -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>> = 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(entityType: string, entities: T[]): void { + const typeCache = new Map>(); + for (const entity of entities) { + // Cast to Record for storage while preserving original data + typeCache.set(entity.id, entity as unknown as Record); + } + this.cache.set(entityType, typeCache); + } + + /** + * Resolve an entity by type and ID + */ + resolve(entityType: string, id: string): Record | 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); + } +} diff --git a/packages/calendar/src/core/EventBus.ts b/packages/calendar/src/core/EventBus.ts new file mode 100644 index 0000000..469a73e --- /dev/null +++ b/packages/calendar/src/core/EventBus.ts @@ -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 = 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; + } +} diff --git a/packages/calendar/src/core/FilterTemplate.ts b/packages/calendar/src/core/FilterTemplate.ts new file mode 100644 index 0000000..00451b1 --- /dev/null +++ b/packages/calendar/src/core/FilterTemplate.ts @@ -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 på. + * + * @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 på 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, 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); + } +} diff --git a/packages/calendar/src/core/HeaderDrawerManager.ts b/packages/calendar/src/core/HeaderDrawerManager.ts new file mode 100644 index 0000000..445bb23 --- /dev/null +++ b/packages/calendar/src/core/HeaderDrawerManager.ts @@ -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; + } +} diff --git a/packages/calendar/src/core/IEntityResolver.ts b/packages/calendar/src/core/IEntityResolver.ts new file mode 100644 index 0000000..b825c0f --- /dev/null +++ b/packages/calendar/src/core/IEntityResolver.ts @@ -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 | undefined; +} diff --git a/packages/calendar/src/core/IGridConfig.ts b/packages/calendar/src/core/IGridConfig.ts new file mode 100644 index 0000000..03c6a2f --- /dev/null +++ b/packages/calendar/src/core/IGridConfig.ts @@ -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) +} diff --git a/packages/calendar/src/core/IGroupingRenderer.ts b/packages/calendar/src/core/IGroupingRenderer.ts new file mode 100644 index 0000000..a1bc507 --- /dev/null +++ b/packages/calendar/src/core/IGroupingRenderer.ts @@ -0,0 +1,15 @@ +import { GroupingConfig } from './ViewConfig'; + +export interface IRenderContext { + headerContainer: HTMLElement; + columnContainer: HTMLElement; + filter: Record; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] } + groupings?: GroupingConfig[]; // Full grouping configs (for hideHeader etc.) + parentChildMap?: Record; // { 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; +} diff --git a/packages/calendar/src/core/IGroupingStore.ts b/packages/calendar/src/core/IGroupingStore.ts new file mode 100644 index 0000000..8abc837 --- /dev/null +++ b/packages/calendar/src/core/IGroupingStore.ts @@ -0,0 +1,4 @@ +export interface IGroupingStore { + readonly type: string; + getByIds(ids: string[]): T[]; +} diff --git a/packages/calendar/src/core/ITimeFormatConfig.ts b/packages/calendar/src/core/ITimeFormatConfig.ts new file mode 100644 index 0000000..1a401d5 --- /dev/null +++ b/packages/calendar/src/core/ITimeFormatConfig.ts @@ -0,0 +1,7 @@ +export interface ITimeFormatConfig { + timezone: string; + use24HourFormat: boolean; + locale: string; + dateFormat: 'locale' | 'technical'; + showSeconds: boolean; +} diff --git a/packages/calendar/src/core/NavigationAnimator.ts b/packages/calendar/src/core/NavigationAnimator.ts new file mode 100644 index 0000000..cf173ad --- /dev/null +++ b/packages/calendar/src/core/NavigationAnimator.ts @@ -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): Promise { + 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 { + 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 { + 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); + } +} diff --git a/packages/calendar/src/core/RenderBuilder.ts b/packages/calendar/src/core/RenderBuilder.ts new file mode 100644 index 0000000..68f0ee3 --- /dev/null +++ b/packages/calendar/src/core/RenderBuilder.ts @@ -0,0 +1,15 @@ +import { IRenderer, IRenderContext } from './IGroupingRenderer'; + +export interface Pipeline { + run(context: IRenderContext): Promise; +} + +export function buildPipeline(renderers: IRenderer[]): Pipeline { + return { + async run(context: IRenderContext) { + for (const renderer of renderers) { + await renderer.render(context); + } + } + }; +} diff --git a/packages/calendar/src/core/ScrollManager.ts b/packages/calendar/src/core/ScrollManager.ts new file mode 100644 index 0000000..bb4f490 --- /dev/null +++ b/packages/calendar/src/core/ScrollManager.ts @@ -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)`; + } +} diff --git a/packages/calendar/src/core/ViewConfig.ts b/packages/calendar/src/core/ViewConfig.ts new file mode 100644 index 0000000..8ecd79b --- /dev/null +++ b/packages/calendar/src/core/ViewConfig.ts @@ -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) +} diff --git a/packages/calendar/src/extensions/audit/AuditService.ts b/packages/calendar/src/extensions/audit/AuditService.ts new file mode 100644 index 0000000..ccdb2b4 --- /dev/null +++ b/packages/calendar/src/extensions/audit/AuditService.ts @@ -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 { + 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 { + // 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 { + // 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 { + 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 { + throw new Error('Audit entries cannot be deleted (compliance requirement)'); + } + + /** + * Get pending audit entries (for sync) + */ + async getPendingAudits(): Promise { + return this.getBySyncStatus('pending'); + } + + /** + * Get audit entries for a specific entity + */ + async getByEntityId(entityId: string): Promise { + 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}`)); + }; + }); + } +} diff --git a/packages/calendar/src/extensions/audit/AuditStore.ts b/packages/calendar/src/extensions/audit/AuditStore.ts new file mode 100644 index 0000000..769b3b9 --- /dev/null +++ b/packages/calendar/src/extensions/audit/AuditStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/extensions/audit/index.ts b/packages/calendar/src/extensions/audit/index.ts new file mode 100644 index 0000000..48100e1 --- /dev/null +++ b/packages/calendar/src/extensions/audit/index.ts @@ -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(); + builder.registerType(AuditService).as(); +} diff --git a/packages/calendar/src/extensions/bookings/BookingService.ts b/packages/calendar/src/extensions/bookings/BookingService.ts new file mode 100644 index 0000000..d12eb57 --- /dev/null +++ b/packages/calendar/src/extensions/bookings/BookingService.ts @@ -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 { + 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; + return { + ...raw, + createdAt: new Date(raw.createdAt as string) + } as IBooking; + } + + /** + * Get bookings for a customer + */ + async getByCustomer(customerId: string): Promise { + 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 { + 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}`)); + }; + }); + } +} diff --git a/packages/calendar/src/extensions/bookings/BookingStore.ts b/packages/calendar/src/extensions/bookings/BookingStore.ts new file mode 100644 index 0000000..5e64ad3 --- /dev/null +++ b/packages/calendar/src/extensions/bookings/BookingStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/extensions/bookings/index.ts b/packages/calendar/src/extensions/bookings/index.ts new file mode 100644 index 0000000..4bfe7d2 --- /dev/null +++ b/packages/calendar/src/extensions/bookings/index.ts @@ -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(); + builder.registerType(BookingService).as>(); + builder.registerType(BookingService).as>(); + builder.registerType(BookingService).as(); +} diff --git a/packages/calendar/src/extensions/customers/CustomerService.ts b/packages/calendar/src/extensions/customers/CustomerService.ts new file mode 100644 index 0000000..d1225e1 --- /dev/null +++ b/packages/calendar/src/extensions/customers/CustomerService.ts @@ -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 { + 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 { + 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 { + 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}`)); + }; + }); + } +} diff --git a/packages/calendar/src/extensions/customers/CustomerStore.ts b/packages/calendar/src/extensions/customers/CustomerStore.ts new file mode 100644 index 0000000..b53cd7e --- /dev/null +++ b/packages/calendar/src/extensions/customers/CustomerStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/extensions/customers/index.ts b/packages/calendar/src/extensions/customers/index.ts new file mode 100644 index 0000000..6d47df5 --- /dev/null +++ b/packages/calendar/src/extensions/customers/index.ts @@ -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(); + builder.registerType(CustomerService).as>(); + builder.registerType(CustomerService).as>(); + builder.registerType(CustomerService).as(); +} diff --git a/packages/calendar/src/extensions/departments/DepartmentRenderer.ts b/packages/calendar/src/extensions/departments/DepartmentRenderer.ts new file mode 100644 index 0000000..16bb161 --- /dev/null +++ b/packages/calendar/src/extensions/departments/DepartmentRenderer.ts @@ -0,0 +1,25 @@ +import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer'; +import { DepartmentService } from './DepartmentService'; +import { IDepartment } from '../../types/CalendarTypes'; + +export class DepartmentRenderer extends BaseGroupingRenderer { + 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 { + return this.departmentService.getByIds(ids); + } + + protected getDisplayName(entity: IDepartment): string { + return entity.name; + } +} diff --git a/packages/calendar/src/extensions/departments/DepartmentService.ts b/packages/calendar/src/extensions/departments/DepartmentService.ts new file mode 100644 index 0000000..4b4c8b9 --- /dev/null +++ b/packages/calendar/src/extensions/departments/DepartmentService.ts @@ -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 { + 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 { + 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); + } +} diff --git a/packages/calendar/src/extensions/departments/DepartmentStore.ts b/packages/calendar/src/extensions/departments/DepartmentStore.ts new file mode 100644 index 0000000..0e9c6a3 --- /dev/null +++ b/packages/calendar/src/extensions/departments/DepartmentStore.ts @@ -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' }); + } +} diff --git a/packages/calendar/src/extensions/departments/index.ts b/packages/calendar/src/extensions/departments/index.ts new file mode 100644 index 0000000..d50130a --- /dev/null +++ b/packages/calendar/src/extensions/departments/index.ts @@ -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(); + builder.registerType(DepartmentService).as>(); + builder.registerType(DepartmentService).as>(); + builder.registerType(DepartmentService).as(); + builder.registerType(DepartmentRenderer).as(); +} diff --git a/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts b/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts new file mode 100644 index 0000000..a548865 --- /dev/null +++ b/packages/calendar/src/extensions/schedules/ResourceScheduleService.ts @@ -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 { + // 1. Check for override + const override = await this.overrideService.getOverride(resourceId, date); + if (override) { + return override.schedule; + } + + // 2. Use default schedule for weekday + const resource = await this.resourceService.get(resourceId); + if (!resource || !resource.defaultSchedule) { + return null; + } + + const weekDay = this.dateService.getISOWeekDay(date); + return resource.defaultSchedule[weekDay] || null; + } + + /** + * Get schedules for multiple dates + * + * @param resourceId - Resource ID + * @param dates - Array of date strings "YYYY-MM-DD" + * @returns Map of date -> ITimeSlot | null + */ + async getSchedulesForDates(resourceId: string, dates: string[]): Promise> { + const result = new Map(); + + // Get resource once + const resource = await this.resourceService.get(resourceId); + + // Get all overrides in date range + const overrides = dates.length > 0 + ? await this.overrideService.getByDateRange(resourceId, dates[0], dates[dates.length - 1]) + : []; + + // Build override map + const overrideMap = new Map(overrides.map(o => [o.date, o.schedule])); + + // Resolve each date + for (const date of dates) { + // Check override first + if (overrideMap.has(date)) { + result.set(date, overrideMap.get(date)!); + continue; + } + + // Fall back to default + if (resource?.defaultSchedule) { + const weekDay = this.dateService.getISOWeekDay(date); + result.set(date, resource.defaultSchedule[weekDay] || null); + } else { + result.set(date, null); + } + } + + return result; + } +} diff --git a/packages/calendar/src/extensions/schedules/ScheduleOverrideService.ts b/packages/calendar/src/extensions/schedules/ScheduleOverrideService.ts new file mode 100644 index 0000000..184e354 --- /dev/null +++ b/packages/calendar/src/extensions/schedules/ScheduleOverrideService.ts @@ -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 { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly'); + const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME); + const index = store.index('resourceId_date'); + const request = index.get([resourceId, date]); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + reject(new Error(`Failed to get override for ${resourceId} on ${date}: ${request.error}`)); + }; + }); + } + + /** + * Get all overrides for a resource + */ + async getByResource(resourceId: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readonly'); + const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME); + const index = store.index('resourceId'); + const request = index.getAll(resourceId); + + request.onsuccess = () => { + resolve(request.result || []); + }; + + request.onerror = () => { + reject(new Error(`Failed to get overrides for ${resourceId}: ${request.error}`)); + }; + }); + } + + /** + * Get overrides for a date range + */ + async getByDateRange(resourceId: string, startDate: string, endDate: string): Promise { + const all = await this.getByResource(resourceId); + return all.filter(o => o.date >= startDate && o.date <= endDate); + } + + /** + * Save an override + */ + async save(override: IScheduleOverride): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite'); + const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME); + const request = store.put(override); + + request.onsuccess = () => resolve(); + request.onerror = () => { + reject(new Error(`Failed to save override ${override.id}: ${request.error}`)); + }; + }); + } + + /** + * Delete an override + */ + async delete(id: string): Promise { + return new Promise((resolve, reject) => { + const transaction = this.db.transaction([ScheduleOverrideStore.STORE_NAME], 'readwrite'); + const store = transaction.objectStore(ScheduleOverrideStore.STORE_NAME); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => { + reject(new Error(`Failed to delete override ${id}: ${request.error}`)); + }; + }); + } +} diff --git a/packages/calendar/src/extensions/schedules/ScheduleOverrideStore.ts b/packages/calendar/src/extensions/schedules/ScheduleOverrideStore.ts new file mode 100644 index 0000000..0fe4f09 --- /dev/null +++ b/packages/calendar/src/extensions/schedules/ScheduleOverrideStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/extensions/schedules/index.ts b/packages/calendar/src/extensions/schedules/index.ts new file mode 100644 index 0000000..e8a94f0 --- /dev/null +++ b/packages/calendar/src/extensions/schedules/index.ts @@ -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(); + builder.registerType(ScheduleOverrideService).as(); + builder.registerType(ResourceScheduleService).as(); +} diff --git a/packages/calendar/src/extensions/teams/TeamRenderer.ts b/packages/calendar/src/extensions/teams/TeamRenderer.ts new file mode 100644 index 0000000..090d8c9 --- /dev/null +++ b/packages/calendar/src/extensions/teams/TeamRenderer.ts @@ -0,0 +1,25 @@ +import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer'; +import { TeamService } from './TeamService'; +import { ITeam } from '../../types/CalendarTypes'; + +export class TeamRenderer extends BaseGroupingRenderer { + 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 { + return this.teamService.getByIds(ids); + } + + protected getDisplayName(entity: ITeam): string { + return entity.name; + } +} diff --git a/packages/calendar/src/extensions/teams/TeamService.ts b/packages/calendar/src/extensions/teams/TeamService.ts new file mode 100644 index 0000000..655dd1f --- /dev/null +++ b/packages/calendar/src/extensions/teams/TeamService.ts @@ -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 { + 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 { + 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> { + const teams = await this.getAll(); + const map: Record = {}; + + for (const team of teams) { + for (const resourceId of team.resourceIds) { + map[resourceId] = team.id; + } + } + + return map; + } +} diff --git a/packages/calendar/src/extensions/teams/TeamStore.ts b/packages/calendar/src/extensions/teams/TeamStore.ts new file mode 100644 index 0000000..af515e0 --- /dev/null +++ b/packages/calendar/src/extensions/teams/TeamStore.ts @@ -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' }); + } +} diff --git a/packages/calendar/src/extensions/teams/index.ts b/packages/calendar/src/extensions/teams/index.ts new file mode 100644 index 0000000..f9d231e --- /dev/null +++ b/packages/calendar/src/extensions/teams/index.ts @@ -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(); + builder.registerType(TeamService).as>(); + builder.registerType(TeamService).as>(); + builder.registerType(TeamService).as(); + builder.registerType(TeamRenderer).as(); +} diff --git a/packages/calendar/src/features/date/DateRenderer.ts b/packages/calendar/src/features/date/DateRenderer.ts new file mode 100644 index 0000000..4f1cfad --- /dev/null +++ b/packages/calendar/src/features/date/DateRenderer.ts @@ -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 = { 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 = ` + ${this.dateService.getDayName(date, 'short')} + ${date.getDate()} + `; + 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 = ''; + 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)); + } + } +} diff --git a/packages/calendar/src/features/date/index.ts b/packages/calendar/src/features/date/index.ts new file mode 100644 index 0000000..7bf37b3 --- /dev/null +++ b/packages/calendar/src/features/date/index.ts @@ -0,0 +1 @@ +export { DateRenderer } from './DateRenderer'; diff --git a/packages/calendar/src/features/event/EventLayoutEngine.ts b/packages/calendar/src/features/event/EventLayoutEngine.ts new file mode 100644 index 0000000..0b10905 --- /dev/null +++ b/packages/calendar/src/features/event/EventLayoutEngine.ts @@ -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(); + 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(); + 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 { + const levels = new Map(); + 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; +} diff --git a/packages/calendar/src/features/event/EventLayoutTypes.ts b/packages/calendar/src/features/event/EventLayoutTypes.ts new file mode 100644 index 0000000..c887eaf --- /dev/null +++ b/packages/calendar/src/features/event/EventLayoutTypes.ts @@ -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[]; +} diff --git a/packages/calendar/src/features/event/EventRenderer.ts b/packages/calendar/src/features/event/EventRenderer.ts new file mode 100644 index 0000000..6cc1b96 --- /dev/null +++ b/packages/calendar/src/features/event/EventRenderer.ts @@ -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).detail; + this.handleColumnChange(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => { + const payload = (e as CustomEvent).detail; + this.updateDragTimestamp(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => { + const payload = (e as CustomEvent).detail; + this.handleEventUpdated(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragEnd(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = (e as CustomEvent).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 { + // 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 { + 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, filterTemplate: FilterTemplate): Promise { + // 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 = ` + ${this.dateService.formatTimeRange(event.start, event.end)} + ${this.escapeHtml(event.title)} + ${event.description ? `${this.escapeHtml(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 = { + '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; + } +} diff --git a/packages/calendar/src/features/event/index.ts b/packages/calendar/src/features/event/index.ts new file mode 100644 index 0000000..7b8f118 --- /dev/null +++ b/packages/calendar/src/features/event/index.ts @@ -0,0 +1 @@ +export { EventRenderer } from './EventRenderer'; diff --git a/packages/calendar/src/features/headerdrawer/HeaderDrawerLayoutEngine.ts b/packages/calendar/src/features/headerdrawer/HeaderDrawerLayoutEngine.ts new file mode 100644 index 0000000..b407a58 --- /dev/null +++ b/packages/calendar/src/features/headerdrawer/HeaderDrawerLayoutEngine.ts @@ -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(); + } +} diff --git a/packages/calendar/src/features/headerdrawer/HeaderDrawerRenderer.ts b/packages/calendar/src/features/headerdrawer/HeaderDrawerRenderer.ts new file mode 100644 index 0000000..1b528cd --- /dev/null +++ b/packages/calendar/src/features/headerdrawer/HeaderDrawerRenderer.ts @@ -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, filterTemplate: FilterTemplate): Promise { + // 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 = { + '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).detail; + this.handleDragEnter(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragMove(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragLeave(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { + const payload = (e as CustomEvent).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 grid→header 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(); + } + } +} diff --git a/packages/calendar/src/features/headerdrawer/index.ts b/packages/calendar/src/features/headerdrawer/index.ts new file mode 100644 index 0000000..7b19757 --- /dev/null +++ b/packages/calendar/src/features/headerdrawer/index.ts @@ -0,0 +1,2 @@ +export { HeaderDrawerRenderer } from './HeaderDrawerRenderer'; +export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine'; diff --git a/packages/calendar/src/features/resource/ResourceRenderer.ts b/packages/calendar/src/features/resource/ResourceRenderer.ts new file mode 100644 index 0000000..2bf565f --- /dev/null +++ b/packages/calendar/src/features/resource/ResourceRenderer.ts @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/packages/calendar/src/features/resource/index.ts b/packages/calendar/src/features/resource/index.ts new file mode 100644 index 0000000..3bbd0d9 --- /dev/null +++ b/packages/calendar/src/features/resource/index.ts @@ -0,0 +1 @@ +export { ResourceRenderer } from './ResourceRenderer'; diff --git a/packages/calendar/src/features/schedule/ScheduleRenderer.ts b/packages/calendar/src/features/schedule/ScheduleRenderer.ts new file mode 100644 index 0000000..d1d3349 --- /dev/null +++ b/packages/calendar/src/features/schedule/ScheduleRenderer.ts @@ -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): Promise { + const dates = filter['date'] || []; + const resourceIds = filter['resource'] || []; + + if (dates.length === 0) return; + + // Find day columns + const dayColumns = container.querySelector('swp-day-columns'); + if (!dayColumns) return; + + const columns = dayColumns.querySelectorAll('swp-day-column'); + + for (const column of columns) { + const 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; + } +} diff --git a/packages/calendar/src/features/schedule/index.ts b/packages/calendar/src/features/schedule/index.ts new file mode 100644 index 0000000..c6ca514 --- /dev/null +++ b/packages/calendar/src/features/schedule/index.ts @@ -0,0 +1 @@ +export { ScheduleRenderer } from './ScheduleRenderer'; diff --git a/packages/calendar/src/features/timeaxis/TimeAxisRenderer.ts b/packages/calendar/src/features/timeaxis/TimeAxisRenderer.ts new file mode 100644 index 0000000..80279be --- /dev/null +++ b/packages/calendar/src/features/timeaxis/TimeAxisRenderer.ts @@ -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); + } + } +} diff --git a/packages/calendar/src/index.ts b/packages/calendar/src/index.ts new file mode 100644 index 0000000..a0bee9c --- /dev/null +++ b/packages/calendar/src/index.ts @@ -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'; diff --git a/packages/calendar/src/managers/DragDropManager.ts b/packages/calendar/src/managers/DragDropManager.ts new file mode 100644 index 0000000..4bf50b9 --- /dev/null +++ b/packages/calendar/src/managers/DragDropManager.ts @@ -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; + } +} diff --git a/packages/calendar/src/managers/EdgeScrollManager.ts b/packages/calendar/src/managers/EdgeScrollManager.ts new file mode 100644 index 0000000..d1b5584 --- /dev/null +++ b/packages/calendar/src/managers/EdgeScrollManager.ts @@ -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); + }; +} diff --git a/packages/calendar/src/managers/EventPersistenceManager.ts b/packages/calendar/src/managers/EventPersistenceManager.ts new file mode 100644 index 0000000..ae59df9 --- /dev/null +++ b/packages/calendar/src/managers/EventPersistenceManager.ts @@ -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 => { + const payload = (e as CustomEvent).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 => { + const payload = (e as CustomEvent).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); + }; +} diff --git a/packages/calendar/src/managers/ResizeManager.ts b/packages/calendar/src/managers/ResizeManager.ts new file mode 100644 index 0000000..5448def --- /dev/null +++ b/packages/calendar/src/managers/ResizeManager.ts @@ -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; + } +} diff --git a/packages/calendar/src/repositories/IApiRepository.ts b/packages/calendar/src/repositories/IApiRepository.ts new file mode 100644 index 0000000..a50791f --- /dev/null +++ b/packages/calendar/src/repositories/IApiRepository.ts @@ -0,0 +1,33 @@ +import { EntityType } from '../types/CalendarTypes'; + +/** + * IApiRepository - Generic interface for backend API communication + * + * Used by DataSeeder to fetch initial data and by SyncManager for sync operations. + */ +export interface IApiRepository { + /** + * Entity type discriminator - used for runtime routing + */ + readonly entityType: EntityType; + + /** + * Send create operation to backend API + */ + sendCreate(data: T): Promise; + + /** + * Send update operation to backend API + */ + sendUpdate(id: string, updates: Partial): Promise; + + /** + * Send delete operation to backend API + */ + sendDelete(id: string): Promise; + + /** + * Fetch all entities from backend API + */ + fetchAll(): Promise; +} diff --git a/packages/calendar/src/storage/BaseEntityService.ts b/packages/calendar/src/storage/BaseEntityService.ts new file mode 100644 index 0000000..ed8d3a1 --- /dev/null +++ b/packages/calendar/src/storage/BaseEntityService.ts @@ -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 - 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 implements IEntityService { + abstract readonly storeName: string; + abstract readonly entityType: EntityType; + + private syncPlugin: SyncPlugin; + private context: IndexedDBContext; + protected eventBus: IEventBus; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + this.context = context; + this.eventBus = eventBus; + this.syncPlugin = new SyncPlugin(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 { + 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 { + 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 { + 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 { + 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 { + return this.syncPlugin.markAsSynced(id); + } + + async markAsError(id: string): Promise { + return this.syncPlugin.markAsError(id); + } + + async getSyncStatus(id: string): Promise { + return this.syncPlugin.getSyncStatus(id); + } + + async getBySyncStatus(syncStatus: string): Promise { + return this.syncPlugin.getBySyncStatus(syncStatus); + } +} diff --git a/packages/calendar/src/storage/IEntityService.ts b/packages/calendar/src/storage/IEntityService.ts new file mode 100644 index 0000000..800ea62 --- /dev/null +++ b/packages/calendar/src/storage/IEntityService.ts @@ -0,0 +1,40 @@ +import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes'; + +/** + * IEntityService - Generic interface for entity services with sync capabilities + * + * All entity services implement this interface to enable polymorphic operations. + */ +export interface IEntityService { + /** + * Entity type discriminator for runtime routing + */ + readonly entityType: EntityType; + + /** + * Get all entities from IndexedDB + */ + getAll(): Promise; + + /** + * 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; + + /** + * Mark entity as successfully synced + */ + markAsSynced(id: string): Promise; + + /** + * Mark entity as sync error + */ + markAsError(id: string): Promise; + + /** + * Get current sync status for an entity + */ + getSyncStatus(id: string): Promise; +} diff --git a/packages/calendar/src/storage/IStore.ts b/packages/calendar/src/storage/IStore.ts new file mode 100644 index 0000000..91ac873 --- /dev/null +++ b/packages/calendar/src/storage/IStore.ts @@ -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; +} diff --git a/packages/calendar/src/storage/IndexedDBContext.ts b/packages/calendar/src/storage/IndexedDBContext.ts new file mode 100644 index 0000000..ab9e9ab --- /dev/null +++ b/packages/calendar/src/storage/IndexedDBContext.ts @@ -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 { + 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 { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`)); + }); + } +} diff --git a/packages/calendar/src/storage/SyncPlugin.ts b/packages/calendar/src/storage/SyncPlugin.ts new file mode 100644 index 0000000..7774da6 --- /dev/null +++ b/packages/calendar/src/storage/SyncPlugin.ts @@ -0,0 +1,64 @@ +import { ISync, SyncStatus } from '../types/CalendarTypes'; + +/** + * SyncPlugin - Pluggable sync functionality for entity services + * + * COMPOSITION PATTERN: + * - Encapsulates all sync-related logic in separate class + * - Composed into BaseEntityService (not inheritance) + */ +export class SyncPlugin { + constructor(private service: any) {} + + /** + * Mark entity as successfully synced + */ + async markAsSynced(id: string): Promise { + 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 { + 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 { + 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 { + 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}`)); + }; + }); + } +} diff --git a/packages/calendar/src/storage/events/EventSerialization.ts b/packages/calendar/src/storage/events/EventSerialization.ts new file mode 100644 index 0000000..583fa79 --- /dev/null +++ b/packages/calendar/src/storage/events/EventSerialization.ts @@ -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): 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; + } +} diff --git a/packages/calendar/src/storage/events/EventService.ts b/packages/calendar/src/storage/events/EventService.ts new file mode 100644 index 0000000..0ccd5a5 --- /dev/null +++ b/packages/calendar/src/storage/events/EventService.ts @@ -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 { + 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); + } + + /** + * Get events within a date range + */ + async getByDateRange(start: Date, end: Date): Promise { + 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 { + 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 { + const resourceEvents = await this.getByResource(resourceId); + return resourceEvents.filter(event => event.start >= start && event.start <= end); + } +} diff --git a/packages/calendar/src/storage/events/EventStore.ts b/packages/calendar/src/storage/events/EventStore.ts new file mode 100644 index 0000000..21c7be0 --- /dev/null +++ b/packages/calendar/src/storage/events/EventStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/storage/resources/ResourceService.ts b/packages/calendar/src/storage/resources/ResourceService.ts new file mode 100644 index 0000000..769210c --- /dev/null +++ b/packages/calendar/src/storage/resources/ResourceService.ts @@ -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 { + readonly storeName = ResourceStore.STORE_NAME; + readonly entityType: EntityType = 'Resource'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + /** + * Get all active resources + */ + async getActive(): Promise { + const all = await this.getAll(); + return all.filter(r => r.isActive !== false); + } + + /** + * Get resources by IDs + */ + async getByIds(ids: string[]): Promise { + 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 { + 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}`)); + }; + }); + } +} diff --git a/packages/calendar/src/storage/resources/ResourceStore.ts b/packages/calendar/src/storage/resources/ResourceStore.ts new file mode 100644 index 0000000..38e39b6 --- /dev/null +++ b/packages/calendar/src/storage/resources/ResourceStore.ts @@ -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 }); + } +} diff --git a/packages/calendar/src/storage/settings/SettingsService.ts b/packages/calendar/src/storage/settings/SettingsService.ts new file mode 100644 index 0000000..5bc57b4 --- /dev/null +++ b/packages/calendar/src/storage/settings/SettingsService.ts @@ -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 { + readonly storeName = SettingsStore.STORE_NAME; + readonly entityType: EntityType = 'Settings'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + /** + * Get workweek settings + */ + async getWorkweekSettings(): Promise { + return this.get(SettingsIds.WORKWEEK) as Promise; + } + + /** + * Get grid settings + */ + async getGridSettings(): Promise { + return this.get(SettingsIds.GRID) as Promise; + } + + /** + * Get time format settings + */ + async getTimeFormatSettings(): Promise { + return this.get(SettingsIds.TIME_FORMAT) as Promise; + } + + /** + * Get view settings + */ + async getViewSettings(): Promise { + return this.get(SettingsIds.VIEWS) as Promise; + } + + /** + * Get workweek preset by ID + */ + async getWorkweekPreset(presetId: string): Promise { + const settings = await this.getWorkweekSettings(); + if (!settings) return null; + return settings.presets[presetId] || null; + } + + /** + * Get the default workweek preset + */ + async getDefaultWorkweekPreset(): Promise { + const settings = await this.getWorkweekSettings(); + if (!settings) return null; + return settings.presets[settings.defaultPreset] || null; + } + + /** + * Get all available workweek presets + */ + async getWorkweekPresets(): Promise { + const settings = await this.getWorkweekSettings(); + if (!settings) return []; + return Object.values(settings.presets); + } +} diff --git a/packages/calendar/src/storage/settings/SettingsStore.ts b/packages/calendar/src/storage/settings/SettingsStore.ts new file mode 100644 index 0000000..a28cc79 --- /dev/null +++ b/packages/calendar/src/storage/settings/SettingsStore.ts @@ -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' }); + } +} diff --git a/packages/calendar/src/storage/viewconfigs/ViewConfigService.ts b/packages/calendar/src/storage/viewconfigs/ViewConfigService.ts new file mode 100644 index 0000000..03a42f7 --- /dev/null +++ b/packages/calendar/src/storage/viewconfigs/ViewConfigService.ts @@ -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 { + readonly storeName = ViewConfigStore.STORE_NAME; + readonly entityType: EntityType = 'ViewConfig'; + + constructor(context: IndexedDBContext, eventBus: IEventBus) { + super(context, eventBus); + } + + async getById(id: string): Promise { + return this.get(id); + } +} diff --git a/packages/calendar/src/storage/viewconfigs/ViewConfigStore.ts b/packages/calendar/src/storage/viewconfigs/ViewConfigStore.ts new file mode 100644 index 0000000..fb02d07 --- /dev/null +++ b/packages/calendar/src/storage/viewconfigs/ViewConfigStore.ts @@ -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' }); + } +} diff --git a/packages/calendar/src/types/AuditTypes.ts b/packages/calendar/src/types/AuditTypes.ts new file mode 100644 index 0000000..3c0eb9f --- /dev/null +++ b/packages/calendar/src/types/AuditTypes.ts @@ -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; +} diff --git a/packages/calendar/src/types/CalendarTypes.ts b/packages/calendar/src/types/CalendarTypes.ts new file mode 100644 index 0000000..c7aa21c --- /dev/null +++ b/packages/calendar/src/types/CalendarTypes.ts @@ -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; +} + +// 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; +} + +// 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; +} diff --git a/packages/calendar/src/types/DragTypes.ts b/packages/calendar/src/types/DragTypes.ts new file mode 100644 index 0000000..d63c1a8 --- /dev/null +++ b/packages/calendar/src/types/DragTypes.ts @@ -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; +} diff --git a/packages/calendar/src/types/ResizeTypes.ts b/packages/calendar/src/types/ResizeTypes.ts new file mode 100644 index 0000000..75caf6b --- /dev/null +++ b/packages/calendar/src/types/ResizeTypes.ts @@ -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 +} diff --git a/packages/calendar/src/types/ScheduleTypes.ts b/packages/calendar/src/types/ScheduleTypes.ts new file mode 100644 index 0000000..65bfee7 --- /dev/null +++ b/packages/calendar/src/types/ScheduleTypes.ts @@ -0,0 +1,27 @@ +/** + * Schedule Types - Resource arbejdstider + */ + +// Genbrugelig tidsslot +export interface ITimeSlot { + start: string; // "HH:mm" + end: string; // "HH:mm" +} + +// Ugedag: 1=mandag, 7=søndag (ISO 8601) +export type WeekDay = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +// Default arbejdstider per ugedag +export interface IWeekSchedule { + [day: number]: ITimeSlot | null; // null = fri den dag +} + +// Override for specifik dato +export interface IScheduleOverride { + id: string; + resourceId: string; + date: string; // "YYYY-MM-DD" + schedule: ITimeSlot | null; // null = fri den dag + breaks?: ITimeSlot[]; + syncStatus?: 'synced' | 'pending' | 'error'; +} diff --git a/packages/calendar/src/types/SettingsTypes.ts b/packages/calendar/src/types/SettingsTypes.ts new file mode 100644 index 0000000..5ae47aa --- /dev/null +++ b/packages/calendar/src/types/SettingsTypes.ts @@ -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; + 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; diff --git a/packages/calendar/src/types/SwpEvent.ts b/packages/calendar/src/types/SwpEvent.ts new file mode 100644 index 0000000..ff8373b --- /dev/null +++ b/packages/calendar/src/types/SwpEvent.ts @@ -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); + } +} diff --git a/packages/calendar/src/utils/PositionUtils.ts b/packages/calendar/src/utils/PositionUtils.ts new file mode 100644 index 0000000..5c99e4b --- /dev/null +++ b/packages/calendar/src/utils/PositionUtils.ts @@ -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; +} diff --git a/packages/calendar/tsconfig.json b/packages/calendar/tsconfig.json new file mode 100644 index 0000000..c1c4978 --- /dev/null +++ b/packages/calendar/tsconfig.json @@ -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"] +} diff --git a/test-package/build.js b/test-package/build.js new file mode 100644 index 0000000..07cf9e7 --- /dev/null +++ b/test-package/build.js @@ -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'); diff --git a/test-package/dist/bundle.js b/test-package/dist/bundle.js new file mode 100644 index 0000000..71cc1e8 --- /dev/null +++ b/test-package/dist/bundle.js @@ -0,0 +1,5289 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/dayjs/dayjs.min.js +var require_dayjs_min = __commonJS({ + "node_modules/dayjs/dayjs.min.js"(exports, module) { + !(function(t, e) { + "object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs = e(); + })(exports, (function() { + "use strict"; + var t = 1e3, e = 6e4, n = 36e5, r = "millisecond", i = "second", s = "minute", u = "hour", a = "day", o = "week", c = "month", f = "quarter", h = "year", d = "date", l = "Invalid Date", $ = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, y = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, M = { name: "en", weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), ordinal: function(t2) { + var e2 = ["th", "st", "nd", "rd"], n2 = t2 % 100; + return "[" + t2 + (e2[(n2 - 20) % 10] || e2[n2] || e2[0]) + "]"; + } }, m = function(t2, e2, n2) { + var r2 = String(t2); + return !r2 || r2.length >= e2 ? t2 : "" + Array(e2 + 1 - r2.length).join(n2) + t2; + }, v = { s: m, z: function(t2) { + var e2 = -t2.utcOffset(), n2 = Math.abs(e2), r2 = Math.floor(n2 / 60), i2 = n2 % 60; + return (e2 <= 0 ? "+" : "-") + m(r2, 2, "0") + ":" + m(i2, 2, "0"); + }, m: function t2(e2, n2) { + if (e2.date() < n2.date()) return -t2(n2, e2); + var r2 = 12 * (n2.year() - e2.year()) + (n2.month() - e2.month()), i2 = e2.clone().add(r2, c), s2 = n2 - i2 < 0, u2 = e2.clone().add(r2 + (s2 ? -1 : 1), c); + return +(-(r2 + (n2 - i2) / (s2 ? i2 - u2 : u2 - i2)) || 0); + }, a: function(t2) { + return t2 < 0 ? Math.ceil(t2) || 0 : Math.floor(t2); + }, p: function(t2) { + return { M: c, y: h, w: o, d: a, D: d, h: u, m: s, s: i, ms: r, Q: f }[t2] || String(t2 || "").toLowerCase().replace(/s$/, ""); + }, u: function(t2) { + return void 0 === t2; + } }, g = "en", D = {}; + D[g] = M; + var p = "$isDayjsObject", S = function(t2) { + return t2 instanceof _ || !(!t2 || !t2[p]); + }, w = function t2(e2, n2, r2) { + var i2; + if (!e2) return g; + if ("string" == typeof e2) { + var s2 = e2.toLowerCase(); + D[s2] && (i2 = s2), n2 && (D[s2] = n2, i2 = s2); + var u2 = e2.split("-"); + if (!i2 && u2.length > 1) return t2(u2[0]); + } else { + var a2 = e2.name; + D[a2] = e2, i2 = a2; + } + return !r2 && i2 && (g = i2), i2 || !r2 && g; + }, O = function(t2, e2) { + if (S(t2)) return t2.clone(); + var n2 = "object" == typeof e2 ? e2 : {}; + return n2.date = t2, n2.args = arguments, new _(n2); + }, b = v; + b.l = w, b.i = S, b.w = function(t2, e2) { + return O(t2, { locale: e2.$L, utc: e2.$u, x: e2.$x, $offset: e2.$offset }); + }; + var _ = (function() { + function M2(t2) { + this.$L = w(t2.locale, null, true), this.parse(t2), this.$x = this.$x || t2.x || {}, this[p] = true; + } + var m2 = M2.prototype; + return m2.parse = function(t2) { + this.$d = (function(t3) { + var e2 = t3.date, n2 = t3.utc; + if (null === e2) return /* @__PURE__ */ new Date(NaN); + if (b.u(e2)) return /* @__PURE__ */ new Date(); + if (e2 instanceof Date) return new Date(e2); + if ("string" == typeof e2 && !/Z$/i.test(e2)) { + var r2 = e2.match($); + if (r2) { + var i2 = r2[2] - 1 || 0, s2 = (r2[7] || "0").substring(0, 3); + return n2 ? new Date(Date.UTC(r2[1], i2, r2[3] || 1, r2[4] || 0, r2[5] || 0, r2[6] || 0, s2)) : new Date(r2[1], i2, r2[3] || 1, r2[4] || 0, r2[5] || 0, r2[6] || 0, s2); + } + } + return new Date(e2); + })(t2), this.init(); + }, m2.init = function() { + var t2 = this.$d; + this.$y = t2.getFullYear(), this.$M = t2.getMonth(), this.$D = t2.getDate(), this.$W = t2.getDay(), this.$H = t2.getHours(), this.$m = t2.getMinutes(), this.$s = t2.getSeconds(), this.$ms = t2.getMilliseconds(); + }, m2.$utils = function() { + return b; + }, m2.isValid = function() { + return !(this.$d.toString() === l); + }, m2.isSame = function(t2, e2) { + var n2 = O(t2); + return this.startOf(e2) <= n2 && n2 <= this.endOf(e2); + }, m2.isAfter = function(t2, e2) { + return O(t2) < this.startOf(e2); + }, m2.isBefore = function(t2, e2) { + return this.endOf(e2) < O(t2); + }, m2.$g = function(t2, e2, n2) { + return b.u(t2) ? this[e2] : this.set(n2, t2); + }, m2.unix = function() { + return Math.floor(this.valueOf() / 1e3); + }, m2.valueOf = function() { + return this.$d.getTime(); + }, m2.startOf = function(t2, e2) { + var n2 = this, r2 = !!b.u(e2) || e2, f2 = b.p(t2), l2 = function(t3, e3) { + var i2 = b.w(n2.$u ? Date.UTC(n2.$y, e3, t3) : new Date(n2.$y, e3, t3), n2); + return r2 ? i2 : i2.endOf(a); + }, $2 = function(t3, e3) { + return b.w(n2.toDate()[t3].apply(n2.toDate("s"), (r2 ? [0, 0, 0, 0] : [23, 59, 59, 999]).slice(e3)), n2); + }, y2 = this.$W, M3 = this.$M, m3 = this.$D, v2 = "set" + (this.$u ? "UTC" : ""); + switch (f2) { + case h: + return r2 ? l2(1, 0) : l2(31, 11); + case c: + return r2 ? l2(1, M3) : l2(0, M3 + 1); + case o: + var g2 = this.$locale().weekStart || 0, D2 = (y2 < g2 ? y2 + 7 : y2) - g2; + return l2(r2 ? m3 - D2 : m3 + (6 - D2), M3); + case a: + case d: + return $2(v2 + "Hours", 0); + case u: + return $2(v2 + "Minutes", 1); + case s: + return $2(v2 + "Seconds", 2); + case i: + return $2(v2 + "Milliseconds", 3); + default: + return this.clone(); + } + }, m2.endOf = function(t2) { + return this.startOf(t2, false); + }, m2.$set = function(t2, e2) { + var n2, o2 = b.p(t2), f2 = "set" + (this.$u ? "UTC" : ""), l2 = (n2 = {}, n2[a] = f2 + "Date", n2[d] = f2 + "Date", n2[c] = f2 + "Month", n2[h] = f2 + "FullYear", n2[u] = f2 + "Hours", n2[s] = f2 + "Minutes", n2[i] = f2 + "Seconds", n2[r] = f2 + "Milliseconds", n2)[o2], $2 = o2 === a ? this.$D + (e2 - this.$W) : e2; + if (o2 === c || o2 === h) { + var y2 = this.clone().set(d, 1); + y2.$d[l2]($2), y2.init(), this.$d = y2.set(d, Math.min(this.$D, y2.daysInMonth())).$d; + } else l2 && this.$d[l2]($2); + return this.init(), this; + }, m2.set = function(t2, e2) { + return this.clone().$set(t2, e2); + }, m2.get = function(t2) { + return this[b.p(t2)](); + }, m2.add = function(r2, f2) { + var d2, l2 = this; + r2 = Number(r2); + var $2 = b.p(f2), y2 = function(t2) { + var e2 = O(l2); + return b.w(e2.date(e2.date() + Math.round(t2 * r2)), l2); + }; + if ($2 === c) return this.set(c, this.$M + r2); + if ($2 === h) return this.set(h, this.$y + r2); + if ($2 === a) return y2(1); + if ($2 === o) return y2(7); + var M3 = (d2 = {}, d2[s] = e, d2[u] = n, d2[i] = t, d2)[$2] || 1, m3 = this.$d.getTime() + r2 * M3; + return b.w(m3, this); + }, m2.subtract = function(t2, e2) { + return this.add(-1 * t2, e2); + }, m2.format = function(t2) { + var e2 = this, n2 = this.$locale(); + if (!this.isValid()) return n2.invalidDate || l; + var r2 = t2 || "YYYY-MM-DDTHH:mm:ssZ", i2 = b.z(this), s2 = this.$H, u2 = this.$m, a2 = this.$M, o2 = n2.weekdays, c2 = n2.months, f2 = n2.meridiem, h2 = function(t3, n3, i3, s3) { + return t3 && (t3[n3] || t3(e2, r2)) || i3[n3].slice(0, s3); + }, d2 = function(t3) { + return b.s(s2 % 12 || 12, t3, "0"); + }, $2 = f2 || function(t3, e3, n3) { + var r3 = t3 < 12 ? "AM" : "PM"; + return n3 ? r3.toLowerCase() : r3; + }; + return r2.replace(y, (function(t3, r3) { + return r3 || (function(t4) { + switch (t4) { + case "YY": + return String(e2.$y).slice(-2); + case "YYYY": + return b.s(e2.$y, 4, "0"); + case "M": + return a2 + 1; + case "MM": + return b.s(a2 + 1, 2, "0"); + case "MMM": + return h2(n2.monthsShort, a2, c2, 3); + case "MMMM": + return h2(c2, a2); + case "D": + return e2.$D; + case "DD": + return b.s(e2.$D, 2, "0"); + case "d": + return String(e2.$W); + case "dd": + return h2(n2.weekdaysMin, e2.$W, o2, 2); + case "ddd": + return h2(n2.weekdaysShort, e2.$W, o2, 3); + case "dddd": + return o2[e2.$W]; + case "H": + return String(s2); + case "HH": + return b.s(s2, 2, "0"); + case "h": + return d2(1); + case "hh": + return d2(2); + case "a": + return $2(s2, u2, true); + case "A": + return $2(s2, u2, false); + case "m": + return String(u2); + case "mm": + return b.s(u2, 2, "0"); + case "s": + return String(e2.$s); + case "ss": + return b.s(e2.$s, 2, "0"); + case "SSS": + return b.s(e2.$ms, 3, "0"); + case "Z": + return i2; + } + return null; + })(t3) || i2.replace(":", ""); + })); + }, m2.utcOffset = function() { + return 15 * -Math.round(this.$d.getTimezoneOffset() / 15); + }, m2.diff = function(r2, d2, l2) { + var $2, y2 = this, M3 = b.p(d2), m3 = O(r2), v2 = (m3.utcOffset() - this.utcOffset()) * e, g2 = this - m3, D2 = function() { + return b.m(y2, m3); + }; + switch (M3) { + case h: + $2 = D2() / 12; + break; + case c: + $2 = D2(); + break; + case f: + $2 = D2() / 3; + break; + case o: + $2 = (g2 - v2) / 6048e5; + break; + case a: + $2 = (g2 - v2) / 864e5; + break; + case u: + $2 = g2 / n; + break; + case s: + $2 = g2 / e; + break; + case i: + $2 = g2 / t; + break; + default: + $2 = g2; + } + return l2 ? $2 : b.a($2); + }, m2.daysInMonth = function() { + return this.endOf(c).$D; + }, m2.$locale = function() { + return D[this.$L]; + }, m2.locale = function(t2, e2) { + if (!t2) return this.$L; + var n2 = this.clone(), r2 = w(t2, e2, true); + return r2 && (n2.$L = r2), n2; + }, m2.clone = function() { + return b.w(this.$d, this); + }, m2.toDate = function() { + return new Date(this.valueOf()); + }, m2.toJSON = function() { + return this.isValid() ? this.toISOString() : null; + }, m2.toISOString = function() { + return this.$d.toISOString(); + }, m2.toString = function() { + return this.$d.toUTCString(); + }, M2; + })(), k = _.prototype; + return O.prototype = k, [["$ms", r], ["$s", i], ["$m", s], ["$H", u], ["$W", a], ["$M", c], ["$y", h], ["$D", d]].forEach((function(t2) { + k[t2[1]] = function(e2) { + return this.$g(e2, t2[0], t2[1]); + }; + })), O.extend = function(t2, e2) { + return t2.$i || (t2(e2, _, O), t2.$i = true), O; + }, O.locale = w, O.isDayjs = S, O.unix = function(t2) { + return O(1e3 * t2); + }, O.en = D[g], O.Ls = D, O.p = {}, O; + })); + } +}); + +// node_modules/dayjs/plugin/utc.js +var require_utc = __commonJS({ + "node_modules/dayjs/plugin/utc.js"(exports, module) { + !(function(t, i) { + "object" == typeof exports && "undefined" != typeof module ? module.exports = i() : "function" == typeof define && define.amd ? define(i) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs_plugin_utc = i(); + })(exports, (function() { + "use strict"; + var t = "minute", i = /[+-]\d\d(?::?\d\d)?/g, e = /([+-]|\d\d)/g; + return function(s, f, n) { + var u = f.prototype; + n.utc = function(t2) { + var i2 = { date: t2, utc: true, args: arguments }; + return new f(i2); + }, u.utc = function(i2) { + var e2 = n(this.toDate(), { locale: this.$L, utc: true }); + return i2 ? e2.add(this.utcOffset(), t) : e2; + }, u.local = function() { + return n(this.toDate(), { locale: this.$L, utc: false }); + }; + var r = u.parse; + u.parse = function(t2) { + t2.utc && (this.$u = true), this.$utils().u(t2.$offset) || (this.$offset = t2.$offset), r.call(this, t2); + }; + var o = u.init; + u.init = function() { + if (this.$u) { + var t2 = this.$d; + this.$y = t2.getUTCFullYear(), this.$M = t2.getUTCMonth(), this.$D = t2.getUTCDate(), this.$W = t2.getUTCDay(), this.$H = t2.getUTCHours(), this.$m = t2.getUTCMinutes(), this.$s = t2.getUTCSeconds(), this.$ms = t2.getUTCMilliseconds(); + } else o.call(this); + }; + var a = u.utcOffset; + u.utcOffset = function(s2, f2) { + var n2 = this.$utils().u; + if (n2(s2)) return this.$u ? 0 : n2(this.$offset) ? a.call(this) : this.$offset; + if ("string" == typeof s2 && (s2 = (function(t2) { + void 0 === t2 && (t2 = ""); + var s3 = t2.match(i); + if (!s3) return null; + var f3 = ("" + s3[0]).match(e) || ["-", 0, 0], n3 = f3[0], u3 = 60 * +f3[1] + +f3[2]; + return 0 === u3 ? 0 : "+" === n3 ? u3 : -u3; + })(s2), null === s2)) return this; + var u2 = Math.abs(s2) <= 16 ? 60 * s2 : s2; + if (0 === u2) return this.utc(f2); + var r2 = this.clone(); + if (f2) return r2.$offset = u2, r2.$u = false, r2; + var o2 = this.$u ? this.toDate().getTimezoneOffset() : -1 * this.utcOffset(); + return (r2 = this.local().add(u2 + o2, t)).$offset = u2, r2.$x.$localOffset = o2, r2; + }; + var h = u.format; + u.format = function(t2) { + var i2 = t2 || (this.$u ? "YYYY-MM-DDTHH:mm:ss[Z]" : ""); + return h.call(this, i2); + }, u.valueOf = function() { + var t2 = this.$utils().u(this.$offset) ? 0 : this.$offset + (this.$x.$localOffset || this.$d.getTimezoneOffset()); + return this.$d.valueOf() - 6e4 * t2; + }, u.isUTC = function() { + return !!this.$u; + }, u.toISOString = function() { + return this.toDate().toISOString(); + }, u.toString = function() { + return this.toDate().toUTCString(); + }; + var l = u.toDate; + u.toDate = function(t2) { + return "s" === t2 && this.$offset ? n(this.format("YYYY-MM-DD HH:mm:ss:SSS")).toDate() : l.call(this); + }; + var c = u.diff; + u.diff = function(t2, i2, e2) { + if (t2 && this.$u === t2.$u) return c.call(this, t2, i2, e2); + var s2 = this.local(), f2 = n(t2).local(); + return c.call(s2, f2, i2, e2); + }; + }; + })); + } +}); + +// node_modules/dayjs/plugin/timezone.js +var require_timezone = __commonJS({ + "node_modules/dayjs/plugin/timezone.js"(exports, module) { + !(function(t, e) { + "object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs_plugin_timezone = e(); + })(exports, (function() { + "use strict"; + var t = { year: 0, month: 1, day: 2, hour: 3, minute: 4, second: 5 }, e = {}; + return function(n, i, o) { + var r, a = function(t2, n2, i2) { + void 0 === i2 && (i2 = {}); + var o2 = new Date(t2), r2 = (function(t3, n3) { + void 0 === n3 && (n3 = {}); + var i3 = n3.timeZoneName || "short", o3 = t3 + "|" + i3, r3 = e[o3]; + return r3 || (r3 = new Intl.DateTimeFormat("en-US", { hour12: false, timeZone: t3, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: i3 }), e[o3] = r3), r3; + })(n2, i2); + return r2.formatToParts(o2); + }, u = function(e2, n2) { + for (var i2 = a(e2, n2), r2 = [], u2 = 0; u2 < i2.length; u2 += 1) { + var f2 = i2[u2], s2 = f2.type, m = f2.value, c = t[s2]; + c >= 0 && (r2[c] = parseInt(m, 10)); + } + var d = r2[3], l = 24 === d ? 0 : d, h = r2[0] + "-" + r2[1] + "-" + r2[2] + " " + l + ":" + r2[4] + ":" + r2[5] + ":000", v = +e2; + return (o.utc(h).valueOf() - (v -= v % 1e3)) / 6e4; + }, f = i.prototype; + f.tz = function(t2, e2) { + void 0 === t2 && (t2 = r); + var n2, i2 = this.utcOffset(), a2 = this.toDate(), u2 = a2.toLocaleString("en-US", { timeZone: t2 }), f2 = Math.round((a2 - new Date(u2)) / 1e3 / 60), s2 = 15 * -Math.round(a2.getTimezoneOffset() / 15) - f2; + if (!Number(s2)) n2 = this.utcOffset(0, e2); + else if (n2 = o(u2, { locale: this.$L }).$set("millisecond", this.$ms).utcOffset(s2, true), e2) { + var m = n2.utcOffset(); + n2 = n2.add(i2 - m, "minute"); + } + return n2.$x.$timezone = t2, n2; + }, f.offsetName = function(t2) { + var e2 = this.$x.$timezone || o.tz.guess(), n2 = a(this.valueOf(), e2, { timeZoneName: t2 }).find((function(t3) { + return "timezonename" === t3.type.toLowerCase(); + })); + return n2 && n2.value; + }; + var s = f.startOf; + f.startOf = function(t2, e2) { + if (!this.$x || !this.$x.$timezone) return s.call(this, t2, e2); + var n2 = o(this.format("YYYY-MM-DD HH:mm:ss:SSS"), { locale: this.$L }); + return s.call(n2, t2, e2).tz(this.$x.$timezone, true); + }, o.tz = function(t2, e2, n2) { + var i2 = n2 && e2, a2 = n2 || e2 || r, f2 = u(+o(), a2); + if ("string" != typeof t2) return o(t2).tz(a2); + var s2 = (function(t3, e3, n3) { + var i3 = t3 - 60 * e3 * 1e3, o2 = u(i3, n3); + if (e3 === o2) return [i3, e3]; + var r2 = u(i3 -= 60 * (o2 - e3) * 1e3, n3); + return o2 === r2 ? [i3, o2] : [t3 - 60 * Math.min(o2, r2) * 1e3, Math.max(o2, r2)]; + })(o.utc(t2, i2).valueOf(), f2, a2), m = s2[0], c = s2[1], d = o(m).utcOffset(c); + return d.$x.$timezone = a2, d; + }, o.tz.guess = function() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + }, o.tz.setDefault = function(t2) { + r = t2; + }; + }; + })); + } +}); + +// node_modules/dayjs/plugin/isoWeek.js +var require_isoWeek = __commonJS({ + "node_modules/dayjs/plugin/isoWeek.js"(exports, module) { + !(function(e, t) { + "object" == typeof exports && "undefined" != typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define(t) : (e = "undefined" != typeof globalThis ? globalThis : e || self).dayjs_plugin_isoWeek = t(); + })(exports, (function() { + "use strict"; + var e = "day"; + return function(t, i, s) { + var a = function(t2) { + return t2.add(4 - t2.isoWeekday(), e); + }, d = i.prototype; + d.isoWeekYear = function() { + return a(this).year(); + }, d.isoWeek = function(t2) { + if (!this.$utils().u(t2)) return this.add(7 * (t2 - this.isoWeek()), e); + var i2, d2, n2, o, r = a(this), u = (i2 = this.isoWeekYear(), d2 = this.$u, n2 = (d2 ? s.utc : s)().year(i2).startOf("year"), o = 4 - n2.isoWeekday(), n2.isoWeekday() > 4 && (o += 7), n2.add(o, e)); + return r.diff(u, "week") + 1; + }, d.isoWeekday = function(e2) { + return this.$utils().u(e2) ? this.day() || 7 : this.day(this.day() % 7 ? e2 : e2 - 7); + }; + var n = d.startOf; + d.startOf = function(e2, t2) { + var i2 = this.$utils(), s2 = !!i2.u(t2) || t2; + return "isoweek" === i2.p(e2) ? s2 ? this.date(this.date() - (this.isoWeekday() - 1)).startOf("day") : this.date(this.date() - 1 - (this.isoWeekday() - 1) + 7).endOf("day") : n.bind(this)(e2, t2); + }; + }; + })); + } +}); + +// node_modules/@novadi/core/dist/token.js +var tokenCounter = 0; +function Token(description) { + const id = ++tokenCounter; + const sym = /* @__PURE__ */ Symbol(description ? `Token(${description})` : `Token#${id}`); + const token2 = { + symbol: sym, + description, + toString() { + return description ? `Token<${description}>` : `Token<#${id}>`; + } + }; + return token2; +} + +// node_modules/@novadi/core/dist/errors.js +var ContainerError = class extends Error { + constructor(message) { + super(message); + this.name = "ContainerError"; + } +}; +var BindingNotFoundError = class extends ContainerError { + constructor(tokenDescription, path = []) { + const pathStr = path.length > 0 ? ` + Dependency path: ${path.join(" -> ")}` : ""; + super(`Token "${tokenDescription}" is not bound or registered in the container.${pathStr}`); + this.name = "BindingNotFoundError"; + } +}; +var CircularDependencyError = class extends ContainerError { + constructor(path) { + super(`Circular dependency detected: ${path.join(" -> ")}`); + this.name = "CircularDependencyError"; + } +}; + +// node_modules/@novadi/core/dist/autowire.js +var paramNameCache = /* @__PURE__ */ new WeakMap(); +function extractParameterNames(constructor) { + const cached = paramNameCache.get(constructor); + if (cached) { + return cached; + } + const fnStr = constructor.toString(); + const match = fnStr.match(/constructor\s*\(([^)]*)\)/) || fnStr.match(/^[^(]*\(([^)]*)\)/); + if (!match || !match[1]) { + return []; + } + const params = match[1].split(",").map((param) => param.trim()).filter((param) => param.length > 0).map((param) => { + let name = param.split(/[:=]/)[0].trim(); + name = name.replace(/^((public|private|protected|readonly)\s+)+/, ""); + if (name.includes("{") || name.includes("[")) { + return null; + } + return name; + }).filter((name) => name !== null); + paramNameCache.set(constructor, params); + return params; +} +function resolveByMap(constructor, container, options) { + if (!options.map) { + throw new Error("AutoWire map strategy requires options.map to be defined"); + } + const paramNames = extractParameterNames(constructor); + const resolvedDeps = []; + for (const paramName of paramNames) { + const resolver = options.map[paramName]; + if (resolver === void 0) { + if (options.strict) { + throw new Error(`Cannot resolve parameter "${paramName}" on ${constructor.name}. Not found in autowire map. Add it to the map: .autoWire({ map: { ${paramName}: ... } })`); + } else { + resolvedDeps.push(void 0); + } + continue; + } + if (typeof resolver === "function") { + resolvedDeps.push(resolver(container)); + } else { + resolvedDeps.push(container.resolve(resolver)); + } + } + return resolvedDeps; +} +function resolveByMapResolvers(_constructor, container, options) { + if (!options.mapResolvers || options.mapResolvers.length === 0) { + return []; + } + const resolvedDeps = []; + for (let i = 0; i < options.mapResolvers.length; i++) { + const resolver = options.mapResolvers[i]; + if (resolver === void 0) { + resolvedDeps.push(void 0); + } else if (typeof resolver === "function") { + resolvedDeps.push(resolver(container)); + } else { + resolvedDeps.push(container.resolve(resolver)); + } + } + return resolvedDeps; +} +function autowire(constructor, container, options) { + const opts = { + by: "paramName", + strict: false, + ...options + }; + if (opts.mapResolvers && opts.mapResolvers.length > 0) { + return resolveByMapResolvers(constructor, container, opts); + } + if (opts.map && Object.keys(opts.map).length > 0) { + return resolveByMap(constructor, container, opts); + } + return []; +} + +// node_modules/@novadi/core/dist/builder.js +var RegistrationBuilder = class { + constructor(pending, registrations) { + this.registrations = registrations; + this.configs = []; + this.defaultLifetime = "singleton"; + this.pending = pending; + } + /** + * Bind this registration to a token or interface type + * + * @overload + * @param {Token} token - Explicit token for binding + * + * @overload + * @param {string} typeName - Interface type name (auto-generated by transformer) + */ + as(tokenOrTypeName) { + if (tokenOrTypeName && typeof tokenOrTypeName === "object" && "symbol" in tokenOrTypeName) { + const config = { + token: tokenOrTypeName, + type: this.pending.type, + value: this.pending.value, + factory: this.pending.factory, + constructor: this.pending.constructor, + lifetime: this.defaultLifetime + }; + this.configs.push(config); + this.registrations.push(config); + return this; + } else { + const config = { + token: null, + // Will be set during build() + type: this.pending.type, + value: this.pending.value, + factory: this.pending.factory, + constructor: this.pending.constructor, + lifetime: this.defaultLifetime, + interfaceType: tokenOrTypeName + }; + this.configs.push(config); + this.registrations.push(config); + return this; + } + } + /** + * Register as default implementation for an interface + * Combines as() + asDefault() + */ + asDefaultInterface(typeName) { + this.as("TInterface", typeName); + return this.asDefault(); + } + /** + * Register as a keyed interface implementation + * Combines as() + keyed() + */ + asKeyedInterface(key, typeName) { + this.as("TInterface", typeName); + return this.keyed(key); + } + /** + * Register as multiple implemented interfaces + */ + asImplementedInterfaces(tokens) { + if (tokens.length === 0) { + return this; + } + if (this.configs.length > 0) { + for (const config of this.configs) { + config.lifetime = "singleton"; + config.additionalTokens = config.additionalTokens || []; + config.additionalTokens.push(...tokens); + } + return this; + } + const firstConfig = { + token: tokens[0], + type: this.pending.type, + value: this.pending.value, + factory: this.pending.factory, + constructor: this.pending.constructor, + lifetime: "singleton" + }; + this.configs.push(firstConfig); + this.registrations.push(firstConfig); + for (let i = 1; i < tokens.length; i++) { + firstConfig.additionalTokens = firstConfig.additionalTokens || []; + firstConfig.additionalTokens.push(tokens[i]); + } + return this; + } + /** + * Set singleton lifetime (one instance for entire container) + */ + singleInstance() { + for (const config of this.configs) { + config.lifetime = "singleton"; + } + return this; + } + /** + * Set per-request lifetime (one instance per resolve call tree) + */ + instancePerRequest() { + for (const config of this.configs) { + config.lifetime = "per-request"; + } + return this; + } + /** + * Set transient lifetime (new instance every time) + * Alias for default behavior + */ + instancePerDependency() { + for (const config of this.configs) { + config.lifetime = "transient"; + } + return this; + } + /** + * Name this registration for named resolution + */ + named(name) { + for (const config of this.configs) { + config.name = name; + } + return this; + } + /** + * Key this registration for keyed resolution + */ + keyed(key) { + for (const config of this.configs) { + config.key = key; + } + return this; + } + /** + * Mark this as default registration + * Default registrations don't override existing ones + */ + asDefault() { + for (const config of this.configs) { + config.isDefault = true; + } + return this; + } + /** + * Only register if token not already registered + */ + ifNotRegistered() { + for (const config of this.configs) { + config.ifNotRegistered = true; + } + return this; + } + /** + * Specify parameter values for constructor (primitives and constants) + * Use this for non-DI parameters like strings, numbers, config values + */ + withParameters(parameters) { + for (const config of this.configs) { + config.parameterValues = parameters; + } + return this; + } + /** + * Enable automatic dependency injection (autowiring) + * Supports three strategies: paramName (default), map, and class + * + * @example + * ```ts + * // Strategy 1: paramName (default, requires non-minified code in dev) + * builder.registerType(EventBus).as().autoWire() + * + * // Strategy 2: map (minify-safe, explicit) + * builder.registerType(EventBus).as().autoWire({ + * map: { + * logger: (c) => c.resolveType() + * } + * }) + * + * // Strategy 3: class (requires build-time codegen) + * builder.registerType(EventBus).as().autoWire({ by: 'class' }) + * ``` + */ + autoWire(options) { + for (const config of this.configs) { + config.autowireOptions = options || { by: "paramName", strict: false }; + } + return this; + } +}; +var Builder = class { + constructor(baseContainer) { + this.baseContainer = baseContainer; + this.registrations = []; + } + /** + * Register a class constructor + */ + registerType(constructor) { + const pending = { + type: "type", + value: null, + constructor + }; + return new RegistrationBuilder(pending, this.registrations); + } + /** + * Register a pre-created instance + */ + registerInstance(instance) { + const pending = { + type: "instance", + value: instance, + constructor: void 0 + }; + return new RegistrationBuilder(pending, this.registrations); + } + /** + * Register a factory function + */ + register(factory) { + const pending = { + type: "factory", + value: null, + factory, + constructor: void 0 + }; + return new RegistrationBuilder(pending, this.registrations); + } + /** + * Register a module (function that adds multiple registrations) + */ + module(moduleFunc) { + moduleFunc(this); + return this; + } + /** + * Resolve interface type names to tokens + * @internal + */ + resolveInterfaceTokens(container) { + for (const config of this.registrations) { + if (config.interfaceType !== void 0 && !config.token) { + config.token = container.interfaceToken(config.interfaceType); + } + } + } + /** + * Identify tokens that have non-default registrations + * @internal + */ + identifyNonDefaultTokens() { + const tokensWithNonDefaults = /* @__PURE__ */ new Set(); + for (const config of this.registrations) { + if (!config.isDefault && !config.name && config.key === void 0) { + tokensWithNonDefaults.add(config.token); + } + } + return tokensWithNonDefaults; + } + /** + * Check if registration should be skipped + * @internal + */ + shouldSkipRegistration(config, tokensWithNonDefaults, registeredTokens) { + if (config.isDefault && !config.name && config.key === void 0 && tokensWithNonDefaults.has(config.token)) { + return true; + } + if (config.ifNotRegistered && registeredTokens.has(config.token)) { + return true; + } + if (config.isDefault && registeredTokens.has(config.token)) { + return true; + } + return false; + } + /** + * Create binding token for registration (named, keyed, or multi) + * @internal + */ + createBindingToken(config, namedRegistrations, keyedRegistrations, multiRegistrations) { + if (config.name) { + const bindingToken = Token(`__named_${config.name}`); + namedRegistrations.set(config.name, { ...config, token: bindingToken }); + return bindingToken; + } else if (config.key !== void 0) { + const keyStr = typeof config.key === "symbol" ? config.key.toString() : config.key; + const bindingToken = Token(`__keyed_${keyStr}`); + keyedRegistrations.set(config.key, { ...config, token: bindingToken }); + return bindingToken; + } else { + if (multiRegistrations.has(config.token)) { + const bindingToken = Token(`__multi_${config.token.toString()}_${multiRegistrations.get(config.token).length}`); + multiRegistrations.get(config.token).push(bindingToken); + return bindingToken; + } else { + multiRegistrations.set(config.token, [config.token]); + return config.token; + } + } + } + /** + * Register additional interfaces for a config + * @internal + */ + registerAdditionalInterfaces(container, config, bindingToken, registeredTokens) { + if (config.additionalTokens) { + for (const additionalToken of config.additionalTokens) { + container.bindFactory(additionalToken, (c) => c.resolve(bindingToken), { lifetime: config.lifetime }); + registeredTokens.add(additionalToken); + } + } + } + /** + * Build the container with all registered bindings + */ + build() { + const container = this.baseContainer.createChild(); + this.resolveInterfaceTokens(container); + const registeredTokens = /* @__PURE__ */ new Set(); + const namedRegistrations = /* @__PURE__ */ new Map(); + const keyedRegistrations = /* @__PURE__ */ new Map(); + const multiRegistrations = /* @__PURE__ */ new Map(); + const tokensWithNonDefaults = this.identifyNonDefaultTokens(); + for (const config of this.registrations) { + if (this.shouldSkipRegistration(config, tokensWithNonDefaults, registeredTokens)) { + continue; + } + const bindingToken = this.createBindingToken(config, namedRegistrations, keyedRegistrations, multiRegistrations); + this.applyRegistration(container, { ...config, token: bindingToken }); + registeredTokens.add(config.token); + this.registerAdditionalInterfaces(container, config, bindingToken, registeredTokens); + } + ; + container.__namedRegistrations = namedRegistrations; + container.__keyedRegistrations = keyedRegistrations; + container.__multiRegistrations = multiRegistrations; + return container; + } + /** + * Analyze constructor to detect dependencies + * @internal + */ + analyzeConstructor(constructor) { + const constructorStr = constructor.toString(); + const hasDependencies = /constructor\s*\([^)]+\)/.test(constructorStr); + return { hasDependencies }; + } + /** + * Create optimized factory for zero-dependency constructors + * @internal + */ + createOptimizedFactory(container, config, options) { + if (config.lifetime === "singleton") { + const instance = new config.constructor(); + container.bindValue(config.token, instance); + } else if (config.lifetime === "transient") { + const ctor = config.constructor; + const fastFactory = () => new ctor(); + container.fastTransientCache.set(config.token, fastFactory); + container.bindFactory(config.token, fastFactory, options); + } else { + const factory = () => new config.constructor(); + container.bindFactory(config.token, factory, options); + } + } + /** + * Create autowire factory + * @internal + */ + createAutoWireFactory(container, config, options) { + const factory = (c) => { + const resolvedDeps = autowire(config.constructor, c, config.autowireOptions); + return new config.constructor(...resolvedDeps); + }; + container.bindFactory(config.token, factory, options); + } + /** + * Create withParameters factory + * @internal + */ + createParameterFactory(container, config, options) { + const factory = () => { + const values = Object.values(config.parameterValues); + return new config.constructor(...values); + }; + container.bindFactory(config.token, factory, options); + } + /** + * Apply type registration (class constructor) + * @internal + */ + applyTypeRegistration(container, config, options) { + const { hasDependencies } = this.analyzeConstructor(config.constructor); + if (!hasDependencies && !config.autowireOptions && !config.parameterValues) { + this.createOptimizedFactory(container, config, options); + return; + } + if (config.autowireOptions) { + this.createAutoWireFactory(container, config, options); + return; + } + if (config.parameterValues) { + this.createParameterFactory(container, config, options); + return; + } + if (hasDependencies) { + const className = config.constructor.name || "UnnamedClass"; + throw new Error(`Service "${className}" has constructor dependencies but no autowiring configuration. + +Solutions: + 1. \u2B50 Use the NovaDI transformer (recommended): + - Add "@novadi/core/unplugin" to your build config + - Transformer automatically generates .autoWire() for all dependencies + + 2. Add manual autowiring: + .autoWire({ map: { /* param: resolver */ } }) + + 3. Use a factory function: + .register((c) => new ${className}(...)) + +See docs: https://github.com/janus007/NovaDI#autowire`); + } + const factory = () => new config.constructor(); + container.bindFactory(config.token, factory, options); + } + applyRegistration(container, config) { + const options = { lifetime: config.lifetime }; + switch (config.type) { + case "instance": + container.bindValue(config.token, config.value); + break; + case "factory": + container.bindFactory(config.token, config.factory, options); + break; + case "type": + this.applyTypeRegistration(container, config, options); + break; + } + } +}; + +// node_modules/@novadi/core/dist/container.js +function isDisposable(obj) { + return obj && typeof obj.dispose === "function"; +} +var ResolutionContext = class { + constructor() { + this.resolvingStack = /* @__PURE__ */ new Set(); + this.perRequestCache = /* @__PURE__ */ new Map(); + } + isResolving(token2) { + return this.resolvingStack.has(token2); + } + enterResolve(token2) { + this.resolvingStack.add(token2); + } + exitResolve(token2) { + this.resolvingStack.delete(token2); + this.path = void 0; + } + getPath() { + if (!this.path) { + this.path = Array.from(this.resolvingStack).map((t) => t.toString()); + } + return [...this.path]; + } + cachePerRequest(token2, instance) { + this.perRequestCache.set(token2, instance); + } + getPerRequest(token2) { + return this.perRequestCache.get(token2); + } + hasPerRequest(token2) { + return this.perRequestCache.has(token2); + } + /** + * Reset context for reuse in object pool + * Performance: Reusing contexts avoids heap allocations + */ + reset() { + this.resolvingStack.clear(); + this.perRequestCache.clear(); + this.path = void 0; + } +}; +var ResolutionContextPool = class { + constructor() { + this.pool = []; + this.maxSize = 10; + } + acquire() { + const context = this.pool.pop(); + if (context) { + context.reset(); + return context; + } + return new ResolutionContext(); + } + release(context) { + if (this.pool.length < this.maxSize) { + this.pool.push(context); + } + } +}; +var Container = class _Container { + constructor(parent) { + this.bindings = /* @__PURE__ */ new Map(); + this.singletonCache = /* @__PURE__ */ new Map(); + this.singletonOrder = []; + this.interfaceRegistry = /* @__PURE__ */ new Map(); + this.interfaceTokenCache = /* @__PURE__ */ new Map(); + this.fastTransientCache = /* @__PURE__ */ new Map(); + this.ultraFastSingletonCache = /* @__PURE__ */ new Map(); + this.parent = parent; + } + /** + * Bind a pre-created value to a token + */ + bindValue(token2, value) { + this.bindings.set(token2, { + type: "value", + lifetime: "singleton", + value, + constructor: void 0 + }); + this.invalidateBindingCache(); + } + /** + * Bind a factory function to a token + */ + bindFactory(token2, factory, options) { + this.bindings.set(token2, { + type: "factory", + lifetime: options?.lifetime || "transient", + factory, + dependencies: options?.dependencies, + constructor: void 0 + }); + this.invalidateBindingCache(); + } + /** + * Bind a class constructor to a token + */ + bindClass(token2, constructor, options) { + const binding = { + type: "class", + lifetime: options?.lifetime || "transient", + constructor, + dependencies: options?.dependencies + }; + this.bindings.set(token2, binding); + this.invalidateBindingCache(); + if (binding.lifetime === "transient" && (!binding.dependencies || binding.dependencies.length === 0)) { + this.fastTransientCache.set(token2, () => new constructor()); + } + } + /** + * Resolve a dependency synchronously + * Performance optimized with multiple fast paths + */ + resolve(token2) { + const cached = this.tryGetFromCaches(token2); + if (cached !== void 0) { + return cached; + } + if (this.currentContext) { + return this.resolveWithContext(token2, this.currentContext); + } + const context = _Container.contextPool.acquire(); + this.currentContext = context; + try { + return this.resolveWithContext(token2, context); + } finally { + this.currentContext = void 0; + _Container.contextPool.release(context); + } + } + /** + * SPECIALIZED: Ultra-fast singleton resolve (no safety checks) + * Use ONLY when you're 100% sure the token is a registered singleton + * @internal For performance-critical paths only + */ + resolveSingletonUnsafe(token2) { + return this.ultraFastSingletonCache.get(token2) ?? this.singletonCache.get(token2); + } + /** + * SPECIALIZED: Fast transient resolve for zero-dependency classes + * Skips all context creation and circular dependency checks + * @internal For performance-critical paths only + */ + resolveTransientSimple(token2) { + const factory = this.fastTransientCache.get(token2); + if (factory) { + return factory(); + } + return this.resolve(token2); + } + /** + * SPECIALIZED: Batch resolve multiple dependencies at once + * More efficient than multiple individual resolves + */ + resolveBatch(tokens) { + const wasResolving = !!this.currentContext; + const context = this.currentContext || _Container.contextPool.acquire(); + if (!wasResolving) { + this.currentContext = context; + } + try { + const results = tokens.map((token2) => { + const cached = this.tryGetFromCaches(token2); + if (cached !== void 0) + return cached; + return this.resolveWithContext(token2, context); + }); + return results; + } finally { + if (!wasResolving) { + this.currentContext = void 0; + _Container.contextPool.release(context); + } + } + } + /** + * Resolve a dependency asynchronously (supports async factories) + */ + async resolveAsync(token2) { + if (this.currentContext) { + return this.resolveAsyncWithContext(token2, this.currentContext); + } + const context = _Container.contextPool.acquire(); + this.currentContext = context; + try { + return await this.resolveAsyncWithContext(token2, context); + } finally { + this.currentContext = void 0; + _Container.contextPool.release(context); + } + } + /** + * Try to get instance from all cache levels + * Returns undefined if not cached + * @internal + */ + tryGetFromCaches(token2) { + const ultraFast = this.ultraFastSingletonCache.get(token2); + if (ultraFast !== void 0) { + return ultraFast; + } + if (this.singletonCache.has(token2)) { + const cached = this.singletonCache.get(token2); + this.ultraFastSingletonCache.set(token2, cached); + return cached; + } + const fastFactory = this.fastTransientCache.get(token2); + if (fastFactory) { + return fastFactory(); + } + return void 0; + } + /** + * Cache instance based on lifetime strategy + * @internal + */ + cacheInstance(token2, instance, lifetime, context) { + if (lifetime === "singleton") { + this.singletonCache.set(token2, instance); + this.singletonOrder.push(token2); + this.ultraFastSingletonCache.set(token2, instance); + } else if (lifetime === "per-request" && context) { + context.cachePerRequest(token2, instance); + } + } + /** + * Validate and get binding with circular dependency check + * Returns binding or throws error + * @internal + */ + validateAndGetBinding(token2, context) { + if (context.isResolving(token2)) { + throw new CircularDependencyError([...context.getPath(), token2.toString()]); + } + const binding = this.getBinding(token2); + if (!binding) { + throw new BindingNotFoundError(token2.toString(), context.getPath()); + } + return binding; + } + /** + * Instantiate from binding synchronously + * @internal + */ + instantiateBindingSync(binding, token2, context) { + switch (binding.type) { + case "value": + return binding.value; + case "factory": + const result = binding.factory(this); + if (result instanceof Promise) { + throw new Error(`Async factory detected for ${token2.toString()}. Use resolveAsync() instead.`); + } + return result; + case "class": + const deps = binding.dependencies || []; + const resolvedDeps = deps.map((dep) => this.resolveWithContext(dep, context)); + return new binding.constructor(...resolvedDeps); + case "inline-class": + return new binding.constructor(); + default: + throw new Error(`Unknown binding type: ${binding.type}`); + } + } + /** + * Instantiate from binding asynchronously + * @internal + */ + async instantiateBindingAsync(binding, context) { + switch (binding.type) { + case "value": + return binding.value; + case "factory": + return await Promise.resolve(binding.factory(this)); + case "class": + const deps = binding.dependencies || []; + const resolvedDeps = await Promise.all(deps.map((dep) => this.resolveAsyncWithContext(dep, context))); + return new binding.constructor(...resolvedDeps); + case "inline-class": + return new binding.constructor(); + default: + throw new Error(`Unknown binding type: ${binding.type}`); + } + } + /** + * Create a child container that inherits bindings from this container + */ + createChild() { + return new _Container(this); + } + /** + * Dispose all singleton instances in reverse registration order + */ + async dispose() { + const errors = []; + for (let i = this.singletonOrder.length - 1; i >= 0; i--) { + const token2 = this.singletonOrder[i]; + const instance = this.singletonCache.get(token2); + if (instance && isDisposable(instance)) { + try { + await instance.dispose(); + } catch (error) { + errors.push(error); + } + } + } + this.singletonCache.clear(); + this.singletonOrder.length = 0; + } + /** + * Create a fluent builder for registering dependencies + */ + builder() { + return new Builder(this); + } + /** + * Resolve a named service + */ + resolveNamed(name) { + const namedRegistrations = this.__namedRegistrations; + if (!namedRegistrations) { + throw new Error(`Named service "${name}" not found. No named registrations exist.`); + } + const config = namedRegistrations.get(name); + if (!config) { + throw new Error(`Named service "${name}" not found`); + } + return this.resolve(config.token); + } + /** + * Resolve a keyed service + */ + resolveKeyed(key) { + const keyedRegistrations = this.__keyedRegistrations; + if (!keyedRegistrations) { + throw new Error(`Keyed service not found. No keyed registrations exist.`); + } + const config = keyedRegistrations.get(key); + if (!config) { + const keyStr = typeof key === "symbol" ? key.toString() : `"${key}"`; + throw new Error(`Keyed service ${keyStr} not found`); + } + return this.resolve(config.token); + } + /** + * Resolve all registrations for a token + */ + resolveAll(token2) { + const multiRegistrations = this.__multiRegistrations; + if (!multiRegistrations) { + return []; + } + const tokens = multiRegistrations.get(token2); + if (!tokens || tokens.length === 0) { + return []; + } + return tokens.map((t) => this.resolve(t)); + } + /** + * Get registry information for debugging/visualization + * Returns array of binding information + */ + getRegistry() { + const registry = []; + this.bindings.forEach((binding, token2) => { + registry.push({ + token: token2.description || token2.symbol.toString(), + type: binding.type, + lifetime: binding.lifetime, + dependencies: binding.dependencies?.map((d) => d.description || d.symbol.toString()) + }); + }); + return registry; + } + /** + * Get or create a token for an interface type + * Uses a type name hash as key for the interface registry + */ + interfaceToken(typeName) { + const key = typeName || `Interface_${Math.random().toString(36).substr(2, 9)}`; + if (this.interfaceRegistry.has(key)) { + return this.interfaceRegistry.get(key); + } + if (this.parent) { + const parentToken = this.parent.interfaceToken(key); + return parentToken; + } + const token2 = Token(key); + this.interfaceRegistry.set(key, token2); + return token2; + } + /** + * Resolve a dependency by interface type without explicit token + */ + resolveType(typeName) { + const key = typeName || ""; + let token2 = this.interfaceTokenCache.get(key); + if (!token2) { + token2 = this.interfaceToken(typeName); + this.interfaceTokenCache.set(key, token2); + } + return this.resolve(token2); + } + /** + * Resolve a keyed interface + */ + resolveTypeKeyed(key, _typeName) { + return this.resolveKeyed(key); + } + /** + * Resolve all registrations for an interface type + */ + resolveTypeAll(typeName) { + const token2 = this.interfaceToken(typeName); + return this.resolveAll(token2); + } + /** + * Internal: Resolve with context for circular dependency detection + */ + resolveWithContext(token2, context) { + const binding = this.validateAndGetBinding(token2, context); + if (binding.lifetime === "per-request" && context.hasPerRequest(token2)) { + return context.getPerRequest(token2); + } + if (binding.lifetime === "singleton" && this.singletonCache.has(token2)) { + return this.singletonCache.get(token2); + } + context.enterResolve(token2); + try { + const instance = this.instantiateBindingSync(binding, token2, context); + this.cacheInstance(token2, instance, binding.lifetime, context); + return instance; + } finally { + context.exitResolve(token2); + } + } + /** + * Internal: Async resolve with context + */ + async resolveAsyncWithContext(token2, context) { + const binding = this.validateAndGetBinding(token2, context); + if (binding.lifetime === "per-request" && context.hasPerRequest(token2)) { + return context.getPerRequest(token2); + } + if (binding.lifetime === "singleton" && this.singletonCache.has(token2)) { + return this.singletonCache.get(token2); + } + context.enterResolve(token2); + try { + const instance = await this.instantiateBindingAsync(binding, context); + this.cacheInstance(token2, instance, binding.lifetime, context); + return instance; + } finally { + context.exitResolve(token2); + } + } + /** + * Get binding from this container or parent chain + * Performance optimized: Uses flat cache to avoid recursive parent lookups + */ + getBinding(token2) { + if (!this.bindingCache) { + this.buildBindingCache(); + } + return this.bindingCache.get(token2); + } + /** + * Build flat cache of all bindings including parent chain + * This converts O(n) parent chain traversal to O(1) lookup + */ + buildBindingCache() { + this.bindingCache = /* @__PURE__ */ new Map(); + let current = this; + while (current) { + current.bindings.forEach((binding, token2) => { + if (!this.bindingCache.has(token2)) { + this.bindingCache.set(token2, binding); + } + }); + current = current.parent; + } + } + /** + * Invalidate binding cache when new bindings are added + * Called by bindValue, bindFactory, bindClass + */ + invalidateBindingCache() { + this.bindingCache = void 0; + this.ultraFastSingletonCache.clear(); + } +}; +Container.contextPool = new ResolutionContextPool(); + +// node_modules/calendar/dist/chunk-OPIZ4QQE.js +var BaseGroupingRenderer = class { + /** + * Main render method - handles common logic + */ + async render(context) { + 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)); + this.renderHeader(entity, header, context); + context.headerContainer.appendChild(header); + } + } + /** + * Override this method for custom header rendering + * Default: just sets textContent to display name + */ + renderHeader(entity, header, _context) { + 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. + */ + createHeader(entity, context) { + const header = document.createElement(this.config.elementTag); + header.dataset[this.config.idAttribute] = entity.id; + this.renderHeader(entity, header, context); + return header; + } +}; + +// node_modules/calendar/dist/chunk-CMOI3H5F.js +var 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" +}; +var SyncPlugin = class { + constructor(service) { + this.service = service; + } + /** + * Mark entity as successfully synced + */ + async markAsSynced(id) { + 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) { + 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) { + const entity = await this.service.get(id); + return entity ? entity.syncStatus : null; + } + /** + * Get entities by sync status using IndexedDB index + */ + async getBySyncStatus(syncStatus) { + 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; + 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}`)); + }; + }); + } +}; +function arrayDifference(first, second) { + const secondSet = new Set(second); + return first.filter((item) => !secondSet.has(item)); +} +function arrayIntersection(first, second) { + const secondSet = new Set(second); + return first.filter((item) => secondSet.has(item)); +} +function keyBy(arr, getKey2) { + const result = {}; + for (const item of arr) { + result[String(getKey2(item))] = item; + } + return result; +} +function diff(oldObj, newObj, options = {}) { + let { embeddedObjKeys } = options; + const { keysToSkip, treatTypeChangeAsReplace } = options; + if (embeddedObjKeys instanceof Map) { + embeddedObjKeys = new Map( + Array.from(embeddedObjKeys.entries()).map(([key, value]) => [ + key instanceof RegExp ? key : key.replace(/^\./, ""), + value + ]) + ); + } else if (embeddedObjKeys) { + embeddedObjKeys = Object.fromEntries( + Object.entries(embeddedObjKeys).map(([key, value]) => [key.replace(/^\./, ""), value]) + ); + } + return compare(oldObj, newObj, [], [], { + embeddedObjKeys, + keysToSkip: keysToSkip ?? [], + treatTypeChangeAsReplace: treatTypeChangeAsReplace ?? true + }); +} +var getTypeOfObj = (obj) => { + if (typeof obj === "undefined") { + return "undefined"; + } + if (obj === null) { + return null; + } + return Object.prototype.toString.call(obj).match(/^\[object\s(.*)\]$/)[1]; +}; +var getKey = (path) => { + const left = path[path.length - 1]; + return left != null ? left : "$root"; +}; +var compare = (oldObj, newObj, path, keyPath, options) => { + let changes = []; + const currentPath = keyPath.join("."); + if (options.keysToSkip?.some((skipPath) => { + if (currentPath === skipPath) { + return true; + } + if (skipPath.includes(".") && skipPath.startsWith(currentPath + ".")) { + return false; + } + if (skipPath.includes(".")) { + const skipParts = skipPath.split("."); + const currentParts = currentPath.split("."); + if (currentParts.length >= skipParts.length) { + for (let i = 0; i < skipParts.length; i++) { + if (skipParts[i] !== currentParts[i]) { + return false; + } + } + return true; + } + } + return false; + })) { + return changes; + } + const typeOfOldObj = getTypeOfObj(oldObj); + const typeOfNewObj = getTypeOfObj(newObj); + if (options.treatTypeChangeAsReplace && typeOfOldObj !== typeOfNewObj) { + if (typeOfOldObj !== "undefined") { + changes.push({ type: "REMOVE", key: getKey(path), value: oldObj }); + } + if (typeOfNewObj !== "undefined") { + changes.push({ type: "ADD", key: getKey(path), value: newObj }); + } + return changes; + } + if (typeOfNewObj === "undefined" && typeOfOldObj !== "undefined") { + changes.push({ type: "REMOVE", key: getKey(path), value: oldObj }); + return changes; + } + if (typeOfNewObj === "Object" && typeOfOldObj === "Array") { + changes.push({ type: "UPDATE", key: getKey(path), value: newObj, oldValue: oldObj }); + return changes; + } + if (typeOfNewObj === null) { + if (typeOfOldObj !== null) { + changes.push({ type: "UPDATE", key: getKey(path), value: newObj, oldValue: oldObj }); + } + return changes; + } + switch (typeOfOldObj) { + case "Date": + if (typeOfNewObj === "Date") { + changes = changes.concat( + comparePrimitives(oldObj.getTime(), newObj.getTime(), path).map((x) => ({ + ...x, + value: new Date(x.value), + oldValue: new Date(x.oldValue) + })) + ); + } else { + changes = changes.concat(comparePrimitives(oldObj, newObj, path)); + } + break; + case "Object": { + const diffs = compareObject(oldObj, newObj, path, keyPath, false, options); + if (diffs.length) { + if (path.length) { + changes.push({ + type: "UPDATE", + key: getKey(path), + changes: diffs + }); + } else { + changes = changes.concat(diffs); + } + } + break; + } + case "Array": + changes = changes.concat(compareArray(oldObj, newObj, path, keyPath, options)); + break; + case "Function": + break; + default: + changes = changes.concat(comparePrimitives(oldObj, newObj, path)); + } + return changes; +}; +var compareObject = (oldObj, newObj, path, keyPath, skipPath = false, options = {}) => { + let k; + let newKeyPath; + let newPath; + if (skipPath == null) { + skipPath = false; + } + let changes = []; + const oldObjKeys = Object.keys(oldObj); + const newObjKeys = Object.keys(newObj); + const intersectionKeys = arrayIntersection(oldObjKeys, newObjKeys); + for (k of intersectionKeys) { + newPath = path.concat([k]); + newKeyPath = skipPath ? keyPath : keyPath.concat([k]); + const diffs = compare(oldObj[k], newObj[k], newPath, newKeyPath, options); + if (diffs.length) { + changes = changes.concat(diffs); + } + } + const addedKeys = arrayDifference(newObjKeys, oldObjKeys); + for (k of addedKeys) { + newPath = path.concat([k]); + newKeyPath = skipPath ? keyPath : keyPath.concat([k]); + const currentPath = newKeyPath.join("."); + if (options.keysToSkip?.some((skipPath2) => currentPath === skipPath2 || currentPath.startsWith(skipPath2 + "."))) { + continue; + } + changes.push({ + type: "ADD", + key: getKey(newPath), + value: newObj[k] + }); + } + const deletedKeys = arrayDifference(oldObjKeys, newObjKeys); + for (k of deletedKeys) { + newPath = path.concat([k]); + newKeyPath = skipPath ? keyPath : keyPath.concat([k]); + const currentPath = newKeyPath.join("."); + if (options.keysToSkip?.some((skipPath2) => currentPath === skipPath2 || currentPath.startsWith(skipPath2 + "."))) { + continue; + } + changes.push({ + type: "REMOVE", + key: getKey(newPath), + value: oldObj[k] + }); + } + return changes; +}; +var compareArray = (oldObj, newObj, path, keyPath, options) => { + if (getTypeOfObj(newObj) !== "Array") { + return [{ type: "UPDATE", key: getKey(path), value: newObj, oldValue: oldObj }]; + } + const left = getObjectKey(options.embeddedObjKeys, keyPath); + const uniqKey = left != null ? left : "$index"; + const indexedOldObj = convertArrayToObj(oldObj, uniqKey); + const indexedNewObj = convertArrayToObj(newObj, uniqKey); + const diffs = compareObject(indexedOldObj, indexedNewObj, path, keyPath, true, options); + if (diffs.length) { + return [ + { + type: "UPDATE", + key: getKey(path), + embeddedKey: typeof uniqKey === "function" && uniqKey.length === 2 ? uniqKey(newObj[0], true) : uniqKey, + changes: diffs + } + ]; + } else { + return []; + } +}; +var getObjectKey = (embeddedObjKeys, keyPath) => { + if (embeddedObjKeys != null) { + const path = keyPath.join("."); + if (embeddedObjKeys instanceof Map) { + for (const [key2, value] of embeddedObjKeys.entries()) { + if (key2 instanceof RegExp) { + if (path.match(key2)) { + return value; + } + } else if (path === key2) { + return value; + } + } + } + const key = embeddedObjKeys[path]; + if (key != null) { + return key; + } + } + return void 0; +}; +var convertArrayToObj = (arr, uniqKey) => { + let obj = {}; + if (uniqKey === "$value") { + arr.forEach((value) => { + obj[value] = value; + }); + } else if (uniqKey !== "$index") { + const keyFunction = typeof uniqKey === "string" ? (item) => item[uniqKey] : uniqKey; + obj = keyBy(arr, keyFunction); + } else { + for (let i = 0; i < arr.length; i++) { + const value = arr[i]; + obj[i] = value; + } + } + return obj; +}; +var comparePrimitives = (oldObj, newObj, path) => { + const changes = []; + if (oldObj !== newObj) { + changes.push({ + type: "UPDATE", + key: getKey(path), + value: newObj, + oldValue: oldObj + }); + } + return changes; +}; +var BaseEntityService = class { + constructor(context, eventBus) { + this.context = context; + this.eventBus = eventBus; + this.syncPlugin = new SyncPlugin(this); + } + get db() { + return this.context.getDatabase(); + } + /** + * Serialize entity before storing in IndexedDB + */ + serialize(entity) { + return entity; + } + /** + * Deserialize data from IndexedDB back to entity + */ + deserialize(data) { + return data; + } + /** + * Get a single entity by ID + */ + async get(id) { + 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() { + 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; + 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, silent = false) { + const entityId = entity.id; + const existingEntity = await this.get(entityId); + const isCreate = existingEntity === null; + let changes; + 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 = () => { + if (!silent) { + const payload = { + 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) { + 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 = { + 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) { + return this.syncPlugin.markAsSynced(id); + } + async markAsError(id) { + return this.syncPlugin.markAsError(id); + } + async getSyncStatus(id) { + return this.syncPlugin.getSyncStatus(id); + } + async getBySyncStatus(syncStatus) { + return this.syncPlugin.getBySyncStatus(syncStatus); + } +}; + +// node_modules/calendar/dist/index.js +var import_dayjs = __toESM(require_dayjs_min(), 1); +var import_utc = __toESM(require_utc(), 1); +var import_timezone = __toESM(require_timezone(), 1); +var import_isoWeek = __toESM(require_isoWeek(), 1); +var NavigationAnimator = class { + constructor(headerTrack, contentTrack, headerDrawer) { + this.headerTrack = headerTrack; + this.contentTrack = contentTrack; + this.headerDrawer = headerDrawer; + } + async slide(direction, renderFn) { + const out = direction === "left" ? "-100%" : "100%"; + const into = direction === "left" ? "100%" : "-100%"; + await this.animateOut(out); + await renderFn(); + await this.animateIn(into); + } + async animateOut(translate) { + 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); + } + async animateIn(translate) { + 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); + } +}; +var 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" +}; +var CalendarApp = class { + constructor(orchestrator, timeAxisRenderer, dateService, scrollManager, headerDrawerManager, dragDropManager, edgeScrollManager, resizeManager, headerDrawerRenderer, eventPersistenceManager, settingsService, viewConfigService, eventBus) { + this.orchestrator = orchestrator; + this.timeAxisRenderer = timeAxisRenderer; + this.dateService = dateService; + this.scrollManager = scrollManager; + this.headerDrawerManager = headerDrawerManager; + this.dragDropManager = dragDropManager; + this.edgeScrollManager = edgeScrollManager; + this.resizeManager = resizeManager; + this.headerDrawerRenderer = headerDrawerRenderer; + this.eventPersistenceManager = eventPersistenceManager; + this.settingsService = settingsService; + this.viewConfigService = viewConfigService; + this.eventBus = eventBus; + this.dayOffset = 0; + this.currentViewId = "simple"; + this.workweekPreset = null; + this.groupingOverrides = /* @__PURE__ */ new Map(); + } + async init(container) { + this.container = container; + const gridSettings = await this.settingsService.getGridSettings(); + if (!gridSettings) { + throw new Error("GridSettings not found"); + } + this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset(); + this.animator = new NavigationAnimator(container.querySelector("swp-header-track"), container.querySelector("swp-content-track"), container.querySelector("swp-header-drawer")); + this.timeAxisRenderer.render(container.querySelector("#time-axis"), gridSettings.dayStartHour, gridSettings.dayEndHour); + this.scrollManager.init(container); + this.headerDrawerManager.init(container); + this.dragDropManager.init(container); + this.resizeManager.init(container); + const scrollableContent = container.querySelector("swp-scrollable-content"); + this.edgeScrollManager.init(scrollableContent); + this.setupEventListeners(); + this.emitStatus("ready"); + } + setupEventListeners() { + this.eventBus.on(CalendarEvents.CMD_NAVIGATE_PREV, () => { + this.handleNavigatePrev(); + }); + this.eventBus.on(CalendarEvents.CMD_NAVIGATE_NEXT, () => { + this.handleNavigateNext(); + }); + this.eventBus.on(CalendarEvents.CMD_DRAWER_TOGGLE, () => { + this.headerDrawerManager.toggle(); + }); + this.eventBus.on(CalendarEvents.CMD_RENDER, (e) => { + const { viewId } = e.detail; + this.handleRenderCommand(viewId); + }); + this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e) => { + const { presetId } = e.detail; + this.handleWorkweekChange(presetId); + }); + this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e) => { + const { type, values } = e.detail; + this.handleViewUpdate(type, values); + }); + } + async handleRenderCommand(viewId) { + this.currentViewId = viewId; + await this.render(); + this.emitStatus("rendered", { viewId }); + } + async handleNavigatePrev() { + const step = this.workweekPreset?.periodDays ?? 7; + this.dayOffset -= step; + await this.animator.slide("right", () => this.render()); + this.emitStatus("rendered", { viewId: this.currentViewId }); + } + async handleNavigateNext() { + const step = this.workweekPreset?.periodDays ?? 7; + this.dayOffset += step; + await this.animator.slide("left", () => this.render()); + this.emitStatus("rendered", { viewId: this.currentViewId }); + } + async handleWorkweekChange(presetId) { + const preset = await this.settingsService.getWorkweekPreset(presetId); + if (preset) { + this.workweekPreset = preset; + await this.render(); + this.emitStatus("rendered", { viewId: this.currentViewId }); + } + } + async handleViewUpdate(type, values) { + this.groupingOverrides.set(type, values); + await this.render(); + this.emitStatus("rendered", { viewId: this.currentViewId }); + } + async render() { + const storedConfig = await this.viewConfigService.getById(this.currentViewId); + if (!storedConfig) { + this.emitStatus("error", { message: `ViewConfig not found: ${this.currentViewId}` }); + return; + } + const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5]; + const periodDays = this.workweekPreset?.periodDays ?? 7; + const dates = periodDays === 1 ? this.dateService.getDatesFromOffset(this.dayOffset, workDays.length) : this.dateService.getWorkDaysFromOffset(this.dayOffset, workDays); + const viewConfig = { + ...storedConfig, + groupings: storedConfig.groupings.map((g) => { + if (g.type === "date") { + return { ...g, values: dates }; + } + const override = this.groupingOverrides.get(g.type); + if (override) { + return { ...g, values: override }; + } + return g; + }) + }; + await this.orchestrator.render(viewConfig, this.container); + } + emitStatus(status, detail) { + this.container.dispatchEvent(new CustomEvent(`calendar:status:${status}`, { + detail, + bubbles: true + })); + } +}; +function buildPipeline(renderers) { + return { + async run(context) { + for (const renderer of renderers) { + await renderer.render(context); + } + } + }; +} +var FilterTemplate = class { + constructor(dateService, entityResolver) { + this.dateService = dateService; + this.entityResolver = entityResolver; + this.fields = []; + } + /** + * Tilføj felt til template + * @param idProperty - Property-navn (bruges på både event og column.dataset) + * @param derivedFrom - Hvis feltet udledes fra anden property (f.eks. date fra start) + */ + addField(idProperty, derivedFrom) { + this.fields.push({ idProperty, derivedFrom }); + return this; + } + /** + * Parse dot-notation string into components + * @example 'resource.teamId' → { entityType: 'resource', property: 'teamId', foreignKey: 'resourceId' } + */ + parseDotNotation(idProperty) { + 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 + */ + getDatasetKey(idProperty) { + const dotNotation = this.parseDotNotation(idProperty); + if (dotNotation) { + return dotNotation.property; + } + 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) { + 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) { + const eventRecord = event; + return this.fields.map((f) => { + const dotNotation = this.parseDotNotation(f.idProperty); + if (dotNotation) { + return this.resolveDotNotation(eventRecord, dotNotation); + } + if (f.derivedFrom) { + 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 + */ + resolveDotNotation(eventRecord, dotNotation) { + if (!this.entityResolver) { + console.warn(`FilterTemplate: EntityResolver required for dot-notation '${dotNotation.entityType}.${dotNotation.property}'`); + return ""; + } + const foreignId = eventRecord[dotNotation.foreignKey]; + if (!foreignId) + return ""; + const entity = this.entityResolver.resolve(dotNotation.entityType, String(foreignId)); + if (!entity) + return ""; + return String(entity[dotNotation.property] || ""); + } + /** + * Match event mod kolonne + */ + matches(event, column) { + return this.buildKeyFromEvent(event) === this.buildKeyFromColumn(column); + } +}; +var CalendarOrchestrator = class { + constructor(allRenderers, eventRenderer, scheduleRenderer, headerDrawerRenderer, dateService, entityServices) { + this.allRenderers = allRenderers; + this.eventRenderer = eventRenderer; + this.scheduleRenderer = scheduleRenderer; + this.headerDrawerRenderer = headerDrawerRenderer; + this.dateService = dateService; + this.entityServices = entityServices; + } + async render(viewConfig, container) { + const headerContainer = container.querySelector("swp-calendar-header"); + const columnContainer = container.querySelector("swp-day-columns"); + if (!headerContainer || !columnContainer) { + throw new Error("Missing swp-calendar-header or swp-day-columns"); + } + const filter = {}; + for (const grouping of viewConfig.groupings) { + filter[grouping.type] = grouping.values; + } + const filterTemplate = new FilterTemplate(this.dateService); + for (const grouping of viewConfig.groupings) { + if (grouping.idProperty) { + filterTemplate.addField(grouping.idProperty, grouping.derivedFrom); + } + } + const { parentChildMap, childType } = await this.resolveBelongsTo(viewConfig.groupings, filter); + const context = { headerContainer, columnContainer, filter, groupings: viewConfig.groupings, parentChildMap, childType }; + headerContainer.innerHTML = ""; + columnContainer.innerHTML = ""; + const levels = viewConfig.groupings.map((g) => g.type).join(" "); + headerContainer.dataset.levels = levels; + const activeRenderers = this.selectRenderers(viewConfig); + const pipeline = buildPipeline(activeRenderers); + await pipeline.run(context); + await this.scheduleRenderer.render(container, filter); + await this.eventRenderer.render(container, filter, filterTemplate); + await this.headerDrawerRenderer.render(container, filter, filterTemplate); + } + selectRenderers(viewConfig) { + const types = viewConfig.groupings.map((g) => g.type); + return types.map((type) => this.allRenderers.find((r) => r.type === type)).filter((r) => r !== void 0); + } + /** + * 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) + */ + async resolveBelongsTo(groupings, filter) { + const childGrouping = groupings.find((g) => g.belongsTo); + if (!childGrouping?.belongsTo) + return {}; + const [entityType, property] = childGrouping.belongsTo.split("."); + if (!entityType || !property) + return {}; + const parentIds = filter[entityType] || []; + if (parentIds.length === 0) + return {}; + const service = this.entityServices.find((s) => s.entityType.toLowerCase() === entityType); + if (!service) + return {}; + const allEntities = await service.getAll(); + const entities = allEntities.filter((e) => parentIds.includes(e.id)); + const map = {}; + for (const entity of entities) { + const entityRecord = entity; + const children = entityRecord[property] || []; + map[entityRecord.id] = children; + } + return { parentChildMap: map, childType: childGrouping.type }; + } +}; +var EventBus = class { + constructor() { + this.eventLog = []; + this.debug = false; + this.listeners = /* @__PURE__ */ new Set(); + this.logConfig = { + calendar: true, + grid: true, + event: true, + scroll: true, + navigation: true, + view: true, + default: true + }; + } + /** + * Subscribe to an event via DOM addEventListener + */ + on(eventType, handler, options) { + document.addEventListener(eventType, handler, options); + this.listeners.add({ eventType, handler, options }); + return () => this.off(eventType, handler); + } + /** + * Subscribe to an event once + */ + once(eventType, handler) { + return this.on(eventType, handler, { once: true }); + } + /** + * Unsubscribe from an event + */ + off(eventType, handler) { + document.removeEventListener(eventType, handler); + 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, detail = {}) { + if (!eventType) { + return false; + } + const event = new CustomEvent(eventType, { + detail: detail ?? {}, + bubbles: true, + cancelable: true + }); + if (this.debug) { + this.logEventWithGrouping(eventType, detail); + } + this.eventLog.push({ + type: eventType, + detail: detail ?? {}, + timestamp: Date.now() + }); + return !document.dispatchEvent(event); + } + /** + * Log event with console grouping + */ + logEventWithGrouping(eventType, _detail) { + const category = this.extractCategory(eventType); + if (!this.logConfig[category]) { + return; + } + this.getCategoryStyle(category); + } + /** + * Extract category from event type + */ + extractCategory(eventType) { + if (!eventType) { + return "unknown"; + } + if (eventType.includes(":")) { + return eventType.split(":")[0]; + } + 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 + */ + getCategoryStyle(category) { + const styles = { + calendar: { emoji: "\u{1F4C5}", color: "#2196F3" }, + grid: { emoji: "\u{1F4CA}", color: "#4CAF50" }, + event: { emoji: "\u{1F4CC}", color: "#FF9800" }, + scroll: { emoji: "\u{1F4DC}", color: "#9C27B0" }, + navigation: { emoji: "\u{1F9ED}", color: "#F44336" }, + view: { emoji: "\u{1F441}", color: "#00BCD4" }, + default: { emoji: "\u{1F4E2}", color: "#607D8B" } + }; + return styles[category] || styles.default; + } + /** + * Configure logging for specific categories + */ + setLogConfig(config) { + this.logConfig = { ...this.logConfig, ...config }; + } + /** + * Get current log configuration + */ + getLogConfig() { + return { ...this.logConfig }; + } + /** + * Get event history + */ + getEventLog(eventType) { + if (eventType) { + return this.eventLog.filter((e) => e.type === eventType); + } + return this.eventLog; + } + /** + * Enable/disable debug mode + */ + setDebug(enabled) { + this.debug = enabled; + } +}; +import_dayjs.default.extend(import_utc.default); +import_dayjs.default.extend(import_timezone.default); +import_dayjs.default.extend(import_isoWeek.default); +var DateService = class { + constructor(config, baseDate) { + this.config = config; + this.timezone = config.timezone; + this.baseDate = baseDate ? (0, import_dayjs.default)(baseDate) : (0, import_dayjs.default)(); + } + /** + * Set a fixed base date (useful for demos with static mock data) + */ + setBaseDate(date) { + this.baseDate = (0, import_dayjs.default)(date); + } + /** + * Get the current base date (either fixed or today) + */ + getBaseDate() { + return this.baseDate.toDate(); + } + parseISO(isoString) { + return (0, import_dayjs.default)(isoString).toDate(); + } + getDayName(date, format = "short") { + 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, count) { + 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, workDays) { + const targetDate = this.baseDate.add(dayOffset, "day"); + const monday = targetDate.startOf("week").add(1, "day"); + return workDays.map((isoDay) => { + 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) { + return this.getDatesFromOffset(weekOffset * 7, days); + } + getWorkWeekDates(weekOffset, workDays) { + return this.getWorkDaysFromOffset(weekOffset * 7, workDays); + } + // ============================================ + // FORMATTING + // ============================================ + formatTime(date, showSeconds = false) { + const pattern = showSeconds ? "HH:mm:ss" : "HH:mm"; + return (0, import_dayjs.default)(date).format(pattern); + } + formatTimeRange(start, end) { + return `${this.formatTime(start)} - ${this.formatTime(end)}`; + } + formatDate(date) { + return (0, import_dayjs.default)(date).format("YYYY-MM-DD"); + } + getDateKey(date) { + 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) { + 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) { + const parts = columnKey.split(":"); + return { + date: parts[0], + resource: parts[1] + }; + } + /** + * Extract dateKey from columnKey (first segment) + */ + getDateFromColumnKey(columnKey) { + return columnKey.split(":")[0]; + } + // ============================================ + // TIME CALCULATIONS + // ============================================ + timeToMinutes(timeString) { + const parts = timeString.split(":").map(Number); + const hours = parts[0] || 0; + const minutes = parts[1] || 0; + return hours * 60 + minutes; + } + minutesToTime(totalMinutes) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return (0, import_dayjs.default)().hour(hours).minute(minutes).format("HH:mm"); + } + getMinutesSinceMidnight(date) { + const d = (0, import_dayjs.default)(date); + return d.hour() * 60 + d.minute(); + } + // ============================================ + // UTC CONVERSIONS + // ============================================ + toUTC(localDate) { + return import_dayjs.default.tz(localDate, this.timezone).utc().toISOString(); + } + fromUTC(utcString) { + return import_dayjs.default.utc(utcString).tz(this.timezone).toDate(); + } + // ============================================ + // DATE CREATION + // ============================================ + createDateAtTime(baseDate, timeString) { + const totalMinutes = this.timeToMinutes(timeString); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return (0, import_dayjs.default)(baseDate).startOf("day").hour(hours).minute(minutes).toDate(); + } + getISOWeekDay(date) { + return (0, import_dayjs.default)(date).isoWeekday(); + } +}; +var ScrollManager = class { + init(container) { + 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()); + this.resizeObserver = new ResizeObserver(() => this.syncHeaderSpacerHeight()); + this.resizeObserver.observe(this.headerViewport); + this.syncHeaderSpacerHeight(); + } + syncHeaderSpacerHeight() { + const computedHeight = getComputedStyle(this.headerViewport).height; + this.headerSpacer.style.height = computedHeight; + } + onScroll() { + const { scrollTop, scrollLeft } = this.scrollableContent; + this.timeAxisContent.style.transform = `translateY(-${scrollTop}px)`; + this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`; + this.headerDrawer.style.transform = `translateX(-${scrollLeft}px)`; + } +}; +var HeaderDrawerManager = class { + constructor() { + this.expanded = false; + this.currentRows = 0; + this.rowHeight = 25; + this.duration = 200; + } + init(container) { + this.drawer = container.querySelector("swp-header-drawer"); + if (!this.drawer) + console.error("HeaderDrawerManager: swp-header-drawer not found"); + } + toggle() { + this.expanded ? this.collapse() : this.expand(); + } + /** + * Expand drawer to single row (legacy support) + */ + expand() { + this.expandToRows(1); + } + /** + * Expand drawer to fit specified number of rows + */ + expandToRows(rowCount) { + const targetHeight = rowCount * this.rowHeight; + const currentHeight = this.expanded ? this.currentRows * this.rowHeight : 0; + if (this.expanded && this.currentRows === rowCount) + return; + this.currentRows = rowCount; + this.expanded = true; + this.animate(currentHeight, targetHeight); + } + collapse() { + if (!this.expanded) + return; + const currentHeight = this.currentRows * this.rowHeight; + this.expanded = false; + this.currentRows = 0; + this.animate(currentHeight, 0); + } + animate(from, to) { + const keyframes = [ + { height: `${from}px` }, + { height: `${to}px` } + ]; + const options = { + duration: this.duration, + easing: "ease", + fill: "forwards" + }; + this.drawer.animate(keyframes, options); + } + isExpanded() { + return this.expanded; + } + getRowCount() { + return this.currentRows; + } +}; +var DateRenderer = class { + constructor(dateService) { + this.dateService = dateService; + this.type = "date"; + } + render(context) { + const dates = context.filter["date"] || []; + const resourceIds = context.filter["resource"] || []; + const dateGrouping = context.groupings?.find((g) => g.type === "date"); + const hideHeader = dateGrouping?.hideHeader === true; + const iterations = resourceIds.length || 1; + let columnCount = 0; + for (let r = 0; r < iterations; r++) { + const resourceId = resourceIds[r]; + for (const dateStr of dates) { + const date = this.dateService.parseISO(dateStr); + const segments = { date: dateStr }; + if (resourceId) + segments.resource = resourceId; + const columnKey = this.dateService.buildColumnKey(segments); + 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 = ` + ${this.dateService.getDayName(date, "short")} + ${date.getDate()} + `; + context.headerContainer.appendChild(header); + const column = document.createElement("swp-day-column"); + column.dataset.date = dateStr; + column.dataset.columnKey = columnKey; + if (resourceId) { + column.dataset.resourceId = resourceId; + } + column.innerHTML = ""; + context.columnContainer.appendChild(column); + columnCount++; + } + } + const container = context.columnContainer.closest("swp-calendar-container"); + if (container) { + container.style.setProperty("--grid-columns", String(columnCount)); + } + } +}; +var ResourceRenderer = class extends BaseGroupingRenderer { + constructor(resourceService) { + super(); + this.resourceService = resourceService; + this.type = "resource"; + this.config = { + elementTag: "swp-resource-header", + idAttribute: "resourceId", + colspanVar: "--resource-cols" + }; + } + getEntities(ids) { + return this.resourceService.getByIds(ids); + } + getDisplayName(entity) { + 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) { + const resourceIds = context.filter["resource"] || []; + const dateCount = context.filter["date"]?.length || 1; + let orderedResourceIds; + if (context.parentChildMap) { + 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); + 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); + } + } +}; +function calculateEventPosition(start, end, config) { + 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 }; +} +function minutesToPixels(minutes, config) { + return minutes / 60 * config.hourHeight; +} +function pixelsToMinutes(pixels, config) { + return pixels / config.hourHeight * 60; +} +function snapToGrid(pixels, config) { + const snapPixels = minutesToPixels(config.snapInterval, config); + return Math.round(pixels / snapPixels) * snapPixels; +} +function eventsOverlap(a, b) { + return a.start < b.end && a.end > b.start; +} +function eventsWithinThreshold(a, b, thresholdMinutes) { + const thresholdMs = thresholdMinutes * 60 * 1e3; + const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime()); + if (startToStartDiff <= thresholdMs) + return true; + const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime(); + if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) + return true; + const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime(); + if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) + return true; + return false; +} +function findOverlapGroups(events) { + if (events.length === 0) + return []; + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const used = /* @__PURE__ */ new Set(); + const groups = []; + for (const event of sorted) { + if (used.has(event.id)) + continue; + const group = [event]; + used.add(event.id); + let expanded = true; + while (expanded) { + expanded = false; + for (const candidate of sorted) { + if (used.has(candidate.id)) + continue; + const connects = group.some((member) => eventsOverlap(member, candidate)); + if (connects) { + group.push(candidate); + used.add(candidate.id); + expanded = true; + } + } + } + groups.push(group); + } + return groups; +} +function findGridCandidates(events, thresholdMinutes) { + if (events.length === 0) + return []; + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const used = /* @__PURE__ */ new Set(); + const groups = []; + for (const event of sorted) { + if (used.has(event.id)) + continue; + const group = [event]; + used.add(event.id); + 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; +} +function calculateStackLevels(events) { + const levels = /* @__PURE__ */ new Map(); + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + for (const event of sorted) { + let maxOverlappingLevel = -1; + 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; +} +function allocateColumns(events) { + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const columns = []; + for (const event of sorted) { + let placed = false; + for (const column of columns) { + const canFit = !column.some((e) => eventsOverlap(event, e)); + if (canFit) { + column.push(event); + placed = true; + break; + } + } + if (!placed) { + columns.push([event]); + } + } + return columns; +} +function calculateColumnLayout(events, config) { + const thresholdMinutes = config.gridStartThresholdMinutes ?? 10; + const result = { + grids: [], + stacked: [] + }; + if (events.length === 0) + return result; + const overlapGroups = findOverlapGroups(events); + for (const overlapGroup of overlapGroups) { + if (overlapGroup.length === 1) { + result.stacked.push({ + event: overlapGroup[0], + stackLevel: 0 + }); + continue; + } + const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes); + const largestGridCandidate = gridSubgroups.reduce((max, g) => g.length > max.length ? g : max, gridSubgroups[0]); + if (largestGridCandidate.length === overlapGroup.length) { + 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 { + const levels = calculateStackLevels(overlapGroup); + for (const event of overlapGroup) { + result.stacked.push({ + event, + stackLevel: levels.get(event.id) ?? 0 + }); + } + } + } + return result; +} +var EventRenderer = class { + constructor(eventService, dateService, gridConfig, eventBus) { + this.eventService = eventService; + this.dateService = dateService; + this.gridConfig = gridConfig; + this.eventBus = eventBus; + this.container = null; + this.setupListeners(); + } + /** + * Setup listeners for drag-drop and update events + */ + setupListeners() { + this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => { + const payload = e.detail; + this.handleColumnChange(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => { + const payload = e.detail; + this.updateDragTimestamp(payload); + }); + this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => { + const payload = e.detail; + this.handleEventUpdated(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { + const payload = e.detail; + this.handleDragEnd(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = e.detail; + this.handleDragLeaveHeader(payload); + }); + } + /** + * Handle EVENT_DRAG_END - remove element if dropped in header + */ + handleDragEnd(payload) { + if (payload.target === "header") { + 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 + */ + handleDragLeaveHeader(payload) { + if (payload.source !== "header") + return; + if (!payload.targetColumn || !payload.start || !payload.end) + return; + if (payload.element) { + payload.element.classList.add("drag-ghost"); + payload.element.style.opacity = "0.3"; + payload.element.style.pointerEvents = "none"; + } + const event = { + id: payload.eventId, + title: payload.title || "", + description: "", + start: payload.start, + end: payload.end, + type: "customer", + allDay: false, + syncStatus: "pending" + }; + const element = this.createEventElement(event); + let eventsLayer = payload.targetColumn.querySelector("swp-events-layer"); + if (!eventsLayer) { + eventsLayer = document.createElement("swp-events-layer"); + payload.targetColumn.appendChild(eventsLayer); + } + eventsLayer.appendChild(element); + element.classList.add("dragging"); + } + /** + * Handle EVENT_UPDATED - re-render affected columns + */ + async handleEventUpdated(payload) { + if (payload.sourceColumnKey !== payload.targetColumnKey) { + await this.rerenderColumn(payload.sourceColumnKey); + } + await this.rerenderColumn(payload.targetColumnKey); + } + /** + * Re-render a single column with fresh data from IndexedDB + */ + async rerenderColumn(columnKey) { + const column = this.findColumn(columnKey); + if (!column) + return; + const date = column.dataset.date; + const resourceId = column.dataset.resourceId; + if (!date) + return; + const startDate = new Date(date); + const endDate = new Date(date); + endDate.setHours(23, 59, 59, 999); + const events = resourceId ? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate) : await this.eventService.getByDateRange(startDate, endDate); + const timedEvents = events.filter((event) => !event.allDay && this.dateService.getDateKey(event.start) === date); + let eventsLayer = column.querySelector("swp-events-layer"); + if (!eventsLayer) { + eventsLayer = document.createElement("swp-events-layer"); + column.appendChild(eventsLayer); + } + eventsLayer.innerHTML = ""; + const layout = calculateColumnLayout(timedEvents, this.gridConfig); + layout.grids.forEach((grid) => { + const groupEl = this.renderGridGroup(grid); + eventsLayer.appendChild(groupEl); + }); + layout.stacked.forEach((item) => { + const eventEl = this.renderStackedEvent(item.event, item.stackLevel); + eventsLayer.appendChild(eventEl); + }); + } + /** + * Find a column element by columnKey + */ + findColumn(columnKey) { + if (!this.container) + return null; + return this.container.querySelector(`swp-day-column[data-column-key="${columnKey}"]`); + } + /** + * Handle event moving to a new column during drag + */ + handleColumnChange(payload) { + const eventsLayer = payload.newColumn.querySelector("swp-events-layer"); + if (!eventsLayer) + return; + eventsLayer.appendChild(payload.element); + payload.element.style.top = `${payload.currentY}px`; + } + /** + * Update timestamp display during drag (snapped to grid) + */ + updateDragTimestamp(payload) { + const timeEl = payload.element.querySelector("swp-event-time"); + if (!timeEl) + return; + const snappedY = snapToGrid(payload.currentY, this.gridConfig); + const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig); + const startMinutes = this.gridConfig.dayStartHour * 60 + minutesFromGridStart; + const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight; + const durationMinutes = pixelsToMinutes(height, this.gridConfig); + 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) + */ + minutesToDate(minutes) { + const date = /* @__PURE__ */ 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, filter, filterTemplate) { + this.container = container; + const visibleDates = filter["date"] || []; + if (visibleDates.length === 0) + return; + 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); + const dayColumns = container.querySelector("swp-day-columns"); + if (!dayColumns) + return; + const columns = dayColumns.querySelectorAll("swp-day-column"); + columns.forEach((column) => { + const columnEl = column; + const columnEvents = events.filter((event) => filterTemplate.matches(event, columnEl)); + let eventsLayer = column.querySelector("swp-events-layer"); + if (!eventsLayer) { + eventsLayer = document.createElement("swp-events-layer"); + column.appendChild(eventsLayer); + } + eventsLayer.innerHTML = ""; + const timedEvents = columnEvents.filter((event) => !event.allDay); + const layout = calculateColumnLayout(timedEvents, this.gridConfig); + layout.grids.forEach((grid) => { + const groupEl = this.renderGridGroup(grid); + eventsLayer.appendChild(groupEl); + }); + 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 + */ + createEventElement(event) { + const element = document.createElement("swp-event"); + element.dataset.eventId = event.id; + if (event.resourceId) { + element.dataset.resourceId = event.resourceId; + } + const position = calculateEventPosition(event.start, event.end, this.gridConfig); + element.style.top = `${position.top}px`; + element.style.height = `${position.height}px`; + const colorClass = this.getColorClass(event); + if (colorClass) { + element.classList.add(colorClass); + } + element.innerHTML = ` + ${this.dateService.formatTimeRange(event.start, event.end)} + ${this.escapeHtml(event.title)} + ${event.description ? `${this.escapeHtml(event.description)}` : ""} + `; + return element; + } + /** + * Get color class based on metadata.color or event type + */ + getColorClass(event) { + if (event.metadata?.color) { + return `is-${event.metadata.color}`; + } + const typeColors = { + "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 + */ + escapeHtml(text) { + 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 + */ + renderGridGroup(layout) { + const group = document.createElement("swp-event-group"); + group.classList.add(`cols-${layout.columns.length}`); + group.style.top = `${layout.position.top}px`; + if (layout.stackLevel > 0) { + group.style.marginLeft = `${layout.stackLevel * 15}px`; + group.style.zIndex = `${100 + layout.stackLevel}`; + } + 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`; + layout.columns.forEach((columnEvents) => { + const wrapper = document.createElement("div"); + wrapper.style.position = "relative"; + columnEvents.forEach((event) => { + const eventEl = this.createEventElement(event); + 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 + */ + renderStackedEvent(event, stackLevel) { + const element = this.createEventElement(event); + element.dataset.stackLink = JSON.stringify({ stackLevel }); + if (stackLevel > 0) { + element.style.marginLeft = `${stackLevel * 15}px`; + element.style.zIndex = `${100 + stackLevel}`; + } + return element; + } +}; +var TimeAxisRenderer = class { + render(container, startHour = 6, endHour = 20) { + 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); + } + } +}; +var HeaderDrawerRenderer = class { + constructor(eventBus, gridConfig, headerDrawerManager, eventService, dateService) { + this.eventBus = eventBus; + this.gridConfig = gridConfig; + this.headerDrawerManager = headerDrawerManager; + this.eventService = eventService; + this.dateService = dateService; + this.currentItem = null; + this.container = null; + this.sourceElement = null; + this.wasExpandedBeforeDrag = false; + this.filterTemplate = null; + this.setupListeners(); + } + /** + * Render allDay events into the header drawer with row stacking + * @param filterTemplate - Template for matching events to columns + */ + async render(container, filter, filterTemplate) { + this.filterTemplate = filterTemplate; + const drawer = container.querySelector("swp-header-drawer"); + if (!drawer) + return; + const visibleDates = filter["date"] || []; + if (visibleDates.length === 0) + return; + const visibleColumnKeys = this.getVisibleColumnKeysFromDOM(); + if (visibleColumnKeys.length === 0) + return; + 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); + const allDayEvents = events.filter((event) => event.allDay !== false); + drawer.innerHTML = ""; + if (allDayEvents.length === 0) + return; + const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys); + const rowCount = Math.max(1, ...layouts.map((l) => l.row)); + layouts.forEach((layout) => { + const item = this.createHeaderItem(layout); + drawer.appendChild(item); + }); + this.headerDrawerManager.expandToRows(rowCount); + } + /** + * Create a header item element from layout + */ + createHeaderItem(layout) { + 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; + const colorClass = this.getColorClass(event); + if (colorClass) + item.classList.add(colorClass); + 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 + */ + calculateLayout(events, visibleColumnKeys) { + const tracks = [new Array(visibleColumnKeys.length).fill(false)]; + const layouts = []; + for (const event of events) { + 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; + const colStart = Math.max(0, startCol); + const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.length - 1) + 1; + const row = this.findAvailableRow(tracks, colStart, colEnd); + 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 + */ + buildColumnKeyFromEvent(event, date) { + if (!this.filterTemplate) { + const dateStr = this.dateService.getDateKey(date || event.start); + return dateStr; + } + if (date && date.getTime() !== event.start.getTime()) { + const tempEvent = { ...event, start: date }; + return this.filterTemplate.buildKeyFromEvent(tempEvent); + } + return this.filterTemplate.buildKeyFromEvent(event); + } + /** + * Find available row for event spanning columns [colStart, colEnd) + */ + findAvailableRow(tracks, colStart, colEnd) { + 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; + } + tracks.push(new Array(tracks[0].length).fill(false)); + return tracks.length - 1; + } + /** + * Get color class based on event metadata or type + */ + getColorClass(event) { + if (event.metadata?.color) { + return `is-${event.metadata.color}`; + } + const typeColors = { + "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 + */ + setupListeners() { + this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => { + const payload = e.detail; + this.handleDragEnter(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => { + const payload = e.detail; + this.handleDragMove(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = e.detail; + this.handleDragLeave(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { + const payload = e.detail; + this.handleDragEnd(payload); + }); + this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => { + this.cleanup(); + }); + } + /** + * Handle drag entering header zone - create preview item + */ + handleDragEnter(payload) { + this.container = document.querySelector("swp-header-drawer"); + if (!this.container) + return; + this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded(); + if (!this.wasExpandedBeforeDrag) { + this.headerDrawerManager.expandToRows(1); + } + this.sourceElement = payload.element; + 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; + if (payload.colorClass) { + item.classList.add(payload.colorClass); + } + item.classList.add("dragging"); + const col = payload.sourceColumnIndex + 1; + const endCol = col + payload.duration; + item.style.gridArea = `1 / ${col} / 2 / ${endCol}`; + this.container.appendChild(item); + this.currentItem = item; + payload.element.style.visibility = "hidden"; + } + /** + * Handle drag moving within header - update column position + */ + handleDragMove(payload) { + if (!this.currentItem) + return; + 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}`; + this.currentItem.dataset.columnKey = payload.columnKey; + } + /** + * Handle drag leaving header - cleanup for grid→header drag only + */ + handleDragLeave(payload) { + if (payload.source === "grid") { + this.cleanup(); + } + } + /** + * Handle drag end - finalize based on drop target + */ + handleDragEnd(payload) { + if (payload.target === "header") { + if (this.currentItem) { + this.currentItem.classList.remove("dragging"); + this.recalculateDrawerLayout(); + this.currentItem = null; + this.sourceElement = null; + } + } else { + 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 + */ + recalculateDrawerLayout() { + const drawer = document.querySelector("swp-header-drawer"); + if (!drawer) + return; + const items = Array.from(drawer.querySelectorAll("swp-header-item")); + if (items.length === 0) + return; + const visibleColumnKeys = this.getVisibleColumnKeysFromDOM(); + if (visibleColumnKeys.length === 0) + return; + const itemData = items.map((item) => ({ + element: item, + columnKey: item.dataset.columnKey || "", + duration: parseInt(item.dataset.duration || "1", 10) + })); + const tracks = [new Array(visibleColumnKeys.length).fill(false)]; + for (const item of itemData) { + 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; + } + item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`; + } + 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 + */ + getVisibleColumnKeysFromDOM() { + if (!this.filterTemplate) + return []; + const columns = document.querySelectorAll("swp-day-column"); + const columnKeys = []; + columns.forEach((col) => { + const columnKey = this.filterTemplate.buildKeyFromColumn(col); + if (columnKey) + columnKeys.push(columnKey); + }); + return columnKeys; + } + /** + * Cleanup preview item and restore source visibility + */ + cleanup() { + this.currentItem?.remove(); + this.currentItem = null; + if (this.sourceElement) { + this.sourceElement.style.visibility = ""; + this.sourceElement = null; + } + if (!this.wasExpandedBeforeDrag) { + this.headerDrawerManager.collapse(); + } + } +}; +var ScheduleRenderer = class { + constructor(scheduleService, dateService, gridConfig) { + this.scheduleService = scheduleService; + this.dateService = dateService; + this.gridConfig = gridConfig; + } + /** + * Render unavailable zones for visible columns + * @param container - Calendar container element + * @param filter - Filter with 'date' and 'resource' arrays + */ + async render(container, filter) { + const dates = filter["date"] || []; + const resourceIds = filter["resource"] || []; + if (dates.length === 0) + return; + 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.dataset.date; + const resourceId = column.dataset.resourceId; + if (!date || !resourceId) + continue; + let unavailableLayer = column.querySelector("swp-unavailable-layer"); + if (!unavailableLayer) { + unavailableLayer = document.createElement("swp-unavailable-layer"); + column.insertBefore(unavailableLayer, column.firstChild); + } + unavailableLayer.innerHTML = ""; + const schedule = await this.scheduleService.getScheduleForDate(resourceId, date); + this.renderUnavailableZones(unavailableLayer, schedule); + } + } + /** + * Render unavailable time zones based on schedule + */ + renderUnavailableZones(layer, schedule) { + const dayStartMinutes = this.gridConfig.dayStartHour * 60; + const dayEndMinutes = this.gridConfig.dayEndHour * 60; + const minuteHeight = this.gridConfig.hourHeight / 60; + if (schedule === null) { + 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); + if (workStartMinutes > dayStartMinutes) { + const top = 0; + const height = (workStartMinutes - dayStartMinutes) * minuteHeight; + const zone = this.createUnavailableZone(top, height); + layer.appendChild(zone); + } + 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 + */ + createUnavailableZone(top, height) { + const zone = document.createElement("swp-unavailable-zone"); + zone.style.top = `${top}px`; + zone.style.height = `${height}px`; + return zone; + } +}; +var defaultDBConfig = { + dbName: "CalendarDB", + dbVersion: 4 +}; +var IndexedDBContext = class { + constructor(stores, config) { + this.db = null; + this.initialized = false; + this.stores = stores; + this.config = config; + } + get dbName() { + return this.config.dbName; + } + /** + * Initialize and open the database + */ + async initialize() { + 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.result; + this.stores.forEach((store) => { + if (!db.objectStoreNames.contains(store.storeName)) { + store.create(db); + } + }); + }; + }); + } + /** + * Check if database is initialized + */ + isInitialized() { + return this.initialized; + } + /** + * Get IDBDatabase instance + */ + getDatabase() { + if (!this.db) { + throw new Error("IndexedDB not initialized. Call initialize() first."); + } + return this.db; + } + /** + * Close database connection + */ + close() { + if (this.db) { + this.db.close(); + this.db = null; + this.initialized = false; + } + } + /** + * Delete entire database (for testing/reset) + */ + static async deleteDatabase(dbName = defaultDBConfig.dbName) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`)); + }); + } +}; +var EventStore = class _EventStore { + constructor() { + this.storeName = _EventStore.STORE_NAME; + } + /** + * Create the events ObjectStore with indexes + */ + create(db) { + const store = db.createObjectStore(_EventStore.STORE_NAME, { keyPath: "id" }); + store.createIndex("start", "start", { unique: false }); + store.createIndex("end", "end", { unique: false }); + store.createIndex("syncStatus", "syncStatus", { unique: false }); + store.createIndex("resourceId", "resourceId", { unique: false }); + store.createIndex("customerId", "customerId", { unique: false }); + store.createIndex("bookingId", "bookingId", { unique: false }); + store.createIndex("startEnd", ["start", "end"], { unique: false }); + } +}; +EventStore.STORE_NAME = "events"; +var EventSerialization = class { + /** + * Serialize event for IndexedDB storage + */ + static serialize(event) { + 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) { + return { + ...data, + start: typeof data.start === "string" ? new Date(data.start) : data.start, + end: typeof data.end === "string" ? new Date(data.end) : data.end + }; + } +}; +var EventService = class extends BaseEntityService { + constructor(context, eventBus) { + super(context, eventBus); + this.storeName = EventStore.STORE_NAME; + this.entityType = "Event"; + } + serialize(event) { + return EventSerialization.serialize(event); + } + deserialize(data) { + return EventSerialization.deserialize(data); + } + /** + * Get events within a date range + */ + async getByDateRange(start, end) { + 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; + 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) { + 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; + 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, start, end) { + const resourceEvents = await this.getByResource(resourceId); + return resourceEvents.filter((event) => event.start >= start && event.start <= end); + } +}; +var ResourceStore = class _ResourceStore { + constructor() { + this.storeName = _ResourceStore.STORE_NAME; + } + create(db) { + 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 }); + } +}; +ResourceStore.STORE_NAME = "resources"; +var ResourceService = class extends BaseEntityService { + constructor(context, eventBus) { + super(context, eventBus); + this.storeName = ResourceStore.STORE_NAME; + this.entityType = "Resource"; + } + /** + * Get all active resources + */ + async getActive() { + const all = await this.getAll(); + return all.filter((r) => r.isActive !== false); + } + /** + * Get resources by IDs + */ + async getByIds(ids) { + if (ids.length === 0) + return []; + const results = await Promise.all(ids.map((id) => this.get(id))); + return results.filter((r) => r !== null); + } + /** + * Get resources by type + */ + async getByType(type) { + 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; + resolve(data); + }; + request.onerror = () => { + reject(new Error(`Failed to get resources by type ${type}: ${request.error}`)); + }; + }); + } +}; +var SettingsIds = { + WORKWEEK: "workweek", + GRID: "grid", + TIME_FORMAT: "timeFormat", + VIEWS: "views" +}; +var SettingsStore = class _SettingsStore { + constructor() { + this.storeName = _SettingsStore.STORE_NAME; + } + create(db) { + db.createObjectStore(_SettingsStore.STORE_NAME, { keyPath: "id" }); + } +}; +SettingsStore.STORE_NAME = "settings"; +var SettingsService = class extends BaseEntityService { + constructor(context, eventBus) { + super(context, eventBus); + this.storeName = SettingsStore.STORE_NAME; + this.entityType = "Settings"; + } + /** + * Get workweek settings + */ + async getWorkweekSettings() { + return this.get(SettingsIds.WORKWEEK); + } + /** + * Get grid settings + */ + async getGridSettings() { + return this.get(SettingsIds.GRID); + } + /** + * Get time format settings + */ + async getTimeFormatSettings() { + return this.get(SettingsIds.TIME_FORMAT); + } + /** + * Get view settings + */ + async getViewSettings() { + return this.get(SettingsIds.VIEWS); + } + /** + * Get workweek preset by ID + */ + async getWorkweekPreset(presetId) { + const settings = await this.getWorkweekSettings(); + if (!settings) + return null; + return settings.presets[presetId] || null; + } + /** + * Get the default workweek preset + */ + async getDefaultWorkweekPreset() { + const settings = await this.getWorkweekSettings(); + if (!settings) + return null; + return settings.presets[settings.defaultPreset] || null; + } + /** + * Get all available workweek presets + */ + async getWorkweekPresets() { + const settings = await this.getWorkweekSettings(); + if (!settings) + return []; + return Object.values(settings.presets); + } +}; +var ViewConfigStore = class _ViewConfigStore { + constructor() { + this.storeName = _ViewConfigStore.STORE_NAME; + } + create(db) { + db.createObjectStore(_ViewConfigStore.STORE_NAME, { keyPath: "id" }); + } +}; +ViewConfigStore.STORE_NAME = "viewconfigs"; +var ViewConfigService = class extends BaseEntityService { + constructor(context, eventBus) { + super(context, eventBus); + this.storeName = ViewConfigStore.STORE_NAME; + this.entityType = "ViewConfig"; + } + async getById(id) { + return this.get(id); + } +}; +var SwpEvent = class _SwpEvent { + constructor(element, columnKey, start, end) { + this.element = element; + this.columnKey = columnKey; + this._start = start; + this._end = end; + } + /** Event ID from element.dataset.eventId */ + get eventId() { + return this.element.dataset.eventId || ""; + } + get start() { + return this._start; + } + get end() { + return this._end; + } + /** Duration in minutes */ + get durationMinutes() { + return (this._end.getTime() - this._start.getTime()) / (1e3 * 60); + } + /** Duration in milliseconds */ + get durationMs() { + 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, columnKey, date, gridConfig) { + const topPixels = parseFloat(element.style.top) || 0; + const heightPixels = parseFloat(element.style.height) || 0; + 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); + const durationMinutes = heightPixels / gridConfig.hourHeight * 60; + const end = new Date(start.getTime() + durationMinutes * 60 * 1e3); + return new _SwpEvent(element, columnKey, start, end); + } +}; +var DragDropManager = class { + constructor(eventBus, gridConfig) { + this.eventBus = eventBus; + this.gridConfig = gridConfig; + this.dragState = null; + this.mouseDownPosition = null; + this.pendingElement = null; + this.pendingMouseOffset = null; + this.container = null; + this.inHeader = false; + this.DRAG_THRESHOLD = 5; + this.INTERPOLATION_FACTOR = 0.3; + this.handlePointerDown = (e) => { + const target = e.target; + if (target.closest("swp-resize-handle")) + return; + const eventElement = target.closest("swp-event"); + const headerItem = target.closest("swp-header-item"); + const draggable = eventElement || headerItem; + if (!draggable) + return; + this.mouseDownPosition = { x: e.clientX, y: e.clientY }; + this.pendingElement = draggable; + const rect = draggable.getBoundingClientRect(); + this.pendingMouseOffset = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + draggable.setPointerCapture(e.pointerId); + }; + this.handlePointerMove = (e) => { + if (!this.mouseDownPosition || !this.pendingElement) { + if (this.dragState) { + this.updateDragTarget(e); + } + return; + } + 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; + this.initializeDrag(this.pendingElement, this.pendingMouseOffset, e); + this.mouseDownPosition = null; + this.pendingElement = null; + this.pendingMouseOffset = null; + }; + this.handlePointerUp = (_e) => { + this.mouseDownPosition = null; + this.pendingElement = null; + this.pendingMouseOffset = null; + if (!this.dragState) + return; + cancelAnimationFrame(this.dragState.animationId); + if (this.dragState.dragSource === "header") { + this.handleHeaderItemDragEnd(); + } else { + this.handleGridEventDragEnd(); + } + this.dragState.element.classList.remove("dragging"); + this.dragState = null; + this.inHeader = false; + }; + this.animateDrag = () => { + if (!this.dragState) + return; + const diff2 = this.dragState.targetY - this.dragState.currentY; + if (Math.abs(diff2) <= 0.5) { + this.dragState.animationId = 0; + return; + } + this.dragState.currentY += diff2 * this.INTERPOLATION_FACTOR; + this.dragState.element.style.top = `${this.dragState.currentY}px`; + if (this.dragState.columnElement) { + const payload = { + eventId: this.dragState.eventId, + element: this.dragState.element, + currentY: this.dragState.currentY, + columnElement: this.dragState.columnElement + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload); + } + this.dragState.animationId = requestAnimationFrame(this.animateDrag); + }; + this.setupScrollListener(); + } + setupScrollListener() { + this.eventBus.on(CoreEvents.EDGE_SCROLL_TICK, (e) => { + if (!this.dragState) + return; + const { scrollDelta } = e.detail; + 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) { + this.container = container; + container.addEventListener("pointerdown", this.handlePointerDown); + document.addEventListener("pointermove", this.handlePointerMove); + document.addEventListener("pointerup", this.handlePointerUp); + } + /** + * Handle drag end for header items + */ + handleHeaderItemDragEnd() { + if (!this.dragState) + return; + if (!this.inHeader && this.dragState.currentColumn) { + const gridEvent = this.dragState.currentColumn.querySelector(`swp-event[data-event-id="${this.dragState.eventId}"]`); + 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 = { + swpEvent, + sourceColumnKey: this.dragState.sourceColumnKey, + target: "grid" + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); + } + } + } + /** + * Handle drag end for grid events + */ + handleGridEventDragEnd() { + if (!this.dragState || !this.dragState.columnElement) + return; + const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig); + this.dragState.element.style.top = `${snappedY}px`; + this.dragState.ghostElement?.remove(); + const columnKey = this.dragState.columnElement.dataset.columnKey || ""; + const date = this.dragState.columnElement.dataset.date || ""; + const swpEvent = SwpEvent.fromElement(this.dragState.element, columnKey, date, this.gridConfig); + const payload = { + swpEvent, + sourceColumnKey: this.dragState.sourceColumnKey, + target: this.inHeader ? "header" : "grid" + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); + } + initializeDrag(element, mouseOffset, e) { + const eventId = element.dataset.eventId || ""; + const isHeaderItem = element.tagName.toLowerCase() === "swp-header-item"; + const columnElement = element.closest("swp-day-column"); + if (!isHeaderItem && !columnElement) + return; + if (isHeaderItem) { + this.initializeHeaderItemDrag(element, mouseOffset, eventId); + } else { + this.initializeGridEventDrag(element, mouseOffset, e, columnElement, eventId); + } + } + /** + * Initialize drag for a header item (allDay event) + */ + initializeHeaderItemDrag(element, mouseOffset, eventId) { + element.classList.add("dragging"); + 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" + }; + this.inHeader = true; + } + /** + * Initialize drag for a grid event + */ + initializeGridEventDrag(element, mouseOffset, e, columnElement, eventId) { + const elementRect = element.getBoundingClientRect(); + const columnRect = columnElement.getBoundingClientRect(); + const startY = elementRect.top - columnRect.top; + const group = element.closest("swp-event-group"); + if (group) { + const eventsLayer = columnElement.querySelector("swp-events-layer"); + if (eventsLayer) { + eventsLayer.appendChild(element); + } + } + element.style.position = "absolute"; + element.style.top = `${startY}px`; + element.style.left = "2px"; + element.style.right = "2px"; + element.style.marginLeft = "0"; + const ghostElement = element.cloneNode(true); + ghostElement.classList.add("drag-ghost"); + ghostElement.style.opacity = "0.3"; + ghostElement.style.pointerEvents = "none"; + element.parentNode?.insertBefore(ghostElement, element); + element.classList.add("dragging"); + const targetY = e.clientY - columnRect.top - mouseOffset.y; + 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" + }; + const payload = { + eventId, + element, + ghostElement, + startY, + mouseOffset, + columnElement + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_START, payload); + this.animateDrag(); + } + updateDragTarget(e) { + if (!this.dragState) + return; + this.checkHeaderZone(e); + if (this.inHeader) + return; + const columnAtPoint = this.getColumnAtPoint(e.clientX); + 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 = { + 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; + } + 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); + if (!this.dragState.animationId) { + this.animateDrag(); + } + } + /** + * Check if pointer is in header zone and emit appropriate events + */ + checkHeaderZone(e) { + 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) { + this.inHeader = true; + if (this.dragState.dragSource === "grid" && this.dragState.columnElement) { + const payload = { + 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); + } + } else if (!isInHeader && this.inHeader) { + this.inHeader = false; + const targetColumn = this.getColumnAtPoint(e.clientX); + if (this.dragState.dragSource === "header") { + const payload = { + eventId: this.dragState.eventId, + source: "header", + element: this.dragState.element, + targetColumn: targetColumn || void 0, + start: this.dragState.element.dataset.start ? new Date(this.dragState.element.dataset.start) : void 0, + end: this.dragState.element.dataset.end ? new Date(this.dragState.element.dataset.end) : void 0, + title: this.dragState.element.textContent || "", + colorClass: [...this.dragState.element.classList].find((c) => c.startsWith("is-")) + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + if (targetColumn) { + const newElement = targetColumn.querySelector(`swp-event[data-event-id="${this.dragState.eventId}"]`); + if (newElement) { + this.dragState.element = newElement; + this.dragState.columnElement = targetColumn; + this.dragState.currentColumn = targetColumn; + this.animateDrag(); + } + } + } else { + const payload = { + eventId: this.dragState.eventId, + source: "grid" + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + } + } else if (isInHeader) { + const column = this.getColumnAtX(e.clientX); + if (column) { + const payload = { + 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 + */ + getColumnIndex(column) { + 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) + */ + getColumnAtX(clientX) { + return this.getColumnAtPoint(clientX); + } + /** + * Find column element at given X coordinate + */ + getColumnAtPoint(clientX) { + 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; + } + } + return null; + } + /** + * Cancel drag and animate back to start position + */ + cancelDrag() { + if (!this.dragState) + return; + cancelAnimationFrame(this.dragState.animationId); + const { element, ghostElement, startY, eventId } = this.dragState; + element.style.transition = "top 200ms ease-out"; + element.style.top = `${startY}px`; + setTimeout(() => { + ghostElement?.remove(); + element.style.transition = ""; + element.classList.remove("dragging"); + }, 200); + const payload = { + eventId, + element, + startY + }; + this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload); + this.dragState = null; + this.inHeader = false; + } +}; +var EdgeScrollManager = class { + constructor(eventBus) { + this.eventBus = eventBus; + this.scrollableContent = null; + this.timeGrid = null; + this.draggedElement = null; + this.scrollRAF = null; + this.mouseY = 0; + this.isDragging = false; + this.isScrolling = false; + this.lastTs = 0; + this.rect = null; + this.initialScrollTop = 0; + this.OUTER_ZONE = 100; + this.INNER_ZONE = 50; + this.SLOW_SPEED = 140; + this.FAST_SPEED = 640; + this.trackMouse = (e) => { + if (this.isDragging) { + this.mouseY = e.clientY; + } + }; + this.scrollTick = (ts) => { + if (!this.isDragging || !this.scrollableContent) + return; + const dt = this.lastTs ? (ts - this.lastTs) / 1e3 : 0; + this.lastTs = ts; + this.rect ?? (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); + }; + this.subscribeToEvents(); + document.addEventListener("pointermove", this.trackMouse); + } + init(scrollableContent) { + this.scrollableContent = scrollableContent; + this.timeGrid = scrollableContent.querySelector("swp-time-grid"); + this.scrollableContent.style.scrollBehavior = "auto"; + } + subscribeToEvents() { + this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event) => { + const payload = event.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()); + } + startDrag() { + this.isDragging = true; + this.isScrolling = false; + this.lastTs = 0; + this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0; + if (this.scrollRAF === null) { + this.scrollRAF = requestAnimationFrame(this.scrollTick); + } + } + stopDrag() { + 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; + } + calculateVelocity() { + 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; + } + isAtBoundary(velocity) { + 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; + } + setScrollingState(scrolling) { + 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, {}); + } + } +}; +var ResizeManager = class { + constructor(eventBus, gridConfig, dateService) { + this.eventBus = eventBus; + this.gridConfig = gridConfig; + this.dateService = dateService; + this.container = null; + this.resizeState = null; + this.Z_INDEX_RESIZING = "1000"; + this.ANIMATION_SPEED = 0.35; + this.MIN_HEIGHT_MINUTES = 15; + this.handleMouseOver = (e) => { + const target = e.target; + const eventElement = target.closest("swp-event"); + if (!eventElement || this.resizeState) + return; + if (!eventElement.querySelector(":scope > swp-resize-handle")) { + const handle = this.createResizeHandle(); + eventElement.appendChild(handle); + } + }; + this.handlePointerDown = (e) => { + const handle = e.target.closest("swp-resize-handle"); + if (!handle) + return; + const element = handle.parentElement; + if (!element) + return; + const eventId = element.dataset.eventId || ""; + const startHeight = element.offsetHeight; + const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig); + const container = element.closest("swp-event-group") ?? element; + const prevZIndex = container.style.zIndex; + this.resizeState = { + eventId, + element, + handleElement: handle, + startY: e.clientY, + startHeight, + startDurationMinutes, + pointerId: e.pointerId, + prevZIndex, + // Animation state + currentHeight: startHeight, + targetHeight: startHeight, + animationId: null + }; + container.style.zIndex = this.Z_INDEX_RESIZING; + try { + handle.setPointerCapture(e.pointerId); + } catch (err) { + console.warn("Pointer capture failed:", err); + } + document.documentElement.classList.add("swp--resizing"); + this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, { + eventId, + element, + startHeight + }); + e.preventDefault(); + }; + this.handlePointerMove = (e) => { + 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); + this.resizeState.targetHeight = newHeight; + if (this.resizeState.animationId === null) { + this.animateHeight(); + } + }; + this.animateHeight = () => { + if (!this.resizeState) + return; + const diff2 = this.resizeState.targetHeight - this.resizeState.currentHeight; + if (Math.abs(diff2) < 0.5) { + this.resizeState.animationId = null; + return; + } + this.resizeState.currentHeight += diff2 * this.ANIMATION_SPEED; + this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`; + this.updateTimestampDisplay(); + this.resizeState.animationId = requestAnimationFrame(this.animateHeight); + }; + this.handlePointerUp = (e) => { + if (!this.resizeState) + return; + if (this.resizeState.animationId !== null) { + cancelAnimationFrame(this.resizeState.animationId); + } + try { + this.resizeState.handleElement.releasePointerCapture(e.pointerId); + } catch (err) { + console.warn("Pointer release failed:", err); + } + this.snapToGridFinal(); + this.updateTimestampDisplay(); + const container = this.resizeState.element.closest("swp-event-group") ?? this.resizeState.element; + container.style.zIndex = this.resizeState.prevZIndex; + document.documentElement.classList.remove("swp--resizing"); + const column = this.resizeState.element.closest("swp-day-column"); + const columnKey = column?.dataset.columnKey || ""; + const date = column?.dataset.date || ""; + const swpEvent = SwpEvent.fromElement(this.resizeState.element, columnKey, date, this.gridConfig); + this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, { + swpEvent + }); + this.resizeState = null; + }; + } + /** + * Initialize resize functionality on container + */ + init(container) { + this.container = container; + container.addEventListener("mouseover", this.handleMouseOver, true); + document.addEventListener("pointerdown", this.handlePointerDown, true); + document.addEventListener("pointermove", this.handlePointerMove, true); + document.addEventListener("pointerup", this.handlePointerUp, true); + } + /** + * Create resize handle element + */ + createResizeHandle() { + const handle = document.createElement("swp-resize-handle"); + handle.setAttribute("aria-label", "Resize event"); + handle.setAttribute("role", "separator"); + return handle; + } + /** + * Update timestamp display with snapped end time + */ + updateTimestampDisplay() { + if (!this.resizeState) + return; + const timeEl = this.resizeState.element.querySelector("swp-event-time"); + if (!timeEl) + return; + const top = parseFloat(this.resizeState.element.style.top) || 0; + const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig); + const startMinutes = this.gridConfig.dayStartHour * 60 + startMinutesFromGrid; + const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig); + const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig); + const endMinutes = startMinutes + durationMinutes; + const start = this.minutesToDate(startMinutes); + const end = this.minutesToDate(endMinutes); + timeEl.textContent = this.dateService.formatTimeRange(start, end); + } + /** + * Convert minutes since midnight to Date + */ + minutesToDate(minutes) { + const date = /* @__PURE__ */ new Date(); + date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0); + return date; + } + /** + * Snap final height to grid interval + */ + snapToGridFinal() { + 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; + } +}; +var EventPersistenceManager = class { + constructor(eventService, eventBus, dateService) { + this.eventService = eventService; + this.eventBus = eventBus; + this.dateService = dateService; + this.handleDragEnd = async (e) => { + const payload = e.detail; + const { swpEvent } = payload; + const event = await this.eventService.get(swpEvent.eventId); + if (!event) { + console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`); + return; + } + const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey); + const updatedEvent = { + ...event, + start: swpEvent.start, + end: swpEvent.end, + resourceId: resource ?? event.resourceId, + allDay: payload.target === "header", + syncStatus: "pending" + }; + await this.eventService.save(updatedEvent); + const updatePayload = { + eventId: updatedEvent.id, + sourceColumnKey: payload.sourceColumnKey, + targetColumnKey: swpEvent.columnKey + }; + this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload); + }; + this.handleResizeEnd = async (e) => { + const payload = e.detail; + const { swpEvent } = payload; + const event = await this.eventService.get(swpEvent.eventId); + if (!event) { + console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`); + return; + } + const updatedEvent = { + ...event, + end: swpEvent.end, + syncStatus: "pending" + }; + await this.eventService.save(updatedEvent); + const updatePayload = { + eventId: updatedEvent.id, + sourceColumnKey: swpEvent.columnKey, + targetColumnKey: swpEvent.columnKey + }; + this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload); + }; + this.setupListeners(); + } + setupListeners() { + this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd); + this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd); + } +}; +var defaultTimeFormatConfig = { + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + use24HourFormat: true, + locale: "da-DK", + dateFormat: "locale", + showSeconds: false +}; +var defaultGridConfig = { + hourHeight: 64, + dayStartHour: 6, + dayEndHour: 18, + snapInterval: 15, + gridStartThresholdMinutes: 30 +}; +function registerCoreServices(builder, options) { + const timeConfig = options?.timeConfig ?? defaultTimeFormatConfig; + const gridConfig = options?.gridConfig ?? defaultGridConfig; + const dbConfig = options?.dbConfig ?? defaultDBConfig; + builder.registerInstance(timeConfig).as("ITimeFormatConfig"); + builder.registerInstance(gridConfig).as("IGridConfig"); + builder.registerInstance(dbConfig).as("IDBConfig"); + builder.registerType(EventBus).as("EventBus"); + builder.registerType(EventBus).as("IEventBus"); + builder.registerType(DateService).as("DateService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("ITimeFormatConfig"), + void 0 + ] + }); + builder.registerType(IndexedDBContext).as("IndexedDBContext").autoWire({ + mapResolvers: [ + (c) => c.resolveTypeAll("IStore"), + (c) => c.resolveType("IDBConfig") + ] + }); + builder.registerType(EventStore).as("IStore"); + builder.registerType(ResourceStore).as("IStore"); + builder.registerType(SettingsStore).as("IStore"); + builder.registerType(ViewConfigStore).as("IStore"); + builder.registerType(EventService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(EventService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(EventService).as("EventService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ResourceService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ResourceService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ResourceService).as("ResourceService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(SettingsService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(SettingsService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(SettingsService).as("SettingsService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ViewConfigService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ViewConfigService).as("IEntityService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ViewConfigService).as("ViewConfigService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(EventRenderer).as("EventRenderer").autoWire({ + mapResolvers: [ + (c) => c.resolveType("EventService"), + (c) => c.resolveType("DateService"), + (c) => c.resolveType("IGridConfig"), + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ScheduleRenderer).as("ScheduleRenderer").autoWire({ + mapResolvers: [ + (c) => c.resolveType("ResourceScheduleService"), + (c) => c.resolveType("DateService"), + (c) => c.resolveType("IGridConfig") + ] + }); + builder.registerType(HeaderDrawerRenderer).as("HeaderDrawerRenderer").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IEventBus"), + (c) => c.resolveType("IGridConfig"), + (c) => c.resolveType("HeaderDrawerManager"), + (c) => c.resolveType("EventService"), + (c) => c.resolveType("DateService") + ] + }); + builder.registerType(TimeAxisRenderer).as("TimeAxisRenderer"); + builder.registerType(DateRenderer).as("IRenderer").autoWire({ + mapResolvers: [ + (c) => c.resolveType("DateService") + ] + }); + builder.registerType(ResourceRenderer).as("IRenderer").autoWire({ + mapResolvers: [ + (c) => c.resolveType("ResourceService") + ] + }); + builder.registerType(ScrollManager).as("ScrollManager"); + builder.registerType(HeaderDrawerManager).as("HeaderDrawerManager"); + builder.registerType(DragDropManager).as("DragDropManager").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IEventBus"), + (c) => c.resolveType("IGridConfig") + ] + }); + builder.registerType(EdgeScrollManager).as("EdgeScrollManager").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IEventBus") + ] + }); + builder.registerType(ResizeManager).as("ResizeManager").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IEventBus"), + (c) => c.resolveType("IGridConfig"), + (c) => c.resolveType("DateService") + ] + }); + builder.registerType(EventPersistenceManager).as("EventPersistenceManager").autoWire({ + mapResolvers: [ + (c) => c.resolveType("EventService"), + (c) => c.resolveType("IEventBus"), + (c) => c.resolveType("DateService") + ] + }); + builder.registerType(CalendarOrchestrator).as("CalendarOrchestrator").autoWire({ + mapResolvers: [ + (c) => c.resolveTypeAll("IRenderer"), + (c) => c.resolveType("EventRenderer"), + (c) => c.resolveType("ScheduleRenderer"), + (c) => c.resolveType("HeaderDrawerRenderer"), + (c) => c.resolveType("DateService"), + (c) => c.resolveTypeAll("IEntityService") + ] + }); + builder.registerType(CalendarApp).as("CalendarApp").autoWire({ + mapResolvers: [ + (c) => c.resolveType("CalendarOrchestrator"), + (c) => c.resolveType("TimeAxisRenderer"), + (c) => c.resolveType("DateService"), + (c) => c.resolveType("ScrollManager"), + (c) => c.resolveType("HeaderDrawerManager"), + (c) => c.resolveType("DragDropManager"), + (c) => c.resolveType("EdgeScrollManager"), + (c) => c.resolveType("ResizeManager"), + (c) => c.resolveType("HeaderDrawerRenderer"), + (c) => c.resolveType("EventPersistenceManager"), + (c) => c.resolveType("SettingsService"), + (c) => c.resolveType("ViewConfigService"), + (c) => c.resolveType("IEventBus") + ] + }); +} + +// node_modules/calendar/dist/extensions/schedules/index.js +var ScheduleOverrideStore = class _ScheduleOverrideStore { + constructor() { + this.storeName = _ScheduleOverrideStore.STORE_NAME; + } + create(db) { + 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 }); + } +}; +ScheduleOverrideStore.STORE_NAME = "scheduleOverrides"; +var ScheduleOverrideService = class { + constructor(context) { + this.context = context; + } + get db() { + return this.context.getDatabase(); + } + /** + * Get override for a specific resource and date + */ + async getOverride(resourceId, date) { + 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) { + 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, startDate, endDate) { + const all = await this.getByResource(resourceId); + return all.filter((o) => o.date >= startDate && o.date <= endDate); + } + /** + * Save an override + */ + async save(override) { + 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) { + 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}`)); + }; + }); + } +}; +var ResourceScheduleService = class { + constructor(resourceService, overrideService, dateService) { + this.resourceService = resourceService; + this.overrideService = overrideService; + this.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, date) { + const override = await this.overrideService.getOverride(resourceId, date); + if (override) { + return override.schedule; + } + 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, dates) { + const result = /* @__PURE__ */ new Map(); + const resource = await this.resourceService.get(resourceId); + const overrides = dates.length > 0 ? await this.overrideService.getByDateRange(resourceId, dates[0], dates[dates.length - 1]) : []; + const overrideMap = new Map(overrides.map((o) => [o.date, o.schedule])); + for (const date of dates) { + if (overrideMap.has(date)) { + result.set(date, overrideMap.get(date)); + continue; + } + if (resource?.defaultSchedule) { + const weekDay = this.dateService.getISOWeekDay(date); + result.set(date, resource.defaultSchedule[weekDay] || null); + } else { + result.set(date, null); + } + } + return result; + } +}; +function registerSchedules(builder) { + builder.registerType(ScheduleOverrideStore).as("IStore"); + builder.registerType(ScheduleOverrideService).as("ScheduleOverrideService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("IndexedDBContext") + ] + }); + builder.registerType(ResourceScheduleService).as("ResourceScheduleService").autoWire({ + mapResolvers: [ + (c) => c.resolveType("ResourceService"), + (c) => c.resolveType("ScheduleOverrideService"), + (c) => c.resolveType("DateService") + ] + }); +} + +// src/index.ts +async function init() { + 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"); + 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); + } + const calendarApp = app.resolveType("CalendarApp"); + const containerEl = document.querySelector("swp-calendar-container"); + await calendarApp.init(containerEl); + const eventBus = app.resolveType("EventBus"); + eventBus.emit(CalendarEvents.CMD_RENDER, { viewId: "simple" }); + console.log("Calendar rendered"); + document.addEventListener("event:drag-end", (e) => { + console.log("event:drag-end:", e.detail); + }); + document.addEventListener("event:updated", (e) => { + console.log("event:updated:", e.detail); + }); + const persistenceManager = app.resolveType("EventPersistenceManager"); + console.log("EventPersistenceManager resolved:", persistenceManager); +} +async function seedData(settingsService, viewConfigService, eventService) { + await settingsService.save({ + id: "grid", + dayStartHour: 8, + dayEndHour: 17, + workStartHour: 9, + workEndHour: 16, + hourHeight: 64, + snapInterval: 15, + syncStatus: "synced" + }); + 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" + }); + await viewConfigService.save({ + id: "simple", + groupings: [{ type: "date", values: [], idProperty: "date", derivedFrom: "start" }], + syncStatus: "synced" + }); + const today = /* @__PURE__ */ 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\xF8de", + 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); diff --git a/test-package/dist/css/calendar.css b/test-package/dist/css/calendar.css new file mode 100644 index 0000000..5a06eed --- /dev/null +++ b/test-package/dist/css/calendar.css @@ -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); + } +} diff --git a/test-package/index.html b/test-package/index.html new file mode 100644 index 0000000..fcc8084 --- /dev/null +++ b/test-package/index.html @@ -0,0 +1,41 @@ + + + + + + Calendar Package Test + + + +

Calendar Package Test

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/test-package/package-lock.json b/test-package/package-lock.json new file mode 100644 index 0000000..c458173 --- /dev/null +++ b/test-package/package-lock.json @@ -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" + } + } +} diff --git a/test-package/package.json b/test-package/package.json new file mode 100644 index 0000000..c0d1011 --- /dev/null +++ b/test-package/package.json @@ -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" + } +} diff --git a/test-package/src/index.ts b/test-package/src/index.ts new file mode 100644 index 0000000..43e78c8 --- /dev/null +++ b/test-package/src/index.ts @@ -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(); + await dbContext.initialize(); + console.log('IndexedDB initialized'); + + const settingsService = app.resolveType(); + const viewConfigService = app.resolveType(); + const eventService = app.resolveType(); + + 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(); + const containerEl = document.querySelector('swp-calendar-container') as HTMLElement; + await calendarApp.init(containerEl); + + const eventBus = app.resolveType(); + 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(); + 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); diff --git a/test-package/tsconfig.json b/test-package/tsconfig.json new file mode 100644 index 0000000..ebbec2c --- /dev/null +++ b/test-package/tsconfig.json @@ -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/**/*"] +}