From 863b433eba96c3a24d59a27717596b61140eae95 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Wed, 17 Dec 2025 23:54:25 +0100 Subject: [PATCH] Refactors calendar project structure and build configuration Consolidates V2 codebase into main project directory Updates build script to support simplified entry points Removes redundant files and cleans up project organization Simplifies module imports and entry points for calendar application --- .claude/settings.local.json | 5 +- .workbench/inspiration.md | 266 ---- build.js | 50 +- reports/css-analysis-report.html | 52 +- reports/css-stats.json | 15 +- reports/purgecss-report.json | 53 +- ...2CompositionRoot.ts => CompositionRoot.ts} | 2 +- src/components/NavigationButtons.ts | 159 -- src/components/ViewSelector.ts | 152 -- src/components/WorkweekPresets.ts | 114 -- src/configurations/CalendarConfig.ts | 115 -- src/configurations/ConfigManager.ts | 104 -- src/configurations/DateViewSettings.ts | 11 - src/configurations/GridSettings.ts | 25 - src/configurations/ICalendarConfig.ts | 30 - src/configurations/TimeFormatConfig.ts | 10 - src/configurations/WorkWeekSettings.ts | 9 - src/constants/CoreEvents.ts | 74 +- src/{v2 => }/core/BaseGroupingRenderer.ts | 0 src/{v2 => }/core/CalendarApp.ts | 40 +- src/{v2 => }/core/CalendarEvents.ts | 16 + src/{v2 => }/core/CalendarOrchestrator.ts | 0 src/{v2 => }/core/DateService.ts | 0 src/{v2 => }/core/EntityResolver.ts | 0 src/core/EventBus.ts | 36 +- src/{v2 => }/core/FilterTemplate.ts | 0 src/{v2 => }/core/HeaderDrawerManager.ts | 0 src/{v2 => }/core/IEntityResolver.ts | 0 src/{v2 => }/core/IGridConfig.ts | 0 src/{v2 => }/core/IGroupingRenderer.ts | 0 src/{v2 => }/core/IGroupingStore.ts | 0 src/{v2 => }/core/ITimeFormatConfig.ts | 0 src/{v2 => }/core/NavigationAnimator.ts | 0 src/{v2 => }/core/RenderBuilder.ts | 0 src/{v2 => }/core/ScrollManager.ts | 0 src/{v2 => }/core/ViewConfig.ts | 0 src/datasources/DateColumnDataSource.ts | 159 -- src/datasources/ResourceColumnDataSource.ts | 87 -- src/{v2 => }/demo/DemoApp.ts | 0 src/{v2 => }/demo/MockStores.ts | 0 src/demo/index.ts | 5 + src/elements/SwpEventElement.ts | 393 ----- src/entry.ts | 6 + src/{v2 => }/features/date/DateRenderer.ts | 0 src/{v2 => }/features/date/index.ts | 0 .../features/department/DepartmentRenderer.ts | 0 .../features/event/EventLayoutEngine.ts | 4 +- .../features/event/EventLayoutTypes.ts | 0 src/{v2 => }/features/event/EventRenderer.ts | 0 src/{v2 => }/features/event/index.ts | 0 .../headerdrawer/HeaderDrawerLayoutEngine.ts | 0 .../headerdrawer/HeaderDrawerRenderer.ts | 0 src/{v2 => }/features/headerdrawer/index.ts | 0 .../features/resource/ResourceRenderer.ts | 0 src/{v2 => }/features/resource/index.ts | 0 .../features/schedule/ScheduleRenderer.ts | 0 src/{v2 => }/features/schedule/index.ts | 0 src/{v2 => }/features/team/TeamRenderer.ts | 0 src/{v2 => }/features/team/index.ts | 0 .../features/timeaxis/TimeAxisRenderer.ts | 0 src/index.ts | 301 +--- src/managers/AllDayManager.ts | 744 --------- src/managers/CalendarManager.ts | 195 --- src/managers/DragDropManager.ts | 1337 +++++++---------- src/managers/EdgeScrollManager.ts | 360 ++--- src/managers/EventFilterManager.ts | 229 --- src/managers/EventLayoutCoordinator.ts | 280 ---- src/managers/EventManager.ts | 199 --- .../managers/EventPersistenceManager.ts | 0 src/managers/EventStackManager.ts | 274 ---- src/managers/GridManager.ts | 111 -- src/managers/HeaderManager.ts | 138 -- src/managers/NavigationManager.ts | 258 ---- src/managers/ResizeHandleManager.ts | 244 --- src/{v2 => }/managers/ResizeManager.ts | 0 src/managers/ScrollManager.ts | 260 ---- src/managers/WorkHoursManager.ts | 162 -- src/renderers/AllDayEventRenderer.ts | 131 -- src/renderers/ColumnRenderer.ts | 79 - src/renderers/DateHeaderRenderer.ts | 61 - src/renderers/EventRenderer.ts | 386 ----- src/renderers/EventRendererManager.ts | 269 ---- src/renderers/GridRenderer.ts | 324 ---- src/renderers/ResourceColumnRenderer.ts | 54 - src/renderers/ResourceHeaderRenderer.ts | 59 - src/renderers/WeekInfoRenderer.ts | 100 -- src/repositories/ApiBookingRepository.ts | 92 -- src/repositories/ApiCustomerRepository.ts | 92 -- src/repositories/ApiEventRepository.ts | 133 -- src/repositories/ApiResourceRepository.ts | 92 -- src/repositories/IApiRepository.ts | 93 +- src/repositories/MockAuditRepository.ts | 2 +- src/repositories/MockBookingRepository.ts | 163 +- src/repositories/MockCustomerRepository.ts | 134 +- .../repositories/MockDepartmentRepository.ts | 0 src/repositories/MockEventRepository.ts | 66 +- src/repositories/MockResourceRepository.ts | 146 +- .../repositories/MockSettingsRepository.ts | 0 .../repositories/MockTeamRepository.ts | 0 .../repositories/MockViewConfigRepository.ts | 0 src/storage/BaseEntityService.ts | 447 +++--- src/storage/IEntityService.ts | 110 +- src/storage/IStore.ts | 43 +- src/storage/IndexedDBContext.ts | 220 ++- src/storage/SyncPlugin.ts | 154 +- src/storage/audit/AuditService.ts | 5 +- src/storage/audit/AuditStore.ts | 18 +- src/storage/bookings/BookingSerialization.ts | 42 - src/storage/bookings/BookingService.ts | 172 +-- src/storage/bookings/BookingStore.ts | 56 +- src/storage/customers/CustomerService.ts | 114 +- src/storage/customers/CustomerStore.ts | 52 +- .../storage/departments/DepartmentService.ts | 0 .../storage/departments/DepartmentStore.ts | 0 src/storage/events/EventSerialization.ts | 72 +- src/storage/events/EventService.ts | 258 ++-- src/storage/events/EventStore.ts | 84 +- src/storage/resources/ResourceService.ts | 110 +- src/storage/resources/ResourceStore.ts | 43 +- .../schedules/ResourceScheduleService.ts | 0 .../schedules/ScheduleOverrideService.ts | 0 .../schedules/ScheduleOverrideStore.ts | 0 .../storage/settings/SettingsService.ts | 0 .../storage/settings/SettingsStore.ts | 0 src/{v2 => }/storage/teams/TeamService.ts | 0 src/{v2 => }/storage/teams/TeamStore.ts | 0 .../storage/viewconfigs/ViewConfigService.ts | 0 .../storage/viewconfigs/ViewConfigStore.ts | 0 src/types/AuditTypes.ts | 46 +- src/types/BookingTypes.ts | 66 - src/types/CalendarTypes.ts | 167 +- src/types/ColumnDataSource.ts | 54 - src/types/CustomerTypes.ts | 13 - src/types/DragDropTypes.ts | 47 - src/{v2 => }/types/DragTypes.ts | 0 src/types/EventId.ts | 31 - src/types/EventTypes.ts | 134 -- src/types/ManagerTypes.ts | 75 - src/{v2 => }/types/ResizeTypes.ts | 0 src/types/ResourceTypes.ts | 23 - src/{v2 => }/types/ScheduleTypes.ts | 0 src/{v2 => }/types/SettingsTypes.ts | 0 src/{v2 => }/types/SwpEvent.ts | 0 src/utils/AllDayLayoutEngine.ts | 167 -- src/utils/ColumnDetectionUtils.ts | 115 -- src/utils/DateService.ts | 496 ------ src/utils/PositionUtils.ts | 300 +--- src/utils/TimeFormatter.ts | 104 -- src/utils/URLManager.ts | 86 -- src/v2/constants/CoreEvents.ts | 71 - src/v2/core/EventBus.ts | 174 --- src/v2/demo/index.ts | 5 - src/v2/entry.ts | 7 - src/v2/index.ts | 17 - src/v2/managers/DragDropManager.ts | 581 ------- src/v2/managers/EdgeScrollManager.ts | 140 -- src/v2/repositories/IApiRepository.ts | 33 - src/v2/repositories/MockAuditRepository.ts | 49 - src/v2/repositories/MockBookingRepository.ts | 73 - src/v2/repositories/MockCustomerRepository.ts | 58 - src/v2/repositories/MockEventRepository.ts | 86 -- src/v2/repositories/MockResourceRepository.ts | 66 - src/v2/storage/BaseEntityService.ts | 181 --- src/v2/storage/IEntityService.ts | 40 - src/v2/storage/IStore.ts | 18 - src/v2/storage/IndexedDBContext.ts | 92 -- src/v2/storage/SyncPlugin.ts | 64 - src/v2/storage/audit/AuditService.ts | 167 -- src/v2/storage/audit/AuditStore.ts | 27 - src/v2/storage/bookings/BookingService.ts | 75 - src/v2/storage/bookings/BookingStore.ts | 18 - src/v2/storage/customers/CustomerService.ts | 46 - src/v2/storage/customers/CustomerStore.ts | 17 - src/v2/storage/events/EventSerialization.ts | 32 - src/v2/storage/events/EventService.ts | 84 -- src/v2/storage/events/EventStore.ts | 37 - src/v2/storage/resources/ResourceService.ts | 55 - src/v2/storage/resources/ResourceStore.ts | 17 - src/v2/types/AuditTypes.ts | 46 - src/v2/types/CalendarTypes.ts | 170 --- src/v2/utils/PositionUtils.ts | 55 - src/v2/workers/DataSeeder.ts | 73 - src/workers/DataSeeder.ts | 176 +-- src/workers/SyncManager.ts | 259 ---- wwwroot/css/calendar-base-css.css | 248 --- ...calendar-v2-base.css => calendar-base.css} | 0 wwwroot/css/calendar-components-css.css | 236 --- wwwroot/css/calendar-events-css.css | 338 ----- ...ndar-v2-events.css => calendar-events.css} | 0 wwwroot/css/calendar-layout-css.css | 1 - ...ndar-v2-layout.css => calendar-layout.css} | 0 wwwroot/css/calendar-month-css.css | 315 ---- wwwroot/css/calendar-popup-css.css | 193 --- wwwroot/css/calendar-sliding-animation.css | 24 - wwwroot/css/calendar-v2.css | 321 ---- wwwroot/css/calendar.css | 6 + wwwroot/css/test-nesting.css | 1 - wwwroot/css/v2/calendar-v2.css | 6 - wwwroot/index.html | 159 +- wwwroot/v2.html | 83 - 200 files changed, 2331 insertions(+), 16193 deletions(-) delete mode 100644 .workbench/inspiration.md rename src/{v2/V2CompositionRoot.ts => CompositionRoot.ts} (97%) delete mode 100644 src/components/NavigationButtons.ts delete mode 100644 src/components/ViewSelector.ts delete mode 100644 src/components/WorkweekPresets.ts delete mode 100644 src/configurations/CalendarConfig.ts delete mode 100644 src/configurations/ConfigManager.ts delete mode 100644 src/configurations/DateViewSettings.ts delete mode 100644 src/configurations/GridSettings.ts delete mode 100644 src/configurations/ICalendarConfig.ts delete mode 100644 src/configurations/TimeFormatConfig.ts delete mode 100644 src/configurations/WorkWeekSettings.ts rename src/{v2 => }/core/BaseGroupingRenderer.ts (100%) rename src/{v2 => }/core/CalendarApp.ts (85%) rename src/{v2 => }/core/CalendarEvents.ts (62%) rename src/{v2 => }/core/CalendarOrchestrator.ts (100%) rename src/{v2 => }/core/DateService.ts (100%) rename src/{v2 => }/core/EntityResolver.ts (100%) rename src/{v2 => }/core/FilterTemplate.ts (100%) rename src/{v2 => }/core/HeaderDrawerManager.ts (100%) rename src/{v2 => }/core/IEntityResolver.ts (100%) rename src/{v2 => }/core/IGridConfig.ts (100%) rename src/{v2 => }/core/IGroupingRenderer.ts (100%) rename src/{v2 => }/core/IGroupingStore.ts (100%) rename src/{v2 => }/core/ITimeFormatConfig.ts (100%) rename src/{v2 => }/core/NavigationAnimator.ts (100%) rename src/{v2 => }/core/RenderBuilder.ts (100%) rename src/{v2 => }/core/ScrollManager.ts (100%) rename src/{v2 => }/core/ViewConfig.ts (100%) delete mode 100644 src/datasources/DateColumnDataSource.ts delete mode 100644 src/datasources/ResourceColumnDataSource.ts rename src/{v2 => }/demo/DemoApp.ts (100%) rename src/{v2 => }/demo/MockStores.ts (100%) create mode 100644 src/demo/index.ts delete mode 100644 src/elements/SwpEventElement.ts create mode 100644 src/entry.ts rename src/{v2 => }/features/date/DateRenderer.ts (100%) rename src/{v2 => }/features/date/index.ts (100%) rename src/{v2 => }/features/department/DepartmentRenderer.ts (100%) rename src/{v2 => }/features/event/EventLayoutEngine.ts (95%) rename src/{v2 => }/features/event/EventLayoutTypes.ts (100%) rename src/{v2 => }/features/event/EventRenderer.ts (100%) rename src/{v2 => }/features/event/index.ts (100%) rename src/{v2 => }/features/headerdrawer/HeaderDrawerLayoutEngine.ts (100%) rename src/{v2 => }/features/headerdrawer/HeaderDrawerRenderer.ts (100%) rename src/{v2 => }/features/headerdrawer/index.ts (100%) rename src/{v2 => }/features/resource/ResourceRenderer.ts (100%) rename src/{v2 => }/features/resource/index.ts (100%) rename src/{v2 => }/features/schedule/ScheduleRenderer.ts (100%) rename src/{v2 => }/features/schedule/index.ts (100%) rename src/{v2 => }/features/team/TeamRenderer.ts (100%) rename src/{v2 => }/features/team/index.ts (100%) rename src/{v2 => }/features/timeaxis/TimeAxisRenderer.ts (100%) delete mode 100644 src/managers/AllDayManager.ts delete mode 100644 src/managers/CalendarManager.ts delete mode 100644 src/managers/EventFilterManager.ts delete mode 100644 src/managers/EventLayoutCoordinator.ts delete mode 100644 src/managers/EventManager.ts rename src/{v2 => }/managers/EventPersistenceManager.ts (100%) delete mode 100644 src/managers/EventStackManager.ts delete mode 100644 src/managers/GridManager.ts delete mode 100644 src/managers/HeaderManager.ts delete mode 100644 src/managers/NavigationManager.ts delete mode 100644 src/managers/ResizeHandleManager.ts rename src/{v2 => }/managers/ResizeManager.ts (100%) delete mode 100644 src/managers/ScrollManager.ts delete mode 100644 src/managers/WorkHoursManager.ts delete mode 100644 src/renderers/AllDayEventRenderer.ts delete mode 100644 src/renderers/ColumnRenderer.ts delete mode 100644 src/renderers/DateHeaderRenderer.ts delete mode 100644 src/renderers/EventRenderer.ts delete mode 100644 src/renderers/EventRendererManager.ts delete mode 100644 src/renderers/GridRenderer.ts delete mode 100644 src/renderers/ResourceColumnRenderer.ts delete mode 100644 src/renderers/ResourceHeaderRenderer.ts delete mode 100644 src/renderers/WeekInfoRenderer.ts delete mode 100644 src/repositories/ApiBookingRepository.ts delete mode 100644 src/repositories/ApiCustomerRepository.ts delete mode 100644 src/repositories/ApiEventRepository.ts delete mode 100644 src/repositories/ApiResourceRepository.ts rename src/{v2 => }/repositories/MockDepartmentRepository.ts (100%) rename src/{v2 => }/repositories/MockSettingsRepository.ts (100%) rename src/{v2 => }/repositories/MockTeamRepository.ts (100%) rename src/{v2 => }/repositories/MockViewConfigRepository.ts (100%) delete mode 100644 src/storage/bookings/BookingSerialization.ts rename src/{v2 => }/storage/departments/DepartmentService.ts (100%) rename src/{v2 => }/storage/departments/DepartmentStore.ts (100%) rename src/{v2 => }/storage/schedules/ResourceScheduleService.ts (100%) rename src/{v2 => }/storage/schedules/ScheduleOverrideService.ts (100%) rename src/{v2 => }/storage/schedules/ScheduleOverrideStore.ts (100%) rename src/{v2 => }/storage/settings/SettingsService.ts (100%) rename src/{v2 => }/storage/settings/SettingsStore.ts (100%) rename src/{v2 => }/storage/teams/TeamService.ts (100%) rename src/{v2 => }/storage/teams/TeamStore.ts (100%) rename src/{v2 => }/storage/viewconfigs/ViewConfigService.ts (100%) rename src/{v2 => }/storage/viewconfigs/ViewConfigStore.ts (100%) delete mode 100644 src/types/BookingTypes.ts delete mode 100644 src/types/ColumnDataSource.ts delete mode 100644 src/types/CustomerTypes.ts delete mode 100644 src/types/DragDropTypes.ts rename src/{v2 => }/types/DragTypes.ts (100%) delete mode 100644 src/types/EventId.ts delete mode 100644 src/types/EventTypes.ts delete mode 100644 src/types/ManagerTypes.ts rename src/{v2 => }/types/ResizeTypes.ts (100%) delete mode 100644 src/types/ResourceTypes.ts rename src/{v2 => }/types/ScheduleTypes.ts (100%) rename src/{v2 => }/types/SettingsTypes.ts (100%) rename src/{v2 => }/types/SwpEvent.ts (100%) delete mode 100644 src/utils/AllDayLayoutEngine.ts delete mode 100644 src/utils/ColumnDetectionUtils.ts delete mode 100644 src/utils/DateService.ts delete mode 100644 src/utils/TimeFormatter.ts delete mode 100644 src/utils/URLManager.ts delete mode 100644 src/v2/constants/CoreEvents.ts delete mode 100644 src/v2/core/EventBus.ts delete mode 100644 src/v2/demo/index.ts delete mode 100644 src/v2/entry.ts delete mode 100644 src/v2/index.ts delete mode 100644 src/v2/managers/DragDropManager.ts delete mode 100644 src/v2/managers/EdgeScrollManager.ts delete mode 100644 src/v2/repositories/IApiRepository.ts delete mode 100644 src/v2/repositories/MockAuditRepository.ts delete mode 100644 src/v2/repositories/MockBookingRepository.ts delete mode 100644 src/v2/repositories/MockCustomerRepository.ts delete mode 100644 src/v2/repositories/MockEventRepository.ts delete mode 100644 src/v2/repositories/MockResourceRepository.ts delete mode 100644 src/v2/storage/BaseEntityService.ts delete mode 100644 src/v2/storage/IEntityService.ts delete mode 100644 src/v2/storage/IStore.ts delete mode 100644 src/v2/storage/IndexedDBContext.ts delete mode 100644 src/v2/storage/SyncPlugin.ts delete mode 100644 src/v2/storage/audit/AuditService.ts delete mode 100644 src/v2/storage/audit/AuditStore.ts delete mode 100644 src/v2/storage/bookings/BookingService.ts delete mode 100644 src/v2/storage/bookings/BookingStore.ts delete mode 100644 src/v2/storage/customers/CustomerService.ts delete mode 100644 src/v2/storage/customers/CustomerStore.ts delete mode 100644 src/v2/storage/events/EventSerialization.ts delete mode 100644 src/v2/storage/events/EventService.ts delete mode 100644 src/v2/storage/events/EventStore.ts delete mode 100644 src/v2/storage/resources/ResourceService.ts delete mode 100644 src/v2/storage/resources/ResourceStore.ts delete mode 100644 src/v2/types/AuditTypes.ts delete mode 100644 src/v2/types/CalendarTypes.ts delete mode 100644 src/v2/utils/PositionUtils.ts delete mode 100644 src/v2/workers/DataSeeder.ts delete mode 100644 src/workers/SyncManager.ts delete mode 100644 wwwroot/css/calendar-base-css.css rename wwwroot/css/{v2/calendar-v2-base.css => calendar-base.css} (100%) delete mode 100644 wwwroot/css/calendar-components-css.css delete mode 100644 wwwroot/css/calendar-events-css.css rename wwwroot/css/{v2/calendar-v2-events.css => calendar-events.css} (100%) delete mode 100644 wwwroot/css/calendar-layout-css.css rename wwwroot/css/{v2/calendar-v2-layout.css => calendar-layout.css} (100%) delete mode 100644 wwwroot/css/calendar-month-css.css delete mode 100644 wwwroot/css/calendar-popup-css.css delete mode 100644 wwwroot/css/calendar-sliding-animation.css delete mode 100644 wwwroot/css/calendar-v2.css create mode 100644 wwwroot/css/calendar.css delete mode 100644 wwwroot/css/test-nesting.css delete mode 100644 wwwroot/css/v2/calendar-v2.css delete mode 100644 wwwroot/v2.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6916dda..b2dc50e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,10 @@ "WebFetch(domain:raw.githubusercontent.com)", "Bash(npm run css:analyze:*)", "Bash(npm run test:run:*)", - "Bash(cd:*)" + "Bash(cd:*)", + "Bash(powershell -Command \"Get-ChildItem -Path src -Directory | Select-Object -ExpandProperty Name\")", + "Bash(powershell -Command \"Get-ChildItem -Path src -Filter ''index.ts'' -Recurse | Select-Object -ExpandProperty FullName\")", + "Bash(powershell -Command:*)" ], "deny": [], "ask": [] diff --git a/.workbench/inspiration.md b/.workbench/inspiration.md deleted file mode 100644 index ffcae70..0000000 --- a/.workbench/inspiration.md +++ /dev/null @@ -1,266 +0,0 @@ -Selvfølgelig—her er en **opdateret, selvstændig `.md`-spec**, som **understøtter variable antal resources per team**, dynamisk kolonnebredde, ingen inline layout-styles, pipeline‐rendering i grupper, og CSS-controlling via custom properties. - -Kopier → gem som fx: -`grid-render-pipeline-dynamic-columns.md` - ---- - -````md -# Grid Render Pipeline — Dynamic Columns Spec - -Denne specifikation beskriver en generisk render-pipeline til at bygge et -dynamisk CSS Grid layout, hvor hver "gruppe" (teams, resources, dates) har sin -egen renderer og pipeline-styring. Layoutet understøtter **variable antal -resources pr. team** og beregner automatisk antal kolonner. Ingen inline-styles -til positionering anvendes. - ---- - -## ✨ Formål - -- Ét globalt CSS Grid. -- Variabelt antal resources pr. team → dynamisk antal kolonner. -- CSS-grid auto-placerer rækker. -- Ingen inline styling af layout (ingen `element.style.gridRow = ...`). -- CSS custom properties bruges til at definere dynamiske spænder. -- Renderere har ens interface og bindes i pipeline. -- `pipeline.run(ctx)` executer alle renderers i rækkefølge. -- Hver renderer kan hente sin egen data (API, async osv.). - ---- - -## 🧩 Data Model - -```ts -type DateString = string; - -interface Resource { - id: string; - name: string; - dates: DateString[]; -} - -interface Team { - id: string; - name: string; - resources: Resource[]; -} -```` - ---- - -## 🧠 Context - -```ts -interface RenderContext { - grid: HTMLElement; // root grid container - teams: Team[]; // data -} -``` - -`grid` er HTML-elementet med `display:grid`, og `teams` er data. - ---- - -## 🎨 CSS Layout - -Grid kolonner bestemmes dynamisk via CSS variablen `--total-cols`. - -```css -.grid { - display: grid; - grid-template-columns: repeat(var(--total-cols), minmax(0, 1fr)); - gap: 6px 10px; -} - -.cell { - font-size: 0.9rem; -} -``` - -### Teams (øverste række) - -Hver team-header spænder **antal resources for team'et**: - -```css -.team-header { - grid-column: span var(--team-cols, 1); - font-weight: 700; - border-bottom: 1px solid #ccc; - padding: 4px 2px; -} -``` - -### Resources (2. række) - -```css -.resource-cell { - padding: 4px 2px; - background: #f5f5f5; - border-radius: 4px; - text-align: center; - font-weight: 600; -} -``` - -### Dates (3. række) - -```css -.dates-cell { padding: 2px 0; } - -.dates-list { - display: flex; - flex-wrap: wrap; - gap: 4px; - justify-content: center; -} - -.date-pill { - padding: 3px 6px; - background: #e3e3e3; - border-radius: 4px; - font-size: 0.8rem; -} -``` - ---- - -## 🔧 Beregning af kolonner - -**Total cols = sum(resources.length for all teams)** - -```ts -const totalCols = ctx.teams.reduce((sum, t) => sum + t.resources.length, 0); -ctx.grid.style.setProperty('--total-cols', totalCols.toString()); -``` - -For hvert team defineres hvor mange kolonner det spænder: - -```ts -cell.style.setProperty('--team-cols', team.resources.length.toString()); -``` - -> Bemærk: vi bruger **kun CSS vars** til layoutparametre – ikke inline -> grid-row/grid-column. - ---- - -## ⚙ Renderer Interface - -```ts -interface Renderer { - id: string; - next: Renderer | null; - render(ctx: RenderContext): void; -} -``` - -### Factory - -```ts -function createRenderer(id: string, fn: (ctx: RenderContext) => void): Renderer { - return { - id, - next: null, - render(ctx) { - fn(ctx); - if (this.next) this.next.render(ctx); - } - }; -} -``` - ---- - -## 🧱 De tre render-lag (grupper) - -### Teams - -* Appender én `.team-header` per team. -* Sætter `--team-cols`. - -### Resources - -* Appender én `.resource-cell` per resource. -* Foregår i teams-orden → CSS auto-row sørger for næste række. - -### Dates - -* Appender én `.dates-cell` per resource. -* Hver celle indeholder flere `.date-pill`. - -Append-rækkefølge giver 3 rækker automatisk: - -1. teams, 2) resources, 3) dates. - ---- - -## 🔗 Pipeline - -```ts -function buildPipeline(renderers: Renderer[]) { - for (let i = 0; i < renderers.length - 1; i++) { - renderers[i].next = renderers[i + 1]; - } - const first = renderers[0] ?? null; - return { - run(ctx: RenderContext) { - if (first) first.render(ctx); - } - }; -} -``` - -### Brug - -```ts -const pipeline = buildPipeline([ - teamsRenderer, - resourcesRenderer, - datesRenderer -]); - -pipeline.run(ctx); -``` - ---- - -## 🚀 Kørsel - -```ts -// 1) beregn total kolonner -const totalCols = ctx.teams.reduce((sum, t) => sum + t.resources.length, 0); -ctx.grid.style.setProperty('--total-cols', totalCols); - -// 2) pipeline -pipeline.run(ctx); -``` - -CSS klarer resten. - ---- - -## 🧽 Principper - -* **Ingen inline style-positionering**. -* **CSS Grid** owner layout. -* **JS** owner data & rækkefølge. -* **Renderers** er udskiftelige og genbrugelige. -* **Append i grupper** = rækker automatisk. -* **CSS vars** styrer spans dynamisk. - ---- - -## ✔ TL;DR - -* Grid-cols bestemmes ud fra data. -* Team-header `span = resources.length`. -* Append rækkefølge = rækker. -* Renderere i pipeline. -* Ingen koordinater, ingen inline layout-styles. - -``` - ---- - -``` diff --git a/build.js b/build.js index 296a1ca..df1d600 100644 --- a/build.js +++ b/build.js @@ -32,9 +32,9 @@ async function renameFiles(dir) { // Build with esbuild async function build() { try { - // Main calendar bundle (with DI) + // Calendar standalone bundle (no DI) await esbuild.build({ - entryPoints: ['src/index.ts'], + entryPoints: ['src/entry.ts'], bundle: true, outfile: 'wwwroot/js/calendar.js', format: 'esm', @@ -42,40 +42,26 @@ async function build() { target: 'es2020', minify: false, keepNames: true, + platform: 'browser' + }); + + console.log('Calendar bundle created: wwwroot/js/calendar.js'); + + // Demo bundle (with DI transformer for autowiring) + await esbuild.build({ + entryPoints: ['src/demo/index.ts'], + bundle: true, + outfile: 'wwwroot/js/demo.js', + format: 'esm', + sourcemap: 'inline', + target: 'es2020', + minify: false, + keepNames: true, platform: 'browser', plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })] }); - // V2 standalone bundle (no DI, no dependencies on main calendar) - await esbuild.build({ - entryPoints: ['src/v2/entry.ts'], - bundle: true, - outfile: 'wwwroot/js/calendar-v2.js', - format: 'esm', - sourcemap: 'inline', - target: 'es2020', - minify: false, - keepNames: true, - platform: 'browser' - }); - - console.log('V2 bundle created: wwwroot/js/calendar-v2.js'); - - // V2 demo bundle (with DI transformer for autowiring) - await esbuild.build({ - entryPoints: ['src/v2/demo/index.ts'], - bundle: true, - outfile: 'wwwroot/js/v2-demo.js', - format: 'esm', - sourcemap: 'inline', - target: 'es2020', - minify: false, - keepNames: true, - platform: 'browser', - plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true })] - }); - - console.log('V2 demo bundle created: wwwroot/js/v2-demo.js'); + console.log('Demo bundle created: wwwroot/js/demo.js'); } catch (error) { console.error('Build failed:', error); diff --git a/reports/css-analysis-report.html b/reports/css-analysis-report.html index 586ad68..156c1df 100644 --- a/reports/css-analysis-report.html +++ b/reports/css-analysis-report.html @@ -141,7 +141,7 @@
Total CSS Size
-
17.00 KB
+
19.26 KB
CSS Files
@@ -149,11 +149,11 @@
Unused CSS Rules
-
23
+
43
Potential Removal
-
0.15%
+
0.27%
@@ -195,12 +195,12 @@ calendar-v2-layout.css - 6.39 KB - 308 - 38 - 48 - 153 - 1 + 8.65 KB + 428 + 56 + 71 + 219 + 2 @@ -237,17 +237,17 @@

calendar-v2-layout.css

- 3 unused rules + 16 unused rules - Original: 6275 | After purge: 6203 + Original: 7087 | After purge: 6800

Show unused selectors
- &:hover
&[data-levels="resource date"]
&[data-levels="team resource date"] + .view-chip
&:hover
&.active
.workweek-dropdown
&:focus
fieldset
legend
.resource-checkboxes
label
input[type="checkbox"]
&.btn-small
&[data-levels="date"] > swp-day-header
&[data-levels="resource date"]
&[data-levels="team resource date"]
&[data-levels="department resource date"]
&[data-hidden="true"]
@@ -257,19 +257,19 @@

calendar-v2-events.css

- - 20 unused rules + + 26 unused rules - Original: 7298 | After purge: 6810 + Original: 7047 | After purge: 6504

Show unused selectors
- &:hover
&[data-continues-before="true"]
&[data-continues-after="true"]
swp-events-layer[data-filter-active="true"] swp-event
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]
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
.is-pink
.is-magenta
.is-violet
.is-deep-purple
.is-indigo
.is-light-blue
.is-cyan
.is-teal
.is-light-green
.is-lime
.is-yellow
.is-orange
.is-deep-orange + &.drag-ghost
&:hover
&[data-continues-before="true"]
&[data-continues-after="true"]
swp-events-layer[data-filter-active="true"] swp-event
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]
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
.is-red
.is-pink
.is-magenta
.is-purple
.is-violet
.is-deep-purple
.is-indigo
.is-blue
.is-light-blue
.is-cyan
.is-teal
.is-green
.is-light-green
.is-lime
.is-yellow
.is-amber
.is-orange
.is-deep-orange
@@ -280,13 +280,21 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev

calendar-v2-base.css

- 0 unused rules + 1 unused rules - Original: 1701 | After purge: 1701 + Original: 1574 | After purge: 1570

-

✅ No unused CSS found!

+ +
+ Show unused selectors +
+ body + +
+
+
@@ -297,12 +305,12 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev
  • ✅ CSS usage is relatively clean.
  • 📦 Consider consolidating similar styles to reduce duplication.
  • -
  • 🎨 Review color palette - found 38 unique colors across all files.
  • +
  • 🎨 Review color palette - found 39 unique colors across all files.
  • 🔄 Implement a build process to automatically remove unused CSS in production.
  • -

    Report generated: 11.12.2025, 00.08.52

    +

    Report generated: 17.12.2025, 21.36.53

    diff --git a/reports/css-stats.json b/reports/css-stats.json index 4ab1aeb..8261e76 100644 --- a/reports/css-stats.json +++ b/reports/css-stats.json @@ -33,14 +33,15 @@ "mediaQueries": 0 }, "calendar-v2-layout.css": { - "lines": 308, - "size": "6.39 KB", - "sizeBytes": 6548, - "rules": 38, - "selectors": 48, - "properties": 153, - "uniqueColors": 1, + "lines": 428, + "size": "8.65 KB", + "sizeBytes": 8857, + "rules": 56, + "selectors": 71, + "properties": 219, + "uniqueColors": 2, "colors": [ + "rgba(0,0,0,0.1)", "rgba(0, 0, 0, 0.05)" ], "mediaQueries": 0 diff --git a/reports/purgecss-report.json b/reports/purgecss-report.json index 212f36b..6abb198 100644 --- a/reports/purgecss-report.json +++ b/reports/purgecss-report.json @@ -1,11 +1,11 @@ { "summary": { "totalFiles": 4, - "totalOriginalSize": 15460, - "totalPurgedSize": 14900, - "totalRejected": 23, - "percentageRemoved": "0.15%", - "potentialSavings": 560 + "totalOriginalSize": 15894, + "totalPurgedSize": 15060, + "totalRejected": 43, + "percentageRemoved": "0.27%", + "potentialSavings": 834 }, "fileDetails": { "calendar-v2.css": { @@ -15,20 +15,34 @@ "rejected": [] }, "calendar-v2-layout.css": { - "originalSize": 6275, - "purgedSize": 6203, - "rejectedCount": 3, + "originalSize": 7087, + "purgedSize": 6800, + "rejectedCount": 16, "rejected": [ + ".view-chip", "&:hover", + "&.active", + ".workweek-dropdown", + "&:focus", + "fieldset", + "legend", + ".resource-checkboxes", + "label", + "input[type=\"checkbox\"]", + "&.btn-small", + "&[data-levels=\"date\"] > swp-day-header", "&[data-levels=\"resource date\"]", - "&[data-levels=\"team resource date\"]" + "&[data-levels=\"team resource date\"]", + "&[data-levels=\"department resource date\"]", + "&[data-hidden=\"true\"]" ] }, "calendar-v2-events.css": { - "originalSize": 7298, - "purgedSize": 6810, - "rejectedCount": 20, + "originalSize": 7047, + "purgedSize": 6504, + "rejectedCount": 26, "rejected": [ + "&.drag-ghost", "&:hover", "&[data-continues-before=\"true\"]", "&[data-continues-after=\"true\"]", @@ -36,26 +50,33 @@ "swp-events-layer[data-filter-active=\"true\"] swp-event[data-matches=\"true\"]", "swp-event[data-stack-link]:not([data-stack-link*='\"stackLevel\":0'])", "\nswp-event-group[data-stack-link]:not([data-stack-link*='\"stackLevel\":0']) swp-event", + ".is-red", ".is-pink", ".is-magenta", + ".is-purple", ".is-violet", ".is-deep-purple", ".is-indigo", + ".is-blue", ".is-light-blue", ".is-cyan", ".is-teal", + ".is-green", ".is-light-green", ".is-lime", ".is-yellow", + ".is-amber", ".is-orange", ".is-deep-orange" ] }, "calendar-v2-base.css": { - "originalSize": 1701, - "purgedSize": 1701, - "rejectedCount": 0, - "rejected": [] + "originalSize": 1574, + "purgedSize": 1570, + "rejectedCount": 1, + "rejected": [ + "body" + ] } } } \ No newline at end of file diff --git a/src/v2/V2CompositionRoot.ts b/src/CompositionRoot.ts similarity index 97% rename from src/v2/V2CompositionRoot.ts rename to src/CompositionRoot.ts index 27e2b9a..9dfca0e 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -95,7 +95,7 @@ const defaultGridConfig: IGridConfig = { gridStartThresholdMinutes: 30 }; -export function createV2Container(): Container { +export function createContainer(): Container { const container = new Container(); const builder = container.builder(); diff --git a/src/components/NavigationButtons.ts b/src/components/NavigationButtons.ts deleted file mode 100644 index 5011a64..0000000 --- a/src/components/NavigationButtons.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { IEventBus, CalendarView } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { DateService } from '../utils/DateService'; -import { Configuration } from '../configurations/CalendarConfig'; -import { INavButtonClickedEventPayload } from '../types/EventTypes'; - -/** - * NavigationButtons - Manages navigation button UI and navigation logic - * - * RESPONSIBILITY: - * =============== - * This manager owns all logic related to the UI element - * and performs the actual navigation calculations. - * - * RESPONSIBILITIES: - * - Handles button clicks on swp-nav-button elements - * - Validates navigation actions (prev, next, today) - * - Calculates next/previous dates based on current view - * - Emits NAVIGATION_COMPLETED events with new date - * - Manages button UI listeners - * - * EVENT FLOW: - * =========== - * User clicks button → calculateNewDate() → emit NAVIGATION_COMPLETED → GridManager re-renders - */ -export class NavigationButtons { - private eventBus: IEventBus; - private buttonListeners: Map = new Map(); - private dateService: DateService; - private config: Configuration; - private currentDate: Date = new Date(); - private currentView: CalendarView = 'week'; - - constructor( - eventBus: IEventBus, - dateService: DateService, - config: Configuration - ) { - this.eventBus = eventBus; - this.dateService = dateService; - this.config = config; - this.setupButtonListeners(); - this.subscribeToEvents(); - } - - /** - * Subscribe to events - */ - private subscribeToEvents(): void { - // Listen for view changes - this.eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => { - const detail = (e as CustomEvent).detail; - this.currentView = detail.currentView; - }); - } - - /** - * Setup click listeners on all navigation buttons - */ - private setupButtonListeners(): void { - const buttons = document.querySelectorAll('swp-nav-button[data-action]'); - - buttons.forEach(button => { - const clickHandler = (event: Event) => { - event.preventDefault(); - const action = button.getAttribute('data-action'); - if (action && this.isValidAction(action)) { - this.handleNavigation(action); - } - }; - - button.addEventListener('click', clickHandler); - this.buttonListeners.set(button, clickHandler); - }); - } - - /** - * Handle navigation action - */ - private handleNavigation(action: string): void { - switch (action) { - case 'prev': - this.navigatePrevious(); - break; - case 'next': - this.navigateNext(); - break; - case 'today': - this.navigateToday(); - break; - } - } - - /** - * Navigate in specified direction - */ - private navigate(direction: 'next' | 'previous'): void { - const offset = direction === 'next' ? 1 : -1; - let newDate: Date; - - switch (this.currentView) { - case 'week': - newDate = this.dateService.addWeeks(this.currentDate, offset); - break; - case 'month': - newDate = this.dateService.addMonths(this.currentDate, offset); - break; - case 'day': - newDate = this.dateService.addDays(this.currentDate, offset); - break; - default: - newDate = this.dateService.addWeeks(this.currentDate, offset); - } - - this.currentDate = newDate; - - const payload: INavButtonClickedEventPayload = { - direction: direction, - newDate: newDate - }; - - this.eventBus.emit(CoreEvents.NAV_BUTTON_CLICKED, payload); - } - - /** - * Navigate to next period - */ - private navigateNext(): void { - this.navigate('next'); - } - - /** - * Navigate to previous period - */ - private navigatePrevious(): void { - this.navigate('previous'); - } - - /** - * Navigate to today - */ - private navigateToday(): void { - this.currentDate = new Date(); - - const payload: INavButtonClickedEventPayload = { - direction: 'today', - newDate: this.currentDate - }; - - this.eventBus.emit(CoreEvents.NAV_BUTTON_CLICKED, payload); - } - - /** - * Validate if string is a valid navigation action - */ - private isValidAction(action: string): boolean { - return ['prev', 'next', 'today'].includes(action); - } -} diff --git a/src/components/ViewSelector.ts b/src/components/ViewSelector.ts deleted file mode 100644 index a82f912..0000000 --- a/src/components/ViewSelector.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { CalendarView, IEventBus } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { Configuration } from '../configurations/CalendarConfig'; - -/** - * ViewSelectorManager - Manages view selector UI and state - * - * RESPONSIBILITY: - * =============== - * This manager owns all logic related to the UI element. - * It follows the principle that each functional UI element has its own manager. - * - * RESPONSIBILITIES: - * - Handles button clicks on swp-view-button elements - * - Manages current view state (day/week/month) - * - Validates view values - * - Emits VIEW_CHANGED and VIEW_RENDERED events - * - Updates button UI states (data-active attributes) - * - * EVENT FLOW: - * =========== - * User clicks button → changeView() → validate → update state → emit event → update UI - * - * IMPLEMENTATION STATUS: - * ====================== - * - Week view: FULLY IMPLEMENTED - * - Day view: NOT IMPLEMENTED (button exists but no rendering) - * - Month view: NOT IMPLEMENTED (button exists but no rendering) - * - * SUBSCRIBERS: - * ============ - * - GridRenderer: Uses view parameter (currently only supports 'week') - * - Future: DayRenderer, MonthRenderer when implemented - */ -export class ViewSelector { - private eventBus: IEventBus; - private config: Configuration; - private buttonListeners: Map = new Map(); - - constructor(eventBus: IEventBus, config: Configuration) { - this.eventBus = eventBus; - this.config = config; - - this.setupButtonListeners(); - this.setupEventListeners(); - } - - /** - * Setup click listeners on all view selector buttons - */ - private setupButtonListeners(): void { - const buttons = document.querySelectorAll('swp-view-button[data-view]'); - - buttons.forEach(button => { - const clickHandler = (event: Event) => { - event.preventDefault(); - const view = button.getAttribute('data-view'); - if (view && this.isValidView(view)) { - this.changeView(view as CalendarView); - } - }; - - button.addEventListener('click', clickHandler); - this.buttonListeners.set(button, clickHandler); - }); - - // Initialize button states - this.updateButtonStates(); - } - - /** - * Setup event bus listeners - */ - private setupEventListeners(): void { - this.eventBus.on(CoreEvents.INITIALIZED, () => { - this.initializeView(); - }); - - this.eventBus.on(CoreEvents.DATE_CHANGED, () => { - this.refreshCurrentView(); - }); - } - - /** - * Change the active view - */ - private changeView(newView: CalendarView): void { - if (newView === this.config.currentView) { - return; // No change - } - - const previousView = this.config.currentView; - this.config.currentView = newView; - - // Update button UI states - this.updateButtonStates(); - - // Emit event for subscribers - this.eventBus.emit(CoreEvents.VIEW_CHANGED, { - previousView, - currentView: newView - }); - } - - /** - * Update button states (data-active attributes) - */ - private updateButtonStates(): void { - const buttons = document.querySelectorAll('swp-view-button[data-view]'); - - buttons.forEach(button => { - const buttonView = button.getAttribute('data-view'); - - if (buttonView === this.config.currentView) { - button.setAttribute('data-active', 'true'); - } else { - button.removeAttribute('data-active'); - } - }); - } - - /** - * Initialize view on INITIALIZED event - */ - private initializeView(): void { - this.updateButtonStates(); - this.emitViewRendered(); - } - - /** - * Emit VIEW_RENDERED event - */ - private emitViewRendered(): void { - this.eventBus.emit(CoreEvents.VIEW_RENDERED, { - view: this.config.currentView - }); - } - - /** - * Refresh current view on DATE_CHANGED event - */ - private refreshCurrentView(): void { - this.emitViewRendered(); - } - - /** - * Validate if string is a valid CalendarView type - */ - private isValidView(view: string): view is CalendarView { - return ['day', 'week', 'month'].includes(view); - } -} diff --git a/src/components/WorkweekPresets.ts b/src/components/WorkweekPresets.ts deleted file mode 100644 index 1a1a99c..0000000 --- a/src/components/WorkweekPresets.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { IEventBus } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { IWorkWeekSettings } from '../configurations/WorkWeekSettings'; -import { WORK_WEEK_PRESETS, Configuration } from '../configurations/CalendarConfig'; - -/** - * WorkweekPresetsManager - Manages workweek preset UI and state - * - * RESPONSIBILITY: - * =============== - * This manager owns all logic related to the UI element. - * It follows the principle that each functional UI element has its own manager. - * - * RESPONSIBILITIES: - * - Owns WORK_WEEK_PRESETS data - * - Handles button clicks on swp-preset-button elements - * - Manages current workweek preset state - * - Validates preset IDs - * - Emits WORKWEEK_CHANGED events - * - Updates button UI states (data-active attributes) - * - * EVENT FLOW: - * =========== - * User clicks button → changePreset() → validate → update state → emit event → update UI - * - * SUBSCRIBERS: - * ============ - * - ConfigManager: Updates CSS variables (--grid-columns) - * - GridManager: Re-renders grid with new column count - * - CalendarManager: Relays to header update (via workweek:header-update) - * - HeaderManager: Updates date headers - */ -export class WorkweekPresets { - private eventBus: IEventBus; - private config: Configuration; - private buttonListeners: Map = new Map(); - - constructor(eventBus: IEventBus, config: Configuration) { - this.eventBus = eventBus; - this.config = config; - - this.setupButtonListeners(); - } - - /** - * Setup click listeners on all workweek preset buttons - */ - private setupButtonListeners(): void { - const buttons = document.querySelectorAll('swp-preset-button[data-workweek]'); - - buttons.forEach(button => { - const clickHandler = (event: Event) => { - event.preventDefault(); - const presetId = button.getAttribute('data-workweek'); - if (presetId) { - this.changePreset(presetId); - } - }; - - button.addEventListener('click', clickHandler); - this.buttonListeners.set(button, clickHandler); - }); - - // Initialize button states - this.updateButtonStates(); - } - - /** - * Change the active workweek preset - */ - private changePreset(presetId: string): void { - if (!WORK_WEEK_PRESETS[presetId]) { - console.warn(`Invalid preset ID "${presetId}"`); - return; - } - - if (presetId === this.config.currentWorkWeek) { - return; // No change - } - - const previousPresetId = this.config.currentWorkWeek; - this.config.currentWorkWeek = presetId; - - const settings = WORK_WEEK_PRESETS[presetId]; - - // Update button UI states - this.updateButtonStates(); - - // Emit event for subscribers - this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, { - workWeekId: presetId, - previousWorkWeekId: previousPresetId, - settings: settings - }); - } - - /** - * Update button states (data-active attributes) - */ - private updateButtonStates(): void { - const buttons = document.querySelectorAll('swp-preset-button[data-workweek]'); - - buttons.forEach(button => { - const buttonPresetId = button.getAttribute('data-workweek'); - - if (buttonPresetId === this.config.currentWorkWeek) { - button.setAttribute('data-active', 'true'); - } else { - button.removeAttribute('data-active'); - } - }); - } - -} diff --git a/src/configurations/CalendarConfig.ts b/src/configurations/CalendarConfig.ts deleted file mode 100644 index 4340128..0000000 --- a/src/configurations/CalendarConfig.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ICalendarConfig } from './ICalendarConfig'; -import { IGridSettings } from './GridSettings'; -import { IDateViewSettings } from './DateViewSettings'; -import { ITimeFormatConfig } from './TimeFormatConfig'; -import { IWorkWeekSettings } from './WorkWeekSettings'; -import { CalendarView } from '../types/CalendarTypes'; - -/** - * All-day event layout constants - */ -export const ALL_DAY_CONSTANTS = { - EVENT_HEIGHT: 22, - EVENT_GAP: 2, - CONTAINER_PADDING: 4, - MAX_COLLAPSED_ROWS: 4, - get SINGLE_ROW_HEIGHT() { - return this.EVENT_HEIGHT + this.EVENT_GAP; // 28px - } -} as const; - -/** - * Work week presets - Configuration data - */ -export const WORK_WEEK_PRESETS: { [key: string]: IWorkWeekSettings } = { - 'standard': { - id: 'standard', - workDays: [1, 2, 3, 4, 5], - totalDays: 5, - firstWorkDay: 1 - }, - 'compressed': { - id: 'compressed', - workDays: [1, 2, 3, 4], - totalDays: 4, - firstWorkDay: 1 - }, - 'midweek': { - id: 'midweek', - workDays: [3, 4, 5], - totalDays: 3, - firstWorkDay: 3 - }, - 'weekend': { - id: 'weekend', - workDays: [6, 7], - totalDays: 2, - firstWorkDay: 6 - }, - 'fullweek': { - id: 'fullweek', - workDays: [1, 2, 3, 4, 5, 6, 7], - totalDays: 7, - firstWorkDay: 1 - } -}; - -/** - * Configuration - DTO container for all configuration - * Pure data object loaded from JSON via ConfigManager - */ -export class Configuration { - private static _instance: Configuration | null = null; - - public config: ICalendarConfig; - public gridSettings: IGridSettings; - public dateViewSettings: IDateViewSettings; - public timeFormatConfig: ITimeFormatConfig; - public currentWorkWeek: string; - public currentView: CalendarView; - public selectedDate: Date; - public apiEndpoint: string = '/api'; - - constructor( - config: ICalendarConfig, - gridSettings: IGridSettings, - dateViewSettings: IDateViewSettings, - timeFormatConfig: ITimeFormatConfig, - currentWorkWeek: string, - currentView: CalendarView, - selectedDate: Date = new Date() - ) { - this.config = config; - this.gridSettings = gridSettings; - this.dateViewSettings = dateViewSettings; - this.timeFormatConfig = timeFormatConfig; - this.currentWorkWeek = currentWorkWeek; - this.currentView = currentView; - this.selectedDate = selectedDate; - - // Store as singleton instance for web components - Configuration._instance = this; - } - - /** - * Get the current Configuration instance - * Used by web components that can't use dependency injection - */ - public static getInstance(): Configuration { - if (!Configuration._instance) { - throw new Error('Configuration has not been initialized. Call ConfigManager.load() first.'); - } - return Configuration._instance; - } - - setSelectedDate(date: Date): void { - this.selectedDate = date; - } - - getWorkWeekSettings(): IWorkWeekSettings { - return WORK_WEEK_PRESETS[this.currentWorkWeek] || WORK_WEEK_PRESETS['standard']; - } -} - -// Backward compatibility alias -export { Configuration as CalendarConfig }; diff --git a/src/configurations/ConfigManager.ts b/src/configurations/ConfigManager.ts deleted file mode 100644 index c4532af..0000000 --- a/src/configurations/ConfigManager.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Configuration } from './CalendarConfig'; -import { ICalendarConfig } from './ICalendarConfig'; -import { TimeFormatter } from '../utils/TimeFormatter'; -import { IEventBus } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { IWorkWeekSettings } from './WorkWeekSettings'; - -/** - * ConfigManager - Configuration loader and CSS property manager - * Loads JSON and creates Configuration instance - * Listens to events and manages CSS custom properties for dynamic styling - */ -export class ConfigManager { - private eventBus: IEventBus; - private config: Configuration; - - constructor(eventBus: IEventBus, config: Configuration) { - this.eventBus = eventBus; - this.config = config; - - this.setupEventListeners(); - this.syncGridCSSVariables(); - this.syncWorkweekCSSVariables(); - } - - /** - * Setup event listeners for dynamic CSS updates - */ - private setupEventListeners(): void { - // Listen to workweek changes and update CSS accordingly - this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => { - const { settings } = (event as CustomEvent<{ settings: IWorkWeekSettings }>).detail; - this.syncWorkweekCSSVariables(settings); - }); - } - - /** - * Sync grid-related CSS variables from configuration - */ - private syncGridCSSVariables(): void { - const gridSettings = this.config.gridSettings; - - document.documentElement.style.setProperty('--hour-height', `${gridSettings.hourHeight}px`); - document.documentElement.style.setProperty('--day-start-hour', gridSettings.dayStartHour.toString()); - document.documentElement.style.setProperty('--day-end-hour', gridSettings.dayEndHour.toString()); - document.documentElement.style.setProperty('--work-start-hour', gridSettings.workStartHour.toString()); - document.documentElement.style.setProperty('--work-end-hour', gridSettings.workEndHour.toString()); - } - - /** - * Sync workweek-related CSS variables - */ - private syncWorkweekCSSVariables(workWeekSettings?: IWorkWeekSettings): void { - const settings = workWeekSettings || this.config.getWorkWeekSettings(); - document.documentElement.style.setProperty('--grid-columns', settings.totalDays.toString()); - } - - /** - * Load configuration from JSON and create Configuration instance - */ - static async load(): Promise { - const response = await fetch('/wwwroot/data/calendar-config.json'); - if (!response.ok) { - throw new Error(`Failed to load config: ${response.statusText}`); - } - - const data = await response.json(); - - // Build main config - const mainConfig: ICalendarConfig = { - scrollbarWidth: data.scrollbar.width, - scrollbarColor: data.scrollbar.color, - scrollbarTrackColor: data.scrollbar.trackColor, - scrollbarHoverColor: data.scrollbar.hoverColor, - scrollbarBorderRadius: data.scrollbar.borderRadius, - allowDrag: data.interaction.allowDrag, - allowResize: data.interaction.allowResize, - allowCreate: data.interaction.allowCreate, - apiEndpoint: data.api.endpoint, - dateFormat: data.api.dateFormat, - timeFormat: data.api.timeFormat, - enableSearch: data.features.enableSearch, - enableTouch: data.features.enableTouch, - defaultEventDuration: data.eventDefaults.defaultEventDuration, - minEventDuration: data.gridSettings.snapInterval, - maxEventDuration: data.eventDefaults.maxEventDuration - }; - - // Create Configuration instance - const config = new Configuration( - mainConfig, - data.gridSettings, - data.dateViewSettings, - data.timeFormatConfig, - data.currentWorkWeek, - data.currentView || 'week' - ); - - // Configure TimeFormatter - TimeFormatter.configure(config.timeFormatConfig); - - return config; - } -} diff --git a/src/configurations/DateViewSettings.ts b/src/configurations/DateViewSettings.ts deleted file mode 100644 index ae9e1ea..0000000 --- a/src/configurations/DateViewSettings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ViewPeriod } from '../types/CalendarTypes'; - -/** - * View settings for date-based calendar mode - */ -export interface IDateViewSettings { - period: ViewPeriod; - weekDays: number; - firstDayOfWeek: number; - showAllDay: boolean; -} diff --git a/src/configurations/GridSettings.ts b/src/configurations/GridSettings.ts deleted file mode 100644 index 511e45a..0000000 --- a/src/configurations/GridSettings.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Grid display settings interface - */ -export interface IGridSettings { - dayStartHour: number; - dayEndHour: number; - workStartHour: number; - workEndHour: number; - hourHeight: number; - snapInterval: number; - fitToWidth: boolean; - scrollToHour: number | null; - gridStartThresholdMinutes: number; - showCurrentTime: boolean; - showWorkHours: boolean; -} - -/** - * Grid settings utility functions - */ -export namespace GridSettingsUtils { - export function isValidSnapInterval(interval: number): boolean { - return [5, 10, 15, 30, 60].includes(interval); - } -} diff --git a/src/configurations/ICalendarConfig.ts b/src/configurations/ICalendarConfig.ts deleted file mode 100644 index aa291e4..0000000 --- a/src/configurations/ICalendarConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Main calendar configuration interface - */ -export interface ICalendarConfig { - // Scrollbar styling - scrollbarWidth: number; - scrollbarColor: string; - scrollbarTrackColor: string; - scrollbarHoverColor: string; - scrollbarBorderRadius: number; - - // Interaction settings - allowDrag: boolean; - allowResize: boolean; - allowCreate: boolean; - - // API settings - apiEndpoint: string; - dateFormat: string; - timeFormat: string; - - // Feature flags - enableSearch: boolean; - enableTouch: boolean; - - // Event defaults - defaultEventDuration: number; - minEventDuration: number; - maxEventDuration: number; -} diff --git a/src/configurations/TimeFormatConfig.ts b/src/configurations/TimeFormatConfig.ts deleted file mode 100644 index 2bb9207..0000000 --- a/src/configurations/TimeFormatConfig.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Time format configuration settings - */ -export interface ITimeFormatConfig { - timezone: string; - use24HourFormat: boolean; - locale: string; - dateFormat: 'locale' | 'technical'; - showSeconds: boolean; -} diff --git a/src/configurations/WorkWeekSettings.ts b/src/configurations/WorkWeekSettings.ts deleted file mode 100644 index 7c01b99..0000000 --- a/src/configurations/WorkWeekSettings.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Work week configuration settings - */ -export interface IWorkWeekSettings { - id: string; - workDays: number[]; - totalDays: number; - firstWorkDay: number; -} diff --git a/src/constants/CoreEvents.ts b/src/constants/CoreEvents.ts index 983e121..7363138 100644 --- a/src/constants/CoreEvents.ts +++ b/src/constants/CoreEvents.ts @@ -1,61 +1,71 @@ /** * CoreEvents - Consolidated essential events for the calendar - * Reduces complexity from 102+ events to ~20 core events */ export const CoreEvents = { - // Lifecycle events (3) + // Lifecycle events INITIALIZED: 'core:initialized', READY: 'core:ready', DESTROYED: 'core:destroyed', - - // View events (3) + + // View events VIEW_CHANGED: 'view:changed', VIEW_RENDERED: 'view:rendered', - WORKWEEK_CHANGED: 'workweek:changed', - - // Navigation events (4) - NAV_BUTTON_CLICKED: 'nav:button-clicked', + + // Navigation events DATE_CHANGED: 'nav:date-changed', NAVIGATION_COMPLETED: 'nav:navigation-completed', - NAVIGATE_TO_EVENT: 'nav:navigate-to-event', - - // Data events (5) + + // Data events DATA_LOADING: 'data:loading', DATA_LOADED: 'data:loaded', DATA_ERROR: 'data:error', - EVENTS_FILTERED: 'data:events-filtered', - REMOTE_UPDATE_RECEIVED: 'data:remote-update', - - // Grid events (3) + + // Grid events GRID_RENDERED: 'grid:rendered', GRID_CLICKED: 'grid:clicked', - CELL_SELECTED: 'grid:cell-selected', - - // Event management (4) + + // Event management EVENT_CREATED: 'event:created', EVENT_UPDATED: 'event:updated', EVENT_DELETED: 'event:deleted', EVENT_SELECTED: 'event:selected', - - // System events (3) - ERROR: 'system:error', - REFRESH_REQUESTED: 'system:refresh', - OFFLINE_MODE_CHANGED: 'system:offline-mode-changed', - // Sync events (4) + // 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', - SYNC_RETRY: 'sync:retry', - // Entity events (3) - for audit and sync + // Entity events - for audit and sync ENTITY_SAVED: 'entity:saved', ENTITY_DELETED: 'entity:deleted', + + // Audit events AUDIT_LOGGED: 'audit:logged', - - // Filter events (1) - FILTER_CHANGED: 'filter:changed', - - // Rendering events (1) + + // Rendering events EVENTS_RENDERED: 'events:rendered' -} as const; \ No newline at end of file +} as const; diff --git a/src/v2/core/BaseGroupingRenderer.ts b/src/core/BaseGroupingRenderer.ts similarity index 100% rename from src/v2/core/BaseGroupingRenderer.ts rename to src/core/BaseGroupingRenderer.ts diff --git a/src/v2/core/CalendarApp.ts b/src/core/CalendarApp.ts similarity index 85% rename from src/v2/core/CalendarApp.ts rename to src/core/CalendarApp.ts index af44af2..b590250 100644 --- a/src/v2/core/CalendarApp.ts +++ b/src/core/CalendarApp.ts @@ -11,11 +11,15 @@ import { ResizeManager } from '../managers/ResizeManager'; import { EventPersistenceManager } from '../managers/EventPersistenceManager'; import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer'; import { SettingsService } from '../storage/settings/SettingsService'; -import { ResourceService } from '../storage/resources/ResourceService'; import { ViewConfigService } from '../storage/viewconfigs/ViewConfigService'; import { IWorkweekPreset } from '../types/SettingsTypes'; import { IEventBus } from '../types/CalendarTypes'; -import { CalendarEvents } from './CalendarEvents'; +import { + CalendarEvents, + RenderPayload, + WorkweekChangePayload, + ViewUpdatePayload +} from './CalendarEvents'; export class CalendarApp { private animator!: NavigationAnimator; @@ -37,7 +41,6 @@ export class CalendarApp { private headerDrawerRenderer: HeaderDrawerRenderer, private eventPersistenceManager: EventPersistenceManager, private settingsService: SettingsService, - private resourceService: ResourceService, private viewConfigService: ViewConfigService, private eventBus: IEventBus ) {} @@ -45,7 +48,12 @@ export class CalendarApp { async init(container: HTMLElement): Promise { this.container = container; - // Load default workweek preset from settings + // 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 @@ -54,11 +62,11 @@ export class CalendarApp { container.querySelector('swp-content-track') as HTMLElement ); - // Render time axis (from settings later, hardcoded for now) + // Render time axis from settings this.timeAxisRenderer.render( container.querySelector('#time-axis') as HTMLElement, - 6, - 18 + gridSettings.dayStartHour, + gridSettings.dayEndHour ); // Init managers @@ -93,22 +101,22 @@ export class CalendarApp { }); // Render command via EventBus - this.eventBus.on(CalendarEvents.CMD_RENDER, ((e: CustomEvent) => { - const { viewId } = e.detail; + this.eventBus.on(CalendarEvents.CMD_RENDER, (e: Event) => { + const { viewId } = (e as CustomEvent).detail; this.handleRenderCommand(viewId); - }) as EventListener); + }); // Workweek change via EventBus - this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, ((e: CustomEvent) => { - const { presetId } = e.detail; + this.eventBus.on(CalendarEvents.CMD_WORKWEEK_CHANGE, (e: Event) => { + const { presetId } = (e as CustomEvent).detail; this.handleWorkweekChange(presetId); - }) as EventListener); + }); // View update via EventBus - this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, ((e: CustomEvent) => { - const { type, values } = e.detail; + this.eventBus.on(CalendarEvents.CMD_VIEW_UPDATE, (e: Event) => { + const { type, values } = (e as CustomEvent).detail; this.handleViewUpdate(type, values); - }) as EventListener); + }); } private async handleRenderCommand(viewId: string): Promise { diff --git a/src/v2/core/CalendarEvents.ts b/src/core/CalendarEvents.ts similarity index 62% rename from src/v2/core/CalendarEvents.ts rename to src/core/CalendarEvents.ts index d52b45d..4cf553e 100644 --- a/src/v2/core/CalendarEvents.ts +++ b/src/core/CalendarEvents.ts @@ -10,3 +10,19 @@ export const CalendarEvents = { 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/src/v2/core/CalendarOrchestrator.ts b/src/core/CalendarOrchestrator.ts similarity index 100% rename from src/v2/core/CalendarOrchestrator.ts rename to src/core/CalendarOrchestrator.ts diff --git a/src/v2/core/DateService.ts b/src/core/DateService.ts similarity index 100% rename from src/v2/core/DateService.ts rename to src/core/DateService.ts diff --git a/src/v2/core/EntityResolver.ts b/src/core/EntityResolver.ts similarity index 100% rename from src/v2/core/EntityResolver.ts rename to src/core/EntityResolver.ts diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts index d58a75a..469a73e 100644 --- a/src/core/EventBus.ts +++ b/src/core/EventBus.ts @@ -1,4 +1,3 @@ -// Core EventBus using pure DOM CustomEvents import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes'; /** @@ -9,7 +8,7 @@ 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, @@ -26,10 +25,10 @@ export class EventBus implements IEventBus { */ 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); } @@ -46,7 +45,7 @@ export class EventBus implements IEventBus { */ 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) { @@ -89,19 +88,17 @@ export class EventBus implements IEventBus { /** * Log event with console grouping */ - private logEventWithGrouping(eventType: string, detail: unknown): void { + 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 - const { emoji, color } = this.getCategoryStyle(category); - - // Use collapsed group to reduce visual noise + // Get category emoji and color (used for future console styling) + this.getCategoryStyle(category); } /** @@ -111,11 +108,11 @@ export class EventBus implements IEventBus { 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'; @@ -123,7 +120,7 @@ export class EventBus implements IEventBus { if (lowerType.includes('scroll')) return 'scroll'; if (lowerType.includes('nav') || lowerType.includes('date')) return 'navigation'; if (lowerType.includes('view')) return 'view'; - + return 'default'; } @@ -132,15 +129,15 @@ export class EventBus implements IEventBus { */ private getCategoryStyle(category: string): { emoji: string; color: string } { const styles: { [key: string]: { emoji: string; color: string } } = { - calendar: { emoji: '🗓️', color: '#2196F3' }, + calendar: { emoji: '📅', color: '#2196F3' }, grid: { emoji: '📊', color: '#4CAF50' }, - event: { emoji: '📅', color: '#FF9800' }, + event: { emoji: '📌', color: '#FF9800' }, scroll: { emoji: '📜', color: '#9C27B0' }, navigation: { emoji: '🧭', color: '#F44336' }, - view: { emoji: '👁️', color: '#00BCD4' }, + view: { emoji: '👁', color: '#00BCD4' }, default: { emoji: '📢', color: '#607D8B' } }; - + return styles[category] || styles.default; } @@ -175,6 +172,3 @@ export class EventBus implements IEventBus { this.debug = enabled; } } - -// Create singleton instance -export const eventBus = new EventBus(); \ No newline at end of file diff --git a/src/v2/core/FilterTemplate.ts b/src/core/FilterTemplate.ts similarity index 100% rename from src/v2/core/FilterTemplate.ts rename to src/core/FilterTemplate.ts diff --git a/src/v2/core/HeaderDrawerManager.ts b/src/core/HeaderDrawerManager.ts similarity index 100% rename from src/v2/core/HeaderDrawerManager.ts rename to src/core/HeaderDrawerManager.ts diff --git a/src/v2/core/IEntityResolver.ts b/src/core/IEntityResolver.ts similarity index 100% rename from src/v2/core/IEntityResolver.ts rename to src/core/IEntityResolver.ts diff --git a/src/v2/core/IGridConfig.ts b/src/core/IGridConfig.ts similarity index 100% rename from src/v2/core/IGridConfig.ts rename to src/core/IGridConfig.ts diff --git a/src/v2/core/IGroupingRenderer.ts b/src/core/IGroupingRenderer.ts similarity index 100% rename from src/v2/core/IGroupingRenderer.ts rename to src/core/IGroupingRenderer.ts diff --git a/src/v2/core/IGroupingStore.ts b/src/core/IGroupingStore.ts similarity index 100% rename from src/v2/core/IGroupingStore.ts rename to src/core/IGroupingStore.ts diff --git a/src/v2/core/ITimeFormatConfig.ts b/src/core/ITimeFormatConfig.ts similarity index 100% rename from src/v2/core/ITimeFormatConfig.ts rename to src/core/ITimeFormatConfig.ts diff --git a/src/v2/core/NavigationAnimator.ts b/src/core/NavigationAnimator.ts similarity index 100% rename from src/v2/core/NavigationAnimator.ts rename to src/core/NavigationAnimator.ts diff --git a/src/v2/core/RenderBuilder.ts b/src/core/RenderBuilder.ts similarity index 100% rename from src/v2/core/RenderBuilder.ts rename to src/core/RenderBuilder.ts diff --git a/src/v2/core/ScrollManager.ts b/src/core/ScrollManager.ts similarity index 100% rename from src/v2/core/ScrollManager.ts rename to src/core/ScrollManager.ts diff --git a/src/v2/core/ViewConfig.ts b/src/core/ViewConfig.ts similarity index 100% rename from src/v2/core/ViewConfig.ts rename to src/core/ViewConfig.ts diff --git a/src/datasources/DateColumnDataSource.ts b/src/datasources/DateColumnDataSource.ts deleted file mode 100644 index a916e04..0000000 --- a/src/datasources/DateColumnDataSource.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource'; -import { DateService } from '../utils/DateService'; -import { Configuration } from '../configurations/CalendarConfig'; -import { CalendarView } from '../types/CalendarTypes'; -import { EventService } from '../storage/events/EventService'; - -/** - * DateColumnDataSource - Provides date-based columns - * - * Calculates which dates to display based on: - * - Current date - * - Current view (day/week/month) - * - Workweek settings - * - * Also fetches and filters events per column using EventService. - */ -export class DateColumnDataSource implements IColumnDataSource { - private dateService: DateService; - private config: Configuration; - private eventService: EventService; - private currentDate: Date; - private currentView: CalendarView; - - constructor( - dateService: DateService, - config: Configuration, - eventService: EventService - ) { - this.dateService = dateService; - this.config = config; - this.eventService = eventService; - this.currentDate = new Date(); - this.currentView = this.config.currentView; - } - - /** - * Get columns (dates) to display with their events - * Each column fetches its own events directly from EventService - */ - public async getColumns(): Promise { - let dates: Date[]; - - switch (this.currentView) { - case 'week': - dates = this.getWeekDates(); - break; - case 'month': - dates = this.getMonthDates(); - break; - case 'day': - dates = [this.currentDate]; - break; - default: - dates = this.getWeekDates(); - } - - // Fetch events for each column directly from EventService - const columnsWithEvents = await Promise.all( - dates.map(async date => ({ - identifier: this.dateService.formatISODate(date), - data: date, - events: await this.eventService.getByDateRange( - this.dateService.startOfDay(date), - this.dateService.endOfDay(date) - ), - groupId: 'week' // All columns in date mode share same group for spanning - })) - ); - - return columnsWithEvents; - } - - /** - * Get type of datasource - */ - public getType(): 'date' | 'resource' { - return 'date'; - } - - /** - * Check if this datasource is in resource mode - */ - public isResource(): boolean { - return false; - } - - /** - * Update current date - */ - public setCurrentDate(date: Date): void { - this.currentDate = date; - } - - /** - * Get current date - */ - public getCurrentDate(): Date { - return this.currentDate; - } - - /** - * Update current view - */ - public setCurrentView(view: CalendarView): void { - this.currentView = view; - } - - /** - * Get dates for week view based on workweek settings - */ - private getWeekDates(): Date[] { - const weekStart = this.getISOWeekStart(this.currentDate); - const workWeekSettings = this.config.getWorkWeekSettings(); - return this.dateService.getWorkWeekDates(weekStart, workWeekSettings.workDays); - } - - /** - * Get all dates in current month - */ - private getMonthDates(): Date[] { - const dates: Date[] = []; - const monthStart = this.getMonthStart(this.currentDate); - const monthEnd = this.getMonthEnd(this.currentDate); - - const totalDays = Math.ceil((monthEnd.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24)) + 1; - - for (let i = 0; i < totalDays; i++) { - dates.push(this.dateService.addDays(monthStart, i)); - } - - return dates; - } - - /** - * Get ISO week start (Monday) - */ - private getISOWeekStart(date: Date): Date { - const weekBounds = this.dateService.getWeekBounds(date); - return this.dateService.startOfDay(weekBounds.start); - } - - /** - * Get month start - */ - private getMonthStart(date: Date): Date { - const year = date.getFullYear(); - const month = date.getMonth(); - return this.dateService.startOfDay(new Date(year, month, 1)); - } - - /** - * Get month end - */ - private getMonthEnd(date: Date): Date { - const nextMonth = this.dateService.addMonths(date, 1); - const firstOfNextMonth = this.getMonthStart(nextMonth); - return this.dateService.endOfDay(this.dateService.addDays(firstOfNextMonth, -1)); - } -} diff --git a/src/datasources/ResourceColumnDataSource.ts b/src/datasources/ResourceColumnDataSource.ts deleted file mode 100644 index 6d1df45..0000000 --- a/src/datasources/ResourceColumnDataSource.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { IColumnDataSource, IColumnInfo } from '../types/ColumnDataSource'; -import { CalendarView } from '../types/CalendarTypes'; -import { ResourceService } from '../storage/resources/ResourceService'; -import { EventService } from '../storage/events/EventService'; -import { DateService } from '../utils/DateService'; - -/** - * ResourceColumnDataSource - Provides resource-based columns - * - * In resource mode, columns represent resources (people, rooms, etc.) - * instead of dates. Events are filtered by current date AND resourceId. - */ -export class ResourceColumnDataSource implements IColumnDataSource { - private resourceService: ResourceService; - private eventService: EventService; - private dateService: DateService; - private currentDate: Date; - private currentView: CalendarView; - - constructor( - resourceService: ResourceService, - eventService: EventService, - dateService: DateService - ) { - this.resourceService = resourceService; - this.eventService = eventService; - this.dateService = dateService; - this.currentDate = new Date(); - this.currentView = 'day'; - } - - /** - * Get columns (resources) to display with their events - */ - public async getColumns(): Promise { - const resources = await this.resourceService.getActive(); - const startDate = this.dateService.startOfDay(this.currentDate); - const endDate = this.dateService.endOfDay(this.currentDate); - - // Fetch events for each resource in parallel - const columnsWithEvents = await Promise.all( - resources.map(async resource => ({ - identifier: resource.id, - data: resource, - events: await this.eventService.getByResourceAndDateRange(resource.id, startDate, endDate), - groupId: resource.id // Each resource is its own group - no spanning across resources - })) - ); - - return columnsWithEvents; - } - - /** - * Get type of datasource - */ - public getType(): 'date' | 'resource' { - return 'resource'; - } - - /** - * Check if this datasource is in resource mode - */ - public isResource(): boolean { - return true; - } - - /** - * Update current date (for event filtering) - */ - public setCurrentDate(date: Date): void { - this.currentDate = date; - } - - /** - * Update current view - */ - public setCurrentView(view: CalendarView): void { - this.currentView = view; - } - - /** - * Get current date (for event filtering) - */ - public getCurrentDate(): Date { - return this.currentDate; - } -} diff --git a/src/v2/demo/DemoApp.ts b/src/demo/DemoApp.ts similarity index 100% rename from src/v2/demo/DemoApp.ts rename to src/demo/DemoApp.ts diff --git a/src/v2/demo/MockStores.ts b/src/demo/MockStores.ts similarity index 100% rename from src/v2/demo/MockStores.ts rename to src/demo/MockStores.ts diff --git a/src/demo/index.ts b/src/demo/index.ts new file mode 100644 index 0000000..0a17f36 --- /dev/null +++ b/src/demo/index.ts @@ -0,0 +1,5 @@ +import { createContainer } from '../CompositionRoot'; +import { DemoApp } from './DemoApp'; + +const container = createContainer(); +container.resolveType().init().catch(console.error); diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts deleted file mode 100644 index 30c8525..0000000 --- a/src/elements/SwpEventElement.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { ICalendarEvent } from '../types/CalendarTypes'; -import { CalendarEventType } from '../types/BookingTypes'; -import { Configuration } from '../configurations/CalendarConfig'; -import { TimeFormatter } from '../utils/TimeFormatter'; -import { PositionUtils } from '../utils/PositionUtils'; -import { DateService } from '../utils/DateService'; -import { EventId } from '../types/EventId'; - -/** - * Base class for event elements - */ -export abstract class BaseSwpEventElement extends HTMLElement { - protected dateService: DateService; - protected config: Configuration; - - constructor() { - super(); - // Get singleton instance for web components (can't use DI) - this.config = Configuration.getInstance(); - this.dateService = new DateService(this.config); - } - - // ============================================ - // Abstract Methods - // ============================================ - - /** - * Create a clone for drag operations - * Must be implemented by subclasses - */ - public abstract createClone(): HTMLElement; - - // ============================================ - // Common Getters/Setters - // ============================================ - - get eventId(): string { - return this.dataset.eventId || ''; - } - set eventId(value: string) { - this.dataset.eventId = value; - } - - get start(): Date { - return new Date(this.dataset.start || ''); - } - set start(value: Date) { - this.dataset.start = this.dateService.toUTC(value); - } - - get end(): Date { - return new Date(this.dataset.end || ''); - } - set end(value: Date) { - this.dataset.end = this.dateService.toUTC(value); - } - - get title(): string { - return this.dataset.title || ''; - } - set title(value: string) { - this.dataset.title = value; - } - - get description(): string { - return this.dataset.description || ''; - } - set description(value: string) { - this.dataset.description = value; - } - - get type(): string { - return this.dataset.type || 'work'; - } - set type(value: string) { - this.dataset.type = value; - } -} - -/** - * Web Component for timed calendar events (Light DOM) - */ -export class SwpEventElement extends BaseSwpEventElement { - - /** - * Observed attributes - changes trigger attributeChangedCallback - */ - static get observedAttributes() { - return ['data-start', 'data-end', 'data-title', 'data-description', 'data-type']; - } - - /** - * Called when element is added to DOM - */ - connectedCallback() { - if (!this.hasChildNodes()) { - this.render(); - } - } - - /** - * Called when observed attribute changes - */ - attributeChangedCallback(name: string, oldValue: string, newValue: string) { - if (oldValue !== newValue && this.isConnected) { - this.updateDisplay(); - } - } - - // ============================================ - // Public Methods - // ============================================ - - /** - * Update event position during drag - * Uses the event's existing date, only updates the time based on Y position - * @param snappedY - The Y position in pixels - */ - public updatePosition(snappedY: number): void { - // 1. Update visual position - this.style.top = `${snappedY + 1}px`; - - // 2. Calculate new timestamps (keep existing date, only change time) - const existingDate = this.start; - const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY); - - // 3. Update data attributes (triggers attributeChangedCallback) - const startDate = this.dateService.createDateAtTime(existingDate, startMinutes); - let endDate = this.dateService.createDateAtTime(existingDate, endMinutes); - - // Handle cross-midnight events - if (endMinutes >= 1440) { - const extraDays = Math.floor(endMinutes / 1440); - endDate = this.dateService.addDays(endDate, extraDays); - } - - this.start = startDate; - this.end = endDate; - } - - /** - * Update event height during resize - * @param newHeight - The new height in pixels - */ - public updateHeight(newHeight: number): void { - // 1. Update visual height - this.style.height = `${newHeight}px`; - - // 2. Calculate new end time based on height - const gridSettings = this.config.gridSettings; - const { hourHeight, snapInterval } = gridSettings; - - // Get current start time - const start = this.start; - - // Calculate duration from height - const rawDurationMinutes = (newHeight / hourHeight) * 60; - - // Snap duration to grid interval (like drag & drop) - const snappedDurationMinutes = Math.round(rawDurationMinutes / snapInterval) * snapInterval; - - // Calculate new end time by adding snapped duration to start (using DateService for timezone safety) - const endDate = this.dateService.addMinutes(start, snappedDurationMinutes); - - // 3. Update end attribute (triggers attributeChangedCallback → updateDisplay) - this.end = endDate; - } - - /** - * Create a clone for drag operations - */ - public createClone(): SwpEventElement { - const clone = this.cloneNode(true) as SwpEventElement; - - // Apply "clone-" prefix to ID - clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId); - - // Disable pointer events on clone so it doesn't interfere with hover detection - clone.style.pointerEvents = 'none'; - - // Cache original duration - const timeEl = this.querySelector('swp-event-time'); - if (timeEl) { - const duration = timeEl.getAttribute('data-duration'); - if (duration) { - clone.dataset.originalDuration = duration; - } - } - - // Set height from original - clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`; - - return clone; - } - - // ============================================ - // Private Methods - // ============================================ - - /** - * Render inner HTML structure - */ - private render(): void { - const start = this.start; - const end = this.end; - const timeRange = TimeFormatter.formatTimeRange(start, end); - const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); - - this.innerHTML = ` - ${timeRange} - ${this.title} - ${this.description ? `${this.description}` : ''} - `; - } - - /** - * Update time display when attributes change - */ - private updateDisplay(): void { - const timeEl = this.querySelector('swp-event-time'); - const titleEl = this.querySelector('swp-event-title'); - const descEl = this.querySelector('swp-event-description'); - - if (timeEl && this.dataset.start && this.dataset.end) { - const start = new Date(this.dataset.start); - const end = new Date(this.dataset.end); - const timeRange = TimeFormatter.formatTimeRange(start, end); - timeEl.textContent = timeRange; - - // Update duration attribute - const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); - timeEl.setAttribute('data-duration', durationMinutes.toString()); - } - - if (titleEl && this.dataset.title) { - titleEl.textContent = this.dataset.title; - } - - if (this.dataset.description) { - if (descEl) { - descEl.textContent = this.dataset.description; - } else if (this.description) { - // Add description element if it doesn't exist - const newDescEl = document.createElement('swp-event-description'); - newDescEl.textContent = this.description; - this.appendChild(newDescEl); - } - } else if (descEl) { - // Remove description element if description is empty - descEl.remove(); - } - } - - - /** - * Calculate start/end minutes from Y position - */ - private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } { - const gridSettings = this.config.gridSettings; - const { hourHeight, dayStartHour, snapInterval } = gridSettings; - - // Get original duration - const originalDuration = parseInt( - this.dataset.originalDuration || - this.dataset.duration || - '60' - ); - - // Calculate snapped start minutes - const minutesFromGridStart = (snappedY / hourHeight) * 60; - const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; - const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval; - - // Calculate end minutes - const endMinutes = snappedStartMinutes + originalDuration; - - return { startMinutes: snappedStartMinutes, endMinutes }; - } - - // ============================================ - // Static Factory Methods - // ============================================ - - /** - * Create SwpEventElement from ICalendarEvent - */ - public static fromCalendarEvent(event: ICalendarEvent): SwpEventElement { - const element = document.createElement('swp-event') as SwpEventElement; - const config = Configuration.getInstance(); - const dateService = new DateService(config); - - element.dataset.eventId = event.id; - element.dataset.title = event.title; - element.dataset.description = event.description || ''; - element.dataset.start = dateService.toUTC(event.start); - element.dataset.end = dateService.toUTC(event.end); - element.dataset.type = event.type; - element.dataset.duration = event.metadata?.duration?.toString() || '60'; - - // Apply color class from metadata - if (event.metadata?.color) { - element.classList.add(`is-${event.metadata.color}`); - } - - return element; - } - - /** - * Extract ICalendarEvent from DOM element - */ - public static extractCalendarEventFromElement(element: HTMLElement): ICalendarEvent { - return { - id: element.dataset.eventId || '', - title: element.dataset.title || '', - description: element.dataset.description || undefined, - start: new Date(element.dataset.start || ''), - end: new Date(element.dataset.end || ''), - type: element.dataset.type as CalendarEventType, - allDay: false, - syncStatus: 'synced', - metadata: { - duration: element.dataset.duration - } - }; - } - -} - -/** - * Web Component for all-day calendar events - */ -export class SwpAllDayEventElement extends BaseSwpEventElement { - - connectedCallback() { - if (!this.textContent) { - this.textContent = this.dataset.title || 'Untitled'; - } - } - - /** - * Create a clone for drag operations - */ - public createClone(): SwpAllDayEventElement { - const clone = this.cloneNode(true) as SwpAllDayEventElement; - - // Apply "clone-" prefix to ID - clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId); - - // Disable pointer events on clone so it doesn't interfere with hover detection - clone.style.pointerEvents = 'none'; - - // Preserve full opacity during drag - clone.style.opacity = '1'; - - return clone; - } - - /** - * Apply CSS grid positioning - */ - public applyGridPositioning(row: number, startColumn: number, endColumn: number): void { - const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`; - this.style.gridArea = gridArea; - } - - /** - * Create from ICalendarEvent - */ - public static fromCalendarEvent(event: ICalendarEvent): SwpAllDayEventElement { - const element = document.createElement('swp-allday-event') as SwpAllDayEventElement; - const config = Configuration.getInstance(); - const dateService = new DateService(config); - - element.dataset.eventId = event.id; - element.dataset.title = event.title; - element.dataset.start = dateService.toUTC(event.start); - element.dataset.end = dateService.toUTC(event.end); - element.dataset.type = event.type; - element.dataset.allday = 'true'; - element.textContent = event.title; - - // Apply color class from metadata - if (event.metadata?.color) { - element.classList.add(`is-${event.metadata.color}`); - } - - return element; - } -} - -// Register custom elements -customElements.define('swp-event', SwpEventElement); -customElements.define('swp-allday-event', SwpAllDayEventElement); \ No newline at end of file diff --git a/src/entry.ts b/src/entry.ts new file mode 100644 index 0000000..582279f --- /dev/null +++ b/src/entry.ts @@ -0,0 +1,6 @@ +/** + * Calendar - Standalone Entry Point + */ + +// Re-export everything from index +export * from './index'; diff --git a/src/v2/features/date/DateRenderer.ts b/src/features/date/DateRenderer.ts similarity index 100% rename from src/v2/features/date/DateRenderer.ts rename to src/features/date/DateRenderer.ts diff --git a/src/v2/features/date/index.ts b/src/features/date/index.ts similarity index 100% rename from src/v2/features/date/index.ts rename to src/features/date/index.ts diff --git a/src/v2/features/department/DepartmentRenderer.ts b/src/features/department/DepartmentRenderer.ts similarity index 100% rename from src/v2/features/department/DepartmentRenderer.ts rename to src/features/department/DepartmentRenderer.ts diff --git a/src/v2/features/event/EventLayoutEngine.ts b/src/features/event/EventLayoutEngine.ts similarity index 95% rename from src/v2/features/event/EventLayoutEngine.ts rename to src/features/event/EventLayoutEngine.ts index 4606933..0b10905 100644 --- a/src/v2/features/event/EventLayoutEngine.ts +++ b/src/features/event/EventLayoutEngine.ts @@ -1,11 +1,11 @@ /** - * EventLayoutEngine - Simplified stacking/grouping algorithm for V2 + * 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) * - * Simplified from V1: No prev/next chains, single-pass greedy algorithm + * No prev/next chains, single-pass greedy algorithm */ import { ICalendarEvent } from '../../types/CalendarTypes'; diff --git a/src/v2/features/event/EventLayoutTypes.ts b/src/features/event/EventLayoutTypes.ts similarity index 100% rename from src/v2/features/event/EventLayoutTypes.ts rename to src/features/event/EventLayoutTypes.ts diff --git a/src/v2/features/event/EventRenderer.ts b/src/features/event/EventRenderer.ts similarity index 100% rename from src/v2/features/event/EventRenderer.ts rename to src/features/event/EventRenderer.ts diff --git a/src/v2/features/event/index.ts b/src/features/event/index.ts similarity index 100% rename from src/v2/features/event/index.ts rename to src/features/event/index.ts diff --git a/src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts b/src/features/headerdrawer/HeaderDrawerLayoutEngine.ts similarity index 100% rename from src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts rename to src/features/headerdrawer/HeaderDrawerLayoutEngine.ts diff --git a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts b/src/features/headerdrawer/HeaderDrawerRenderer.ts similarity index 100% rename from src/v2/features/headerdrawer/HeaderDrawerRenderer.ts rename to src/features/headerdrawer/HeaderDrawerRenderer.ts diff --git a/src/v2/features/headerdrawer/index.ts b/src/features/headerdrawer/index.ts similarity index 100% rename from src/v2/features/headerdrawer/index.ts rename to src/features/headerdrawer/index.ts diff --git a/src/v2/features/resource/ResourceRenderer.ts b/src/features/resource/ResourceRenderer.ts similarity index 100% rename from src/v2/features/resource/ResourceRenderer.ts rename to src/features/resource/ResourceRenderer.ts diff --git a/src/v2/features/resource/index.ts b/src/features/resource/index.ts similarity index 100% rename from src/v2/features/resource/index.ts rename to src/features/resource/index.ts diff --git a/src/v2/features/schedule/ScheduleRenderer.ts b/src/features/schedule/ScheduleRenderer.ts similarity index 100% rename from src/v2/features/schedule/ScheduleRenderer.ts rename to src/features/schedule/ScheduleRenderer.ts diff --git a/src/v2/features/schedule/index.ts b/src/features/schedule/index.ts similarity index 100% rename from src/v2/features/schedule/index.ts rename to src/features/schedule/index.ts diff --git a/src/v2/features/team/TeamRenderer.ts b/src/features/team/TeamRenderer.ts similarity index 100% rename from src/v2/features/team/TeamRenderer.ts rename to src/features/team/TeamRenderer.ts diff --git a/src/v2/features/team/index.ts b/src/features/team/index.ts similarity index 100% rename from src/v2/features/team/index.ts rename to src/features/team/index.ts diff --git a/src/v2/features/timeaxis/TimeAxisRenderer.ts b/src/features/timeaxis/TimeAxisRenderer.ts similarity index 100% rename from src/v2/features/timeaxis/TimeAxisRenderer.ts rename to src/features/timeaxis/TimeAxisRenderer.ts diff --git a/src/index.ts b/src/index.ts index 1b8f91d..6e390a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,284 +1,17 @@ -// Main entry point for Calendar Plantempus -import { Container } from '@novadi/core'; -import { eventBus } from './core/EventBus'; -import { ConfigManager } from './configurations/ConfigManager'; -import { Configuration } from './configurations/CalendarConfig'; -import { URLManager } from './utils/URLManager'; -import { ICalendarEvent, IEventBus } from './types/CalendarTypes'; - -// Import all managers -import { EventManager } from './managers/EventManager'; -import { EventRenderingService } from './renderers/EventRendererManager'; -import { GridManager } from './managers/GridManager'; -import { ScrollManager } from './managers/ScrollManager'; -import { NavigationManager } from './managers/NavigationManager'; -import { NavigationButtons } from './components/NavigationButtons'; -import { ViewSelector } from './components/ViewSelector'; -import { CalendarManager } from './managers/CalendarManager'; -import { DragDropManager } from './managers/DragDropManager'; -import { AllDayManager } from './managers/AllDayManager'; -import { ResizeHandleManager } from './managers/ResizeHandleManager'; -import { EdgeScrollManager } from './managers/EdgeScrollManager'; -import { HeaderManager } from './managers/HeaderManager'; -import { WorkweekPresets } from './components/WorkweekPresets'; - -// Import repositories and storage -import { MockEventRepository } from './repositories/MockEventRepository'; -import { MockBookingRepository } from './repositories/MockBookingRepository'; -import { MockCustomerRepository } from './repositories/MockCustomerRepository'; -import { MockResourceRepository } from './repositories/MockResourceRepository'; -import { MockAuditRepository } from './repositories/MockAuditRepository'; -import { IApiRepository } from './repositories/IApiRepository'; -import { IAuditEntry } from './types/AuditTypes'; -import { ApiEventRepository } from './repositories/ApiEventRepository'; -import { ApiBookingRepository } from './repositories/ApiBookingRepository'; -import { ApiCustomerRepository } from './repositories/ApiCustomerRepository'; -import { ApiResourceRepository } from './repositories/ApiResourceRepository'; -import { IndexedDBContext } from './storage/IndexedDBContext'; -import { IStore } from './storage/IStore'; -import { AuditStore } from './storage/audit/AuditStore'; -import { AuditService } from './storage/audit/AuditService'; -import { BookingStore } from './storage/bookings/BookingStore'; -import { CustomerStore } from './storage/customers/CustomerStore'; -import { ResourceStore } from './storage/resources/ResourceStore'; -import { EventStore } from './storage/events/EventStore'; -import { IEntityService } from './storage/IEntityService'; -import { EventService } from './storage/events/EventService'; -import { BookingService } from './storage/bookings/BookingService'; -import { CustomerService } from './storage/customers/CustomerService'; -import { ResourceService } from './storage/resources/ResourceService'; - -// Import workers -import { SyncManager } from './workers/SyncManager'; -import { DataSeeder } from './workers/DataSeeder'; - -// Import renderers -import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer'; -import { DateColumnRenderer, type IColumnRenderer } from './renderers/ColumnRenderer'; -import { DateEventRenderer, type IEventRenderer } from './renderers/EventRenderer'; -import { AllDayEventRenderer } from './renderers/AllDayEventRenderer'; -import { GridRenderer } from './renderers/GridRenderer'; -import { WeekInfoRenderer } from './renderers/WeekInfoRenderer'; - -// Import utilities and services -import { DateService } from './utils/DateService'; -import { TimeFormatter } from './utils/TimeFormatter'; -import { PositionUtils } from './utils/PositionUtils'; -import { AllDayLayoutEngine } from './utils/AllDayLayoutEngine'; -import { WorkHoursManager } from './managers/WorkHoursManager'; -import { EventStackManager } from './managers/EventStackManager'; -import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator'; -import { IColumnDataSource } from './types/ColumnDataSource'; -import { DateColumnDataSource } from './datasources/DateColumnDataSource'; -import { ResourceColumnDataSource } from './datasources/ResourceColumnDataSource'; -import { ResourceHeaderRenderer } from './renderers/ResourceHeaderRenderer'; -import { ResourceColumnRenderer } from './renderers/ResourceColumnRenderer'; -import { IBooking } from './types/BookingTypes'; -import { ICustomer } from './types/CustomerTypes'; -import { IResource } from './types/ResourceTypes'; - -/** - * Handle deep linking functionality after managers are initialized - */ -async function handleDeepLinking(eventManager: EventManager, urlManager: URLManager): Promise { - try { - const eventId = urlManager.parseEventIdFromURL(); - - if (eventId) { - console.log(`Deep linking to event ID: ${eventId}`); - - // Wait a bit for managers to be fully ready - setTimeout(async () => { - const success = await eventManager.navigateToEvent(eventId); - if (!success) { - console.warn(`Deep linking failed: Event with ID ${eventId} not found`); - } - }, 500); - } - } catch (error) { - console.warn('Deep linking failed:', error); - } -} - -/** - * Initialize the calendar application using NovaDI - */ -async function initializeCalendar(): Promise { - try { - // Load configuration from JSON - const config = await ConfigManager.load(); - - // Create NovaDI container - const container = new Container(); - const builder = container.builder(); - - // Enable debug mode for development - eventBus.setDebug(true); - - // Bind core services as instances - builder.registerInstance(eventBus).as(); - - // Register configuration instance - builder.registerInstance(config).as(); - - // Register storage stores (IStore implementations) - // Open/Closed Principle: Adding new entity only requires adding one line here - builder.registerType(BookingStore).as(); - builder.registerType(CustomerStore).as(); - builder.registerType(ResourceStore).as(); - builder.registerType(EventStore).as(); - builder.registerType(AuditStore).as(); - - // Register storage and repository services - builder.registerType(IndexedDBContext).as(); - - // Register Mock repositories (development/testing - load from JSON files) - // Each entity type has its own Mock repository implementing IApiRepository - builder.registerType(MockEventRepository).as>(); - builder.registerType(MockBookingRepository).as>(); - builder.registerType(MockCustomerRepository).as>(); - builder.registerType(MockResourceRepository).as>(); - builder.registerType(MockAuditRepository).as>(); - - - let calendarMode = 'resource' ; - // Register DataSource and HeaderRenderer based on mode - if (calendarMode === 'resource') { - builder.registerType(ResourceColumnDataSource).as(); - builder.registerType(ResourceHeaderRenderer).as(); - } else { - builder.registerType(DateColumnDataSource).as(); - builder.registerType(DateHeaderRenderer).as(); - } - - // Register entity services (sync status management) - // Open/Closed Principle: Adding new entity only requires adding one line here - builder.registerType(EventService).as>(); - builder.registerType(EventService).as(); - builder.registerType(BookingService).as>(); - builder.registerType(CustomerService).as>(); - builder.registerType(ResourceService).as>(); - builder.registerType(ResourceService).as(); - builder.registerType(AuditService).as(); - - // Register workers - builder.registerType(SyncManager).as(); - builder.registerType(DataSeeder).as(); - - // Register renderers - // Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode - if (calendarMode === 'resource') { - builder.registerType(ResourceColumnRenderer).as(); - } else { - builder.registerType(DateColumnRenderer).as(); - } - builder.registerType(DateEventRenderer).as(); - - // Register core services and utilities - builder.registerType(DateService).as(); - builder.registerType(EventStackManager).as(); - builder.registerType(EventLayoutCoordinator).as(); - builder.registerType(WorkHoursManager).as(); - builder.registerType(URLManager).as(); - builder.registerType(TimeFormatter).as(); - builder.registerType(PositionUtils).as(); - // Note: AllDayLayoutEngine is instantiated per-operation with specific dates, not a singleton - builder.registerType(WeekInfoRenderer).as(); - builder.registerType(AllDayEventRenderer).as(); - - builder.registerType(EventRenderingService).as(); - builder.registerType(GridRenderer).as(); - builder.registerType(GridManager).as(); - builder.registerType(ScrollManager).as(); - builder.registerType(NavigationManager).as(); - builder.registerType(NavigationButtons).as(); - builder.registerType(ViewSelector).as(); - builder.registerType(DragDropManager).as(); - builder.registerType(AllDayManager).as(); - builder.registerType(ResizeHandleManager).as(); - builder.registerType(EdgeScrollManager).as(); - builder.registerType(HeaderManager).as(); - builder.registerType(CalendarManager).as(); - builder.registerType(WorkweekPresets).as(); - - builder.registerType(ConfigManager).as(); - builder.registerType(EventManager).as(); - - // Build the container - const app = builder.build(); - - // Initialize database and seed data BEFORE initializing managers - const indexedDBContext = app.resolveType(); - await indexedDBContext.initialize(); - - const dataSeeder = app.resolveType(); - await dataSeeder.seedIfEmpty(); - - // Get managers from container - const eb = app.resolveType(); - const calendarManager = app.resolveType(); - const eventManager = app.resolveType(); - const resizeHandleManager = app.resolveType(); - const headerManager = app.resolveType(); - const dragDropManager = app.resolveType(); - const viewSelectorManager = app.resolveType(); - const navigationManager = app.resolveType(); - const navigationButtonsManager = app.resolveType(); - const edgeScrollManager = app.resolveType(); - const allDayManager = app.resolveType(); - const urlManager = app.resolveType(); - const workweekPresetsManager = app.resolveType(); - const configManager = app.resolveType(); - - // Initialize managers - await calendarManager.initialize?.(); - await resizeHandleManager.initialize?.(); - - // Resolve AuditService (starts listening for entity events) - const auditService = app.resolveType(); - - // Resolve SyncManager (starts background sync automatically) - const syncManager = app.resolveType(); - - // Handle deep linking after managers are initialized - await handleDeepLinking(eventManager, urlManager); - - // Expose to window for debugging (with proper typing) - (window as Window & { - calendarDebug?: { - eventBus: typeof eventBus; - app: typeof app; - calendarManager: typeof calendarManager; - eventManager: typeof eventManager; - workweekPresetsManager: typeof workweekPresetsManager; - auditService: typeof auditService; - syncManager: typeof syncManager; - }; - }).calendarDebug = { - eventBus, - app, - calendarManager, - eventManager, - workweekPresetsManager, - auditService, - syncManager, - }; - - } catch (error) { - throw error; - } -} - -// Initialize when DOM is ready - now handles async properly -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - initializeCalendar().catch(error => { - console.error('Calendar initialization failed:', error); - }); - }); -} else { - initializeCalendar().catch(error => { - console.error('Calendar initialization failed:', error); - }); -} - +// Core exports +export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; +export { IRenderer as Renderer, IRenderContext as RenderContext } from './core/IGroupingRenderer'; +export { IGroupingStore } from './core/IGroupingStore'; +export { CalendarOrchestrator } from './core/CalendarOrchestrator'; +export { NavigationAnimator } from './core/NavigationAnimator'; +export { buildPipeline, Pipeline } from './core/RenderBuilder'; + +// Feature exports +export { DateRenderer } from './features/date'; +export { DateService } from './core/DateService'; +export { ITimeFormatConfig } from './core/ITimeFormatConfig'; +export { EventRenderer } from './features/event'; +export { ResourceRenderer } from './features/resource'; +export { TeamRenderer } from './features/team'; +export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; + diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts deleted file mode 100644 index f003630..0000000 --- a/src/managers/AllDayManager.ts +++ /dev/null @@ -1,744 +0,0 @@ -// All-day row height management and animations - -import { eventBus } from '../core/EventBus'; -import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig'; -import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer'; -import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine'; -import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; -import { IColumnDataSource } from '../types/ColumnDataSource'; -import { ICalendarEvent } from '../types/CalendarTypes'; -import { CalendarEventType } from '../types/BookingTypes'; -import { SwpAllDayEventElement } from '../elements/SwpEventElement'; -import { - IDragMouseEnterHeaderEventPayload, - IDragMouseEnterColumnEventPayload, - IDragStartEventPayload, - IDragMoveEventPayload, - IDragEndEventPayload, - IDragColumnChangeEventPayload, - IHeaderReadyEventPayload -} from '../types/EventTypes'; -import { IDragOffset, IMousePosition } from '../types/DragDropTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { EventManager } from './EventManager'; -import { DateService } from '../utils/DateService'; -import { EventId } from '../types/EventId'; - -/** - * AllDayManager - Handles all-day row height animations and management - * Uses AllDayLayoutEngine for all overlap detection and layout calculation - */ -export class AllDayManager { - private allDayEventRenderer: AllDayEventRenderer; - private eventManager: EventManager; - private dateService: DateService; - private dataSource: IColumnDataSource; - - private layoutEngine: AllDayLayoutEngine | null = null; - - // State tracking for layout calculation - private currentAllDayEvents: ICalendarEvent[] = []; - private currentColumns: IColumnBounds[] = []; - - // Expand/collapse state - private isExpanded: boolean = false; - private actualRowCount: number = 0; - - - constructor( - eventManager: EventManager, - allDayEventRenderer: AllDayEventRenderer, - dateService: DateService, - dataSource: IColumnDataSource - ) { - this.eventManager = eventManager; - this.allDayEventRenderer = allDayEventRenderer; - this.dateService = dateService; - this.dataSource = dataSource; - - // Sync CSS variable with TypeScript constant to ensure consistency - document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`); - this.setupEventListeners(); - } - - /** - * Setup event listeners for drag conversions - */ - private setupEventListeners(): void { - eventBus.on('drag:mouseenter-header', (event) => { - const payload = (event as CustomEvent).detail; - - if (payload.draggedClone.hasAttribute('data-allday')) - return; - - console.log('🔄 AllDayManager: Received drag:mouseenter-header', { - targetDate: payload.targetColumn, - originalElementId: payload.originalElement?.dataset?.eventId, - originalElementTag: payload.originalElement?.tagName - }); - - this.handleConvertToAllDay(payload); - }); - - eventBus.on('drag:mouseleave-header', (event) => { - const { originalElement, cloneElement } = (event as CustomEvent).detail; - - console.log('🚪 AllDayManager: Received drag:mouseleave-header', { - originalElementId: originalElement?.dataset?.eventId - }); - - }); - - // Listen for drag operations on all-day events - eventBus.on('drag:start', (event) => { - let payload: IDragStartEventPayload = (event as CustomEvent).detail; - - if (!payload.draggedClone?.hasAttribute('data-allday')) { - return; - } - - this.allDayEventRenderer.handleDragStart(payload); - }); - - eventBus.on('drag:column-change', (event) => { - let payload: IDragColumnChangeEventPayload = (event as CustomEvent).detail; - - if (!payload.draggedClone?.hasAttribute('data-allday')) { - return; - } - - this.handleColumnChange(payload); - }); - - eventBus.on('drag:end', (event) => { - let dragEndPayload: IDragEndEventPayload = (event as CustomEvent).detail; - - console.log('🎯 AllDayManager: drag:end received', { - target: dragEndPayload.target, - originalElementTag: dragEndPayload.originalElement?.tagName, - hasAllDayAttribute: dragEndPayload.originalElement?.hasAttribute('data-allday'), - eventId: dragEndPayload.originalElement?.dataset.eventId - }); - - // Handle all-day → all-day drops (within header) - if (dragEndPayload.target === 'swp-day-header' && dragEndPayload.originalElement?.hasAttribute('data-allday')) { - console.log('✅ AllDayManager: Handling all-day → all-day drop'); - this.handleDragEnd(dragEndPayload); - return; - } - - // Handle timed → all-day conversion (dropped in header) - if (dragEndPayload.target === 'swp-day-header' && !dragEndPayload.originalElement?.hasAttribute('data-allday')) { - console.log('🔄 AllDayManager: Timed → all-day conversion on drop'); - this.handleTimedToAllDayDrop(dragEndPayload); - return; - } - - // Handle all-day → timed conversion (dropped in column) - if (dragEndPayload.target === 'swp-day-column' && dragEndPayload.originalElement?.hasAttribute('data-allday')) { - const eventId = dragEndPayload.originalElement.dataset.eventId; - - console.log('🔄 AllDayManager: All-day → timed conversion', { eventId }); - - // Mark for removal (sets data-removing attribute) - this.fadeOutAndRemove(dragEndPayload.originalElement); - - // Recalculate layout WITHOUT the removed event to compress gaps - const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId); - const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns); - - // Re-render all-day events with compressed layout - this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); - - // NOW animate height with compressed layout - this.checkAndAnimateAllDayHeight(); - } - }); - - // Listen for drag cancellation to recalculate height - eventBus.on('drag:cancelled', (event) => { - const { draggedElement, reason } = (event as CustomEvent).detail; - - console.log('🚫 AllDayManager: Drag cancelled', { - eventId: draggedElement?.dataset?.eventId, - reason - }); - - }); - - // Listen for header ready - when dates are populated with period data - eventBus.on('header:ready', async (event: Event) => { - let headerReadyEventPayload = (event as CustomEvent).detail; - - let startDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(0)!.identifier); - let endDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(-1)!.identifier); - - let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate); - // Filter for all-day events - const allDayEvents = events.filter(event => event.allDay); - - const layouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements); - - this.allDayEventRenderer.renderAllDayEventsForPeriod(layouts); - this.checkAndAnimateAllDayHeight(); - }); - - eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => { - this.allDayEventRenderer.handleViewChanged(event as CustomEvent); - }); - } - - private getAllDayContainer(): HTMLElement | null { - return document.querySelector('swp-calendar-header swp-allday-container'); - } - - private getCalendarHeader(): HTMLElement | null { - return document.querySelector('swp-calendar-header'); - } - - private getHeaderSpacer(): HTMLElement | null { - return document.querySelector('swp-header-spacer'); - } - - /** - * Read current max row from DOM elements - * Excludes events marked as removing (data-removing attribute) - */ - private getMaxRowFromDOM(): number { - const container = this.getAllDayContainer(); - if (!container) return 0; - - let maxRow = 0; - const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator):not([data-removing])'); - - allDayEvents.forEach((element: Element) => { - const htmlElement = element as HTMLElement; - const row = parseInt(htmlElement.style.gridRow) || 1; - maxRow = Math.max(maxRow, row); - }); - - return maxRow; - } - - /** - * Get current gridArea for an event from DOM - */ - private getGridAreaFromDOM(eventId: string): string | null { - const container = this.getAllDayContainer(); - if (!container) return null; - - const element = container.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement; - return element?.style.gridArea || null; - } - - /** - * Count events in a specific column by reading DOM - */ - private countEventsInColumnFromDOM(columnIndex: number): number { - const container = this.getAllDayContainer(); - if (!container) return 0; - - let count = 0; - const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator)'); - - allDayEvents.forEach((element: Element) => { - const htmlElement = element as HTMLElement; - const gridColumn = htmlElement.style.gridColumn; - - // Parse "1 / 3" format - const match = gridColumn.match(/(\d+)\s*\/\s*(\d+)/); - if (match) { - const startCol = parseInt(match[1]); - const endCol = parseInt(match[2]) - 1; // End is exclusive in CSS - - if (startCol <= columnIndex && endCol >= columnIndex) { - count++; - } - } - }); - - return count; - } - - /** - * Calculate all-day height based on number of rows - */ - private calculateAllDayHeight(targetRows: number): { - targetHeight: number; - currentHeight: number; - heightDifference: number; - } { - const root = document.documentElement; - const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; - // Read CSS variable directly from style property or default to 0 - const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px'; - const currentHeight = parseInt(currentHeightStr) || 0; - const heightDifference = targetHeight - currentHeight; - - return { targetHeight, currentHeight, heightDifference }; - } - - /** - * Check current all-day events and animate to correct height - * Reads max row directly from DOM elements - */ - public checkAndAnimateAllDayHeight(): void { - // Read max row directly from DOM - const maxRows = this.getMaxRowFromDOM(); - - console.log('📊 AllDayManager: Height calculation', { - maxRows, - isExpanded: this.isExpanded - }); - - // Store actual row count - this.actualRowCount = maxRows; - - // Determine what to display - let displayRows = maxRows; - - if (maxRows > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) { - // Show chevron button - this.updateChevronButton(true); - - // Show 4 rows when collapsed (3 events + indicators) - if (!this.isExpanded) { - - displayRows = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS; - this.updateOverflowIndicators(); - - } else { - - this.clearOverflowIndicators(); - - } - } else { - - // Hide chevron - not needed - this.updateChevronButton(false); - this.clearOverflowIndicators(); - } - - console.log('🎬 AllDayManager: Will animate to', { - displayRows, - maxRows, - willAnimate: displayRows !== this.actualRowCount - }); - - console.log(`🎯 AllDayManager: Animating to ${displayRows} rows`); - - // Animate to required rows (0 = collapse, >0 = expand) - this.animateToRows(displayRows); - } - - /** - * Animate all-day container to specific number of rows - */ - public animateToRows(targetRows: number): void { - const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows); - - if (targetHeight === currentHeight) return; // No animation needed - - console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`); - - // Get cached elements - const calendarHeader = this.getCalendarHeader(); - const headerSpacer = this.getHeaderSpacer(); - const allDayContainer = this.getAllDayContainer(); - - if (!calendarHeader || !allDayContainer) return; - - // Get current parent height for animation - const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height); - const targetParentHeight = currentParentHeight + heightDifference; - - const animations = [ - calendarHeader.animate([ - { height: `${currentParentHeight}px` }, - { height: `${targetParentHeight}px` } - ], { - duration: 150, - easing: 'ease-out', - fill: 'forwards' - }) - ]; - - // Add spacer animation if spacer exists, but don't use fill: 'forwards' - if (headerSpacer) { - const root = document.documentElement; - const headerHeightStr = root.style.getPropertyValue('--header-height'); - const headerHeight = parseInt(headerHeightStr); - const currentSpacerHeight = headerHeight + currentHeight; - const targetSpacerHeight = headerHeight + targetHeight; - - animations.push( - headerSpacer.animate([ - { height: `${currentSpacerHeight}px` }, - { height: `${targetSpacerHeight}px` } - ], { - duration: 150, - easing: 'ease-out' - // No fill: 'forwards' - let CSS calc() take over after animation - }) - ); - } - - // Update CSS variable after animation - Promise.all(animations.map(anim => anim.finished)).then(() => { - const root = document.documentElement; - root.style.setProperty('--all-day-row-height', `${targetHeight}px`); - eventBus.emit('header:height-changed'); - }); - } - - - /** - * Calculate layout for ALL all-day events using AllDayLayoutEngine - * This is the correct method that processes all events together for proper overlap detection - */ - private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] { - - // Store current state - this.currentAllDayEvents = events; - this.currentColumns = dayHeaders; - - // Map IColumnBounds to IColumnInfo structure (identifier + groupId) - const columns = dayHeaders.map(column => ({ - identifier: column.identifier, - groupId: column.element.dataset.groupId || column.identifier, - data: new Date(), // Not used by AllDayLayoutEngine - events: [] // Not used by AllDayLayoutEngine - })); - - // Initialize layout engine with column info including groupId - let layoutEngine = new AllDayLayoutEngine(columns); - - // Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly - return layoutEngine.calculateLayout(events); - - } - - private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void { - - let allDayContainer = this.getAllDayContainer(); - if (!allDayContainer) return; - - // Create SwpAllDayEventElement from ICalendarEvent - const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent); - - // Apply grid positioning - allDayElement.style.gridRow = '1'; - allDayElement.style.gridColumn = payload.targetColumn.index.toString(); - - // Remove old swp-event clone - payload.draggedClone.remove(); - - // Call delegate to update DragDropManager's draggedClone reference - payload.replaceClone(allDayElement); - - // Append to container - allDayContainer.appendChild(allDayElement); - - ColumnDetectionUtils.updateColumnBoundsCache(); - - // Recalculate height after adding all-day event - this.checkAndAnimateAllDayHeight(); - - } - - - /** - * Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER - */ - private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void { - - let allDayContainer = this.getAllDayContainer(); - if (!allDayContainer) return; - - let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition); - - if (targetColumn == null) - return; - - if (!dragColumnChangeEventPayload.draggedClone) - return; - - // Calculate event span from original grid positioning - const computedStyle = window.getComputedStyle(dragColumnChangeEventPayload.draggedClone); - const gridColumnStart = parseInt(computedStyle.gridColumnStart) || targetColumn.index; - const gridColumnEnd = parseInt(computedStyle.gridColumnEnd) || targetColumn.index + 1; - const span = gridColumnEnd - gridColumnStart; - - // Update clone position maintaining the span - const newStartColumn = targetColumn.index; - const newEndColumn = newStartColumn + span; - dragColumnChangeEventPayload.draggedClone.style.gridColumn = `${newStartColumn} / ${newEndColumn}`; - - } - private fadeOutAndRemove(element: HTMLElement): void { - console.log('🗑️ AllDayManager: About to remove all-day event', { - eventId: element.dataset.eventId, - element: element.tagName - }); - - // Mark element as removing so it's excluded from height calculations - element.setAttribute('data-removing', 'true'); - - element.style.transition = 'opacity 0.3s ease-out'; - element.style.opacity = '0'; - - setTimeout(() => { - element.remove(); - console.log('✅ AllDayManager: All-day event removed from DOM'); - }, 300); - } - - - /** - * Handle timed → all-day conversion on drop - */ - private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise { - if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; - - const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; - const eventId = EventId.from(clone.eventId); - const columnIdentifier = dragEndEvent.finalPosition.column.identifier; - - // Determine target date based on mode - let targetDate: Date; - let resourceId: string | undefined; - - if (this.dataSource.isResource()) { - // Resource mode: keep event's existing date, set resourceId - targetDate = clone.start; - resourceId = columnIdentifier; - } else { - // Date mode: parse date from column identifier - targetDate = this.dateService.parseISO(columnIdentifier); - } - - console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId }); - - // Create new dates preserving time - const newStart = new Date(targetDate); - newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0); - - const newEnd = new Date(targetDate); - newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0); - - // Build update payload - const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = { - start: newStart, - end: newEnd, - allDay: true - }; - - if (resourceId) { - updatePayload.resourceId = resourceId; - } - - // Update event in repository - await this.eventManager.updateEvent(eventId, updatePayload); - - // Remove original timed event - this.fadeOutAndRemove(dragEndEvent.originalElement); - - // Add to current all-day events and recalculate layout - const newEvent: ICalendarEvent = { - id: eventId, - title: clone.title, - start: newStart, - end: newEnd, - type: clone.type as CalendarEventType, - allDay: true, - syncStatus: 'synced' - }; - - const updatedEvents = [...this.currentAllDayEvents, newEvent]; - const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns); - this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); - - // Animate height - this.checkAndAnimateAllDayHeight(); - } - - /** - * Handle all-day → all-day drop (moving within header) - */ - private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise { - if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; - - const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; - const eventId = EventId.from(clone.eventId); - const columnIdentifier = dragEndEvent.finalPosition.column.identifier; - - // Determine target date based on mode - let targetDate: Date; - let resourceId: string | undefined; - - if (this.dataSource.isResource()) { - // Resource mode: keep event's existing date, set resourceId - targetDate = clone.start; - resourceId = columnIdentifier; - } else { - // Date mode: parse date from column identifier - targetDate = this.dateService.parseISO(columnIdentifier); - } - - // Calculate duration in days - const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start); - - // Create new dates preserving time - const newStart = new Date(targetDate); - newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0); - - const newEnd = new Date(targetDate); - newEnd.setDate(newEnd.getDate() + durationDays); - newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0); - - // Build update payload - const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = { - start: newStart, - end: newEnd, - allDay: true - }; - - if (resourceId) { - updatePayload.resourceId = resourceId; - } - - // Update event in repository - await this.eventManager.updateEvent(eventId, updatePayload); - - // Remove original and fade out - this.fadeOutAndRemove(dragEndEvent.originalElement); - - // Recalculate and re-render ALL events - const updatedEvents = this.currentAllDayEvents.map(e => - e.id === eventId ? { ...e, start: newStart, end: newEnd } : e - ); - const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns); - this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts); - - // Animate height - this also handles overflow classes! - this.checkAndAnimateAllDayHeight(); - } - - /** - * Update chevron button visibility and state - */ - private updateChevronButton(show: boolean): void { - const headerSpacer = this.getHeaderSpacer(); - if (!headerSpacer) return; - - let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement; - - if (show && !chevron) { - - chevron = document.createElement('button'); - chevron.className = 'allday-chevron collapsed'; - chevron.innerHTML = ` - - - - `; - chevron.onclick = () => this.toggleExpanded(); - headerSpacer.appendChild(chevron); - - } else if (!show && chevron) { - - chevron.remove(); - - } else if (chevron) { - - chevron.classList.toggle('collapsed', !this.isExpanded); - chevron.classList.toggle('expanded', this.isExpanded); - - } - } - - /** - * Toggle between expanded and collapsed state - */ - private toggleExpanded(): void { - this.isExpanded = !this.isExpanded; - this.checkAndAnimateAllDayHeight(); - - const elements = document.querySelectorAll('swp-allday-container swp-allday-event.max-event-overflow-hide, swp-allday-container swp-allday-event.max-event-overflow-show'); - - elements.forEach((element) => { - if (this.isExpanded) { - // ALTID vis når expanded=true - element.classList.remove('max-event-overflow-hide'); - element.classList.add('max-event-overflow-show'); - } else { - // ALTID skjul når expanded=false - element.classList.remove('max-event-overflow-show'); - element.classList.add('max-event-overflow-hide'); - } - }); - } - /** - * Count number of events in a specific column using IColumnBounds - * Reads directly from DOM elements - */ - private countEventsInColumn(columnBounds: IColumnBounds): number { - return this.countEventsInColumnFromDOM(columnBounds.index); - } - - /** - * Update overflow indicators for collapsed state - */ - private updateOverflowIndicators(): void { - const container = this.getAllDayContainer(); - if (!container) return; - - // Create overflow indicators for each column that needs them - let columns = ColumnDetectionUtils.getColumns(); - - columns.forEach((columnBounds) => { - let totalEventsInColumn = this.countEventsInColumn(columnBounds); - let overflowCount = totalEventsInColumn - ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS - - if (overflowCount > 0) { - // Check if indicator already exists in this column - let existingIndicator = container.querySelector(`.max-event-indicator[data-column="${columnBounds.index}"]`) as HTMLElement; - - if (existingIndicator) { - // Update existing indicator - existingIndicator.innerHTML = `+${overflowCount + 1} more`; - } else { - // Create new overflow indicator element - let overflowElement = document.createElement('swp-allday-event'); - overflowElement.className = 'max-event-indicator'; - overflowElement.setAttribute('data-column', columnBounds.index.toString()); - overflowElement.style.gridRow = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS.toString(); - overflowElement.style.gridColumn = columnBounds.index.toString(); - overflowElement.innerHTML = `+${overflowCount + 1} more`; - overflowElement.onclick = (e) => { - e.stopPropagation(); - this.toggleExpanded(); - }; - - container.appendChild(overflowElement); - } - } - }); - } - - /** - * Clear overflow indicators and restore normal state - */ - private clearOverflowIndicators(): void { - const container = this.getAllDayContainer(); - if (!container) return; - - // Remove all overflow indicator elements - container.querySelectorAll('.max-event-indicator').forEach((element) => { - element.remove(); - }); - - - } - -} \ No newline at end of file diff --git a/src/managers/CalendarManager.ts b/src/managers/CalendarManager.ts deleted file mode 100644 index 68c777d..0000000 --- a/src/managers/CalendarManager.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { CoreEvents } from '../constants/CoreEvents'; -import { Configuration } from '../configurations/CalendarConfig'; -import { CalendarView, IEventBus } from '../types/CalendarTypes'; -import { EventManager } from './EventManager'; -import { GridManager } from './GridManager'; -import { EventRenderingService } from '../renderers/EventRendererManager'; -import { ScrollManager } from './ScrollManager'; - -/** - * CalendarManager - Main coordinator for all calendar managers - */ -export class CalendarManager { - private eventBus: IEventBus; - private eventManager: EventManager; - private gridManager: GridManager; - private eventRenderer: EventRenderingService; - private scrollManager: ScrollManager; - private config: Configuration; - private currentView: CalendarView; - private currentDate: Date = new Date(); - private isInitialized: boolean = false; - - constructor( - eventBus: IEventBus, - eventManager: EventManager, - gridManager: GridManager, - eventRenderingService: EventRenderingService, - scrollManager: ScrollManager, - config: Configuration - ) { - this.eventBus = eventBus; - this.eventManager = eventManager; - this.gridManager = gridManager; - this.eventRenderer = eventRenderingService; - this.scrollManager = scrollManager; - this.config = config; - this.currentView = this.config.currentView; - this.setupEventListeners(); - } - - /** - * Initialize calendar system using simple direct calls - */ - public async initialize(): Promise { - if (this.isInitialized) { - return; - } - - - try { - // Step 1: Load data - await this.eventManager.loadData(); - - // Step 2: Render grid structure - await this.gridManager.render(); - - this.scrollManager.initialize(); - - this.setView(this.currentView); - this.setCurrentDate(this.currentDate); - - this.isInitialized = true; - - // Emit initialization complete event - this.eventBus.emit(CoreEvents.INITIALIZED, { - currentDate: this.currentDate, - currentView: this.currentView - }); - - } catch (error) { - throw error; - } - } - - /** - * Skift calendar view (dag/uge/måned) - */ - public setView(view: CalendarView): void { - if (this.currentView === view) { - return; - } - - const previousView = this.currentView; - this.currentView = view; - - - // Emit view change event - this.eventBus.emit(CoreEvents.VIEW_CHANGED, { - previousView, - currentView: view, - date: this.currentDate - }); - - } - - /** - * Sæt aktuel dato - */ - public setCurrentDate(date: Date): void { - - const previousDate = this.currentDate; - this.currentDate = new Date(date); - - // Emit date change event - this.eventBus.emit(CoreEvents.DATE_CHANGED, { - previousDate, - currentDate: this.currentDate, - view: this.currentView - }); - } - - - /** - * Setup event listeners for at håndtere events fra andre managers - */ - private setupEventListeners(): void { - // Listen for workweek changes only - this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => { - const customEvent = event as CustomEvent; - this.handleWorkweekChange(); - }); - } - - - - /** - * Calculate the current period based on view and date - */ - private calculateCurrentPeriod(): { start: string; end: string } { - const current = new Date(this.currentDate); - - switch (this.currentView) { - case 'day': - const dayStart = new Date(current); - dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(current); - dayEnd.setHours(23, 59, 59, 999); - return { - start: dayStart.toISOString(), - end: dayEnd.toISOString() - }; - - case 'week': - // Find start of week (Monday) - const weekStart = new Date(current); - const dayOfWeek = weekStart.getDay(); - const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Sunday = 0, so 6 days back to Monday - weekStart.setDate(weekStart.getDate() - daysToMonday); - weekStart.setHours(0, 0, 0, 0); - - // Find end of week (Sunday) - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekEnd.getDate() + 6); - weekEnd.setHours(23, 59, 59, 999); - - return { - start: weekStart.toISOString(), - end: weekEnd.toISOString() - }; - - case 'month': - const monthStart = new Date(current.getFullYear(), current.getMonth(), 1); - const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999); - return { - start: monthStart.toISOString(), - end: monthEnd.toISOString() - }; - - default: - // Fallback to week view - const fallbackStart = new Date(current); - fallbackStart.setDate(fallbackStart.getDate() - 3); - fallbackStart.setHours(0, 0, 0, 0); - const fallbackEnd = new Date(current); - fallbackEnd.setDate(fallbackEnd.getDate() + 3); - fallbackEnd.setHours(23, 59, 59, 999); - return { - start: fallbackStart.toISOString(), - end: fallbackEnd.toISOString() - }; - } - } - - /** - * Handle workweek configuration changes - */ - private handleWorkweekChange(): void { - // Simply relay the event - workweek info is in the WORKWEEK_CHANGED event - this.eventBus.emit('workweek:header-update', { - currentDate: this.currentDate, - currentView: this.currentView - }); - } - -} diff --git a/src/managers/DragDropManager.ts b/src/managers/DragDropManager.ts index 11c6952..4bf50b9 100644 --- a/src/managers/DragDropManager.ts +++ b/src/managers/DragDropManager.ts @@ -1,756 +1,581 @@ -/** - * DragDropManager - Advanced drag-and-drop system with smooth animations and event type conversion - * - * ARCHITECTURE OVERVIEW: - * ===================== - * DragDropManager provides a sophisticated drag-and-drop system for calendar events that supports: - * - Smooth animated dragging with requestAnimationFrame - * - Automatic event type conversion (timed events ↔ all-day events) - * - Scroll compensation during edge scrolling - * - Grid snapping for precise event placement - * - Column detection and change tracking - * - * KEY FEATURES: - * ============= - * 1. DRAG DETECTION - * - Movement threshold (5px) to distinguish clicks from drags - * - Immediate visual feedback with cloned element - * - Mouse offset tracking for natural drag feel - * - * 2. SMOOTH ANIMATION - * - Uses requestAnimationFrame for 60fps animations - * - Interpolated movement (30% per frame) for smooth transitions - * - Continuous drag:move events for real-time updates - * - * 3. EVENT TYPE CONVERSION - * - Timed → All-day: When dragging into calendar header - * - All-day → Timed: When dragging into day columns - * - Automatic clone replacement with appropriate element type - * - * 4. SCROLL COMPENSATION - * - Tracks scroll delta during edge-scrolling - * - Compensates dragged element position during scroll - * - Prevents visual "jumping" when scrolling while dragging - * - * 5. GRID SNAPPING - * - Snaps to time grid on mouse up - * - Uses PositionUtils for consistent positioning - * - Accounts for mouse offset within event - * - * STATE MANAGEMENT: - * ================= - * Mouse Tracking: - * - mouseDownPosition: Initial click position - * - currentMousePosition: Latest mouse position - * - mouseOffset: Click offset within event (for natural dragging) - * - * Drag State: - * - originalElement: Source event being dragged - * - draggedClone: Animated clone following mouse - * - currentColumn: Column mouse is currently over - * - previousColumn: Last column (for detecting changes) - * - isDragStarted: Whether drag threshold exceeded - * - * Scroll State: - * - scrollDeltaY: Accumulated scroll offset during drag - * - lastScrollTop: Previous scroll position - * - isScrollCompensating: Whether edge-scroll is active - * - * Animation State: - * - dragAnimationId: requestAnimationFrame ID - * - targetY: Desired position for smooth interpolation - * - currentY: Current interpolated position - * - * EVENT FLOW: - * =========== - * 1. Mouse Down (handleMouseDown) - * ├─ Store originalElement and mouse offset - * └─ Wait for movement - * - * 2. Mouse Move (handleMouseMove) - * ├─ Check movement threshold - * ├─ Initialize drag if threshold exceeded (initializeDrag) - * │ ├─ Create clone - * │ ├─ Emit drag:start - * │ └─ Start animation loop - * ├─ Continue drag (continueDrag) - * │ ├─ Calculate target position with scroll compensation - * │ └─ Update animation target - * └─ Detect column changes (detectColumnChange) - * └─ Emit drag:column-change - * - * 3. Animation Loop (animateDrag) - * ├─ Interpolate currentY toward targetY - * ├─ Emit drag:move on each frame - * └─ Schedule next frame until target reached - * - * 4. Event Type Conversion - * ├─ Entering header (handleHeaderMouseEnter) - * │ ├─ Emit drag:mouseenter-header - * │ └─ AllDayManager creates all-day clone - * └─ Entering column (handleColumnMouseEnter) - * ├─ Emit drag:mouseenter-column - * └─ EventRenderingService creates timed clone - * - * 5. Mouse Up (handleMouseUp) - * ├─ Stop animation - * ├─ Snap to grid - * ├─ Detect drop target (header or column) - * ├─ Emit drag:end with final position - * └─ Cleanup drag state - * - * SCROLL COMPENSATION SYSTEM: - * =========================== - * Problem: When EdgeScrollManager scrolls the grid during drag, the dragged element - * can appear to "jump" because the mouse position stays the same but the - * coordinate system (scrollable content) has moved. - * - * Solution: Track cumulative scroll delta and add it to mouse position calculations - * - * Flow: - * 1. EdgeScrollManager starts scrolling → emit edgescroll:started - * 2. DragDropManager sets isScrollCompensating = true - * 3. On each scroll event: - * ├─ Calculate scrollDelta = currentScrollTop - lastScrollTop - * ├─ Accumulate into scrollDeltaY - * └─ Call continueDrag with adjusted position - * 4. continueDrag adds scrollDeltaY to mouse Y coordinate - * 5. On event conversion, reset scrollDeltaY (new clone, new coordinate system) - * - * PERFORMANCE OPTIMIZATIONS: - * ========================== - * - Uses ColumnDetectionUtils cache for fast column lookups - * - Single requestAnimationFrame loop (not per-mousemove) - * - Interpolated animation reduces update frequency - * - Passive scroll listeners - * - Event delegation for header/column detection - * - * USAGE: - * ====== - * const dragDropManager = new DragDropManager(eventBus, positionUtils); - * // Automatically attaches event listeners and manages drag lifecycle - * // Other managers listen to drag:start, drag:move, drag:end, etc. - */ - -import { IEventBus } from '../types/CalendarTypes'; -import { PositionUtils } from '../utils/PositionUtils'; -import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; -import { SwpEventElement, BaseSwpEventElement } from '../elements/SwpEventElement'; -import { - IDragStartEventPayload, - IDragMoveEventPayload, - IDragEndEventPayload, - IDragMouseEnterHeaderEventPayload, - IDragMouseLeaveHeaderEventPayload, - IDragMouseEnterColumnEventPayload, - IDragColumnChangeEventPayload -} from '../types/EventTypes'; -import { IMousePosition } from '../types/DragDropTypes'; -import { CoreEvents } from '../constants/CoreEvents'; - -export class DragDropManager { - private eventBus: IEventBus; - - // Mouse tracking with optimized state - private mouseDownPosition: IMousePosition = { x: 0, y: 0 }; - private currentMousePosition: IMousePosition = { x: 0, y: 0 }; - private mouseOffset: IMousePosition = { x: 0, y: 0 }; - - // Drag state - private originalElement!: HTMLElement | null; - private draggedClone!: HTMLElement | null; - private currentColumn: IColumnBounds | null = null; - private previousColumn: IColumnBounds | null = null; - private originalSourceColumn: IColumnBounds | null = null; // Track original start column - private isDragStarted = false; - - // Movement threshold to distinguish click from drag - private readonly dragThreshold = 5; // pixels - - // Scroll compensation - private scrollableContent: HTMLElement | null = null; - private scrollDeltaY = 0; // Current scroll delta to apply in continueDrag - private lastScrollTop = 0; // Last scroll position for delta calculation - private isScrollCompensating = false; // Track if scroll compensation is active - - // Smooth drag animation - private dragAnimationId: number | null = null; - private targetY = 0; - private currentY = 0; - private targetColumn: IColumnBounds | null = null; - private positionUtils: PositionUtils; - - constructor(eventBus: IEventBus, positionUtils: PositionUtils) { - this.eventBus = eventBus; - this.positionUtils = positionUtils; - - this.init(); - } - - /** - * Initialize with optimized event listener setup - */ - private init(): void { - // Add event listeners - document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); - document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); - document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); - - const calendarContainer = document.querySelector('swp-calendar-container'); - - if (calendarContainer) { - calendarContainer.addEventListener('mouseleave', () => { - if (this.originalElement && this.isDragStarted) { - this.cancelDrag(); - } - }); - - // Event delegation for header enter/leave - calendarContainer.addEventListener('mouseenter', (e) => { - const target = e.target as HTMLElement; - if (target.closest('swp-calendar-header')) { - this.handleHeaderMouseEnter(e as MouseEvent); - } else if (target.closest('swp-day-column')) { - this.handleColumnMouseEnter(e as MouseEvent); - } - }, true); // Use capture phase - - calendarContainer.addEventListener('mouseleave', (e) => { - const target = e.target as HTMLElement; - if (target.closest('swp-calendar-header')) { - this.handleHeaderMouseLeave(e as MouseEvent); - } - // Don't handle swp-event mouseleave here - let mousemove handle it - }, true); // Use capture phase - } - - // Initialize column bounds cache - ColumnDetectionUtils.updateColumnBoundsCache(); - - - - - // Listen to resize events to update cache - window.addEventListener('resize', () => { - ColumnDetectionUtils.updateColumnBoundsCache(); - }); - - // Listen to navigation events to update cache - this.eventBus.on('navigation:completed', () => { - ColumnDetectionUtils.updateColumnBoundsCache(); - }); - - this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => { - this.handleGridRendered(event as CustomEvent); - }); - - // Listen to edge-scroll events to control scroll compensation - this.eventBus.on('edgescroll:started', () => { - this.isScrollCompensating = true; - - // Gem nuværende scroll position for delta beregning - if (this.scrollableContent) { - this.lastScrollTop = this.scrollableContent.scrollTop; - } - }); - - this.eventBus.on('edgescroll:stopped', () => { - this.isScrollCompensating = false; - }); - - // Reset scrollDeltaY when event converts (new clone created) - this.eventBus.on('drag:mouseenter-header', () => { - this.scrollDeltaY = 0; - this.lastScrollTop = 0; - }); - - this.eventBus.on('drag:mouseenter-column', () => { - this.scrollDeltaY = 0; - this.lastScrollTop = 0; - }); - - } - private handleGridRendered(event: CustomEvent) { - this.scrollableContent = document.querySelector('swp-scrollable-content'); - this.scrollableContent!.addEventListener('scroll', this.handleScroll.bind(this), { passive: true }); - } - - private handleMouseDown(event: MouseEvent): void { - - // Clean up drag state first - this.cleanupDragState(); - ColumnDetectionUtils.updateColumnBoundsCache(); - //this.lastMousePosition = { x: event.clientX, y: event.clientY }; - //this.initialMousePosition = { x: event.clientX, y: event.clientY }; - - // Check if mousedown is on an event - const target = event.target as HTMLElement; - if (target.closest('swp-resize-handle')) return; - - let eventElement = target; - - while (eventElement && eventElement.tagName !== 'SWP-GRID-CONTAINER') { - if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') { - break; - } - eventElement = eventElement.parentElement as HTMLElement; - if (!eventElement) return; - } - - if (eventElement) { - - // Normal drag - prepare for potential dragging - this.originalElement = eventElement; - // Calculate mouse offset within event - const eventRect = eventElement.getBoundingClientRect(); - this.mouseOffset = { - x: event.clientX - eventRect.left, - y: event.clientY - eventRect.top - }; - this.mouseDownPosition = { x: event.clientX, y: event.clientY }; - - } - } - - private handleMouseMove(event: MouseEvent): void { - - if (event.buttons === 1) { - // Always update mouse position from event - this.currentMousePosition = { x: event.clientX, y: event.clientY }; - - // Try to initialize drag if not started - if (!this.isDragStarted && this.originalElement) { - if (!this.initializeDrag(this.currentMousePosition)) { - return; // Not enough movement yet - } - } - - // Continue drag if started (også under scroll - accumulatedScrollDelta kompenserer) - if (this.isDragStarted && this.originalElement && this.draggedClone) { - this.continueDrag(this.currentMousePosition); - this.detectColumnChange(this.currentMousePosition); - } - } - } - - /** - * Try to initialize drag based on movement threshold - * Returns true if drag was initialized, false if not enough movement - */ - private initializeDrag(currentPosition: IMousePosition): boolean { - const deltaX = Math.abs(currentPosition.x - this.mouseDownPosition.x); - const deltaY = Math.abs(currentPosition.y - this.mouseDownPosition.y); - const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - if (totalMovement < this.dragThreshold) { - return false; // Not enough movement - } - - // Start drag - this.isDragStarted = true; - - - - // Set high z-index on event-group if exists, otherwise on event itself - const eventGroup = this.originalElement!.closest('swp-event-group'); - if (eventGroup) { - eventGroup.style.zIndex = '9999'; - } else { - this.originalElement!.style.zIndex = '9999'; - } - - const originalElement = this.originalElement as BaseSwpEventElement; - this.currentColumn = ColumnDetectionUtils.getColumnBounds(currentPosition); - this.originalSourceColumn = this.currentColumn; // Store original source column at drag start - this.draggedClone = originalElement.createClone(); - - const dragStartPayload: IDragStartEventPayload = { - originalElement: this.originalElement!, - draggedClone: this.draggedClone, - mousePosition: this.mouseDownPosition, - mouseOffset: this.mouseOffset, - columnBounds: this.currentColumn - }; - this.eventBus.emit('drag:start', dragStartPayload); - - return true; - } - - - private continueDrag(currentPosition: IMousePosition): void { - - if (!this.draggedClone!.hasAttribute("data-allday")) { - // Calculate raw position from mouse (no snapping) - const column = ColumnDetectionUtils.getColumnBounds(currentPosition); - - if (column) { - // Calculate raw Y position relative to column (accounting for mouse offset) - const columnRect = column.boundingClientRect; - - // Beregn position fra mus + scroll delta kompensation - const adjustedMouseY = currentPosition.y + this.scrollDeltaY; - const eventTopY = adjustedMouseY - columnRect.top - this.mouseOffset.y; - - this.targetY = Math.max(0, eventTopY); - this.targetColumn = column; - - // Start animation loop if not already running - if (this.dragAnimationId === null) { - this.currentY = parseFloat(this.draggedClone!.style.top) || 0; - this.animateDrag(); - } - } - - } - } - - /** - * Detect column change and emit event - */ - private detectColumnChange(currentPosition: IMousePosition): void { - const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition); - if (newColumn == null) return; - - if (newColumn.index !== this.currentColumn?.index) { - this.previousColumn = this.currentColumn; - this.currentColumn = newColumn; - - const dragColumnChangePayload: IDragColumnChangeEventPayload = { - originalElement: this.originalElement!, - draggedClone: this.draggedClone!, - previousColumn: this.previousColumn, - newColumn, - mousePosition: currentPosition - }; - this.eventBus.emit('drag:column-change', dragColumnChangePayload); - } - } - - /** - * Optimized mouse up handler with consolidated cleanup - */ - private handleMouseUp(event: MouseEvent): void { - this.stopDragAnimation(); - - if (this.originalElement) { - - // Only emit drag:end if drag was actually started - if (this.isDragStarted) { - const mousePosition: IMousePosition = { x: event.clientX, y: event.clientY }; - - // Snap to grid on mouse up (like ResizeHandleManager) - const column = ColumnDetectionUtils.getColumnBounds(mousePosition); - - if (!column) return; - - // Get current position and snap it to grid - const snappedY = this.calculateSnapPosition(mousePosition.y, column); - - // Update clone to snapped position immediately - if (this.draggedClone) { - this.draggedClone.style.top = `${snappedY}px`; - } - - // Detect drop target (swp-day-column or swp-day-header) - const dropTarget = this.detectDropTarget(mousePosition); - - if (!dropTarget) - throw "dropTarget is null"; - - // Read date and resourceId directly from DOM - const dateString = column.element.dataset.date; - if (!dateString) { - throw "column.element.dataset.date is not set"; - } - const date = new Date(dateString); - const resourceId = column.element.dataset.resourceId; // undefined in date mode - - const dragEndPayload: IDragEndEventPayload = { - originalElement: this.originalElement, - draggedClone: this.draggedClone, - mousePosition, - originalSourceColumn: this.originalSourceColumn!!, - finalPosition: { column, date, resourceId, snappedY }, - target: dropTarget - }; - - this.eventBus.emit('drag:end', dragEndPayload); - - this.cleanupDragState(); - - } else { - // This was just a click - emit click event instead - this.eventBus.emit('event:click', { - clickedElement: this.originalElement, - mousePosition: { x: event.clientX, y: event.clientY } - }); - } - } - } - // Add a cleanup method that finds and removes ALL clones - private cleanupAllClones(): void { - // Remove clones from all possible locations - const allClones = document.querySelectorAll('[data-event-id^="clone"]'); - - if (allClones.length > 0) { - allClones.forEach(clone => clone.remove()); - } - } - - /** - * Cancel drag operation when mouse leaves grid container - * Animates clone back to original position before cleanup - */ - private cancelDrag(): void { - if (!this.originalElement || !this.draggedClone) return; - - // Get current clone position - const cloneRect = this.draggedClone.getBoundingClientRect(); - - // Get original element position - const originalRect = this.originalElement.getBoundingClientRect(); - - // Calculate distance to animate - const deltaX = originalRect.left - cloneRect.left; - const deltaY = originalRect.top - cloneRect.top; - - // Add transition for smooth animation - this.draggedClone.style.transition = 'transform 300ms ease-out'; - this.draggedClone.style.transform = `translate(${deltaX}px, ${deltaY}px)`; - - // Wait for animation to complete, then cleanup - setTimeout(() => { - this.cleanupAllClones(); - - if (this.originalElement) { - this.originalElement.style.opacity = ''; - this.originalElement.style.cursor = ''; - } - - this.eventBus.emit('drag:cancelled', { - originalElement: this.originalElement, - reason: 'mouse-left-grid' - }); - - this.cleanupDragState(); - this.stopDragAnimation(); - }, 300); - } - - /** - * Optimized snap position calculation using PositionUtils - */ - private calculateSnapPosition(mouseY: number, column: IColumnBounds): number { - // Calculate where the event top would be (accounting for mouse offset) - const eventTopY = mouseY - this.mouseOffset.y; - - // Snap the event top position, not the mouse position - const snappedY = this.positionUtils.getPositionFromCoordinate(eventTopY, column); - - return Math.max(0, snappedY); - } - - /** - * Smooth drag animation using requestAnimationFrame - * Emits drag:move events with current draggedClone reference on each frame - */ - private animateDrag(): void { //TODO: this can be optimized... WAIT !!! - - if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) { - this.dragAnimationId = null; - return; - } - - // Smooth interpolation towards target - const diff = this.targetY - this.currentY; - const step = diff * 0.3; // 30% of distance per frame - - // Update if difference is significant - if (Math.abs(diff) > 0.5) { - this.currentY += step; - - // Emit drag:move event with current draggedClone reference - const dragMovePayload: IDragMoveEventPayload = { - originalElement: this.originalElement!, - draggedClone: this.draggedClone, // Always uses current reference - mousePosition: this.currentMousePosition, // Use current mouse position! - snappedY: this.currentY, - columnBounds: this.targetColumn, - mouseOffset: this.mouseOffset - }; - this.eventBus.emit('drag:move', dragMovePayload); - - this.dragAnimationId = requestAnimationFrame(() => this.animateDrag()); - } else { - // Close enough - snap to target - this.currentY = this.targetY; - - // Emit final position - const dragMovePayload: IDragMoveEventPayload = { - originalElement: this.originalElement!, - draggedClone: this.draggedClone, - mousePosition: this.currentMousePosition, // Use current mouse position! - snappedY: this.currentY, - columnBounds: this.targetColumn, - mouseOffset: this.mouseOffset - }; - this.eventBus.emit('drag:move', dragMovePayload); - - this.dragAnimationId = null; - } - } - - /** - * Handle scroll during drag - update scrollDeltaY and call continueDrag - */ - private handleScroll(): void { - if (!this.isDragStarted || !this.draggedClone || !this.scrollableContent || !this.isScrollCompensating) return; - - const currentScrollTop = this.scrollableContent.scrollTop; - const scrollDelta = currentScrollTop - this.lastScrollTop; - - // Gem scroll delta for continueDrag - this.scrollDeltaY += scrollDelta; - this.lastScrollTop = currentScrollTop; - - // Kald continueDrag med nuværende mus position - this.continueDrag(this.currentMousePosition); - } - - /** - * Stop drag animation - */ - private stopDragAnimation(): void { - if (this.dragAnimationId !== null) { - cancelAnimationFrame(this.dragAnimationId); - this.dragAnimationId = null; - } - } - - /** - * Clean up drag state - */ - private cleanupDragState(): void { - this.previousColumn = null; - this.originalElement = null; - this.draggedClone = null; - this.currentColumn = null; - this.originalSourceColumn = null; - this.isDragStarted = false; - this.scrollDeltaY = 0; - this.lastScrollTop = 0; - } - - /** - * Detect drop target - whether dropped in swp-day-column or swp-day-header - */ - private detectDropTarget(position: IMousePosition): 'swp-day-column' | 'swp-day-header' | null { - - // Traverse up the DOM tree to find the target container - let currentElement = this.draggedClone; - while (currentElement && currentElement !== document.body) { - if (currentElement.tagName === 'SWP-ALLDAY-CONTAINER') { - return 'swp-day-header'; - } - if (currentElement.tagName === 'SWP-DAY-COLUMN') { - return 'swp-day-column'; - } - currentElement = currentElement.parentElement as HTMLElement; - } - - return null; - } - - /** - * Handle mouse enter on calendar header - simplified using native events - */ - private handleHeaderMouseEnter(event: MouseEvent): void { - // Only handle if we're dragging a timed event (not all-day) - if (!this.isDragStarted || !this.draggedClone) { - return; - } - - const position: IMousePosition = { x: event.clientX, y: event.clientY }; - const targetColumn = ColumnDetectionUtils.getColumnBounds(position); - - if (targetColumn) { - const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); - - const dragMouseEnterPayload: IDragMouseEnterHeaderEventPayload = { - targetColumn: targetColumn, - mousePosition: position, - originalElement: this.originalElement, - draggedClone: this.draggedClone, - calendarEvent: calendarEvent, - replaceClone: (newClone: HTMLElement) => { - this.draggedClone = newClone; - this.dragAnimationId === null; - } - }; - this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload); - } - } - - /** - * Handle mouse enter on day column - for converting all-day to timed events - */ - private handleColumnMouseEnter(event: MouseEvent): void { - // Only handle if we're dragging an all-day event - if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute('data-allday')) { - return; - } - - const position: IMousePosition = { x: event.clientX, y: event.clientY }; - const targetColumn = ColumnDetectionUtils.getColumnBounds(position); - - if (!targetColumn) { - return; - } - - // Calculate snapped Y position - const snappedY = this.calculateSnapPosition(position.y, targetColumn); - - // Extract ICalendarEvent from the dragged clone - const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); - - const dragMouseEnterPayload: IDragMouseEnterColumnEventPayload = { - targetColumn: targetColumn, - mousePosition: position, - snappedY: snappedY, - originalElement: this.originalElement, - draggedClone: this.draggedClone, - calendarEvent: calendarEvent, - replaceClone: (newClone: HTMLElement) => { - this.draggedClone = newClone; - this.dragAnimationId === null; - this.stopDragAnimation(); - } - }; - this.eventBus.emit('drag:mouseenter-column', dragMouseEnterPayload); - } - - /** - * Handle mouse leave from calendar header - simplified using native events - */ - private handleHeaderMouseLeave(event: MouseEvent): void { - // Only handle if we're dragging an all-day event - if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute("data-allday")) { - return; - } - - const position: IMousePosition = { x: event.clientX, y: event.clientY }; - const targetColumn = ColumnDetectionUtils.getColumnBounds(position); - - if (!targetColumn) { - return; - } - - const dragMouseLeavePayload: IDragMouseLeaveHeaderEventPayload = { - targetColumn: targetColumn, - mousePosition: position, - originalElement: this.originalElement, - draggedClone: this.draggedClone - }; - this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload); - } -} +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/src/managers/EdgeScrollManager.ts b/src/managers/EdgeScrollManager.ts index 9170ed5..d1b5584 100644 --- a/src/managers/EdgeScrollManager.ts +++ b/src/managers/EdgeScrollManager.ts @@ -1,220 +1,140 @@ -/** - * EdgeScrollManager - Auto-scroll when dragging near edges - * Uses time-based scrolling with 2-zone system for variable speed - */ - -import { IEventBus } from '../types/CalendarTypes'; -import { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes'; - -export class EdgeScrollManager { - private scrollableContent: HTMLElement | null = null; - private timeGrid: HTMLElement | null = null; - private draggedClone: HTMLElement | null = null; - private scrollRAF: number | null = null; - private mouseY = 0; - private isDragging = false; - private isScrolling = false; // Track if edge-scroll is active - private lastTs = 0; - private rect: DOMRect | null = null; - private initialScrollTop = 0; - private scrollListener: ((e: Event) => void) | null = null; - - // Constants - fixed values as per requirements - private readonly OUTER_ZONE = 100; // px from edge (slow zone) - private readonly INNER_ZONE = 50; // px from edge (fast zone) - private readonly SLOW_SPEED_PXS = 140; // px/sec in outer zone - private readonly FAST_SPEED_PXS = 640; // px/sec in inner zone - - constructor(private eventBus: IEventBus) { - this.init(); - } - - private init(): void { - // Wait for DOM to be ready - setTimeout(() => { - this.scrollableContent = document.querySelector('swp-scrollable-content'); - this.timeGrid = document.querySelector('swp-time-grid'); - - if (this.scrollableContent) { - // Disable smooth scroll for instant auto-scroll - this.scrollableContent.style.scrollBehavior = 'auto'; - - // Add scroll listener to detect actual scrolling - this.scrollListener = this.handleScroll.bind(this); - this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true }); - } - }, 100); - - // Listen to mousemove directly from document to always get mouse coords - document.body.addEventListener('mousemove', (e: MouseEvent) => { - if (this.isDragging) { - this.mouseY = e.clientY; - } - }); - - this.subscribeToEvents(); - } - - private subscribeToEvents(): void { - - // Listen to drag events from DragDropManager - this.eventBus.on('drag:start', (event: Event) => { - const payload = (event as CustomEvent).detail; - this.draggedClone = payload.draggedClone; - this.startDrag(); - }); - - this.eventBus.on('drag:end', () => this.stopDrag()); - this.eventBus.on('drag:cancelled', () => this.stopDrag()); - - // Stop scrolling when event converts to/from all-day - this.eventBus.on('drag:mouseenter-header', () => { - console.log('🔄 EdgeScrollManager: Event converting to all-day - stopping scroll'); - this.stopDrag(); - }); - - this.eventBus.on('drag:mouseenter-column', () => { - this.startDrag(); - }); - } - - private startDrag(): void { - console.log('🎬 EdgeScrollManager: Starting drag'); - this.isDragging = true; - this.isScrolling = false; // Reset scroll state - this.lastTs = performance.now(); - - // Save initial scroll position - if (this.scrollableContent) { - this.initialScrollTop = this.scrollableContent.scrollTop; - } - - if (this.scrollRAF === null) { - this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts)); - } - } - - private stopDrag(): void { - this.isDragging = false; - - // Emit stopped event if we were scrolling - if (this.isScrolling) { - this.isScrolling = false; - console.log('🛑 EdgeScrollManager: Edge-scroll stopped (drag ended)'); - this.eventBus.emit('edgescroll:stopped', {}); - } - - if (this.scrollRAF !== null) { - cancelAnimationFrame(this.scrollRAF); - this.scrollRAF = null; - } - this.rect = null; - this.lastTs = 0; - this.initialScrollTop = 0; - } - - private handleScroll(): void { - if (!this.isDragging || !this.scrollableContent) return; - - const currentScrollTop = this.scrollableContent.scrollTop; - const scrollDelta = Math.abs(currentScrollTop - this.initialScrollTop); - - // Only emit started event if we've actually scrolled more than 1px - if (scrollDelta > 1 && !this.isScrolling) { - this.isScrolling = true; - console.log('💾 EdgeScrollManager: Edge-scroll started (actual scroll detected)', { - initialScrollTop: this.initialScrollTop, - currentScrollTop, - scrollDelta - }); - this.eventBus.emit('edgescroll:started', {}); - } - } - - private scrollTick(ts: number): void { - const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0; - this.lastTs = ts; - - if (!this.scrollableContent) { - this.stopDrag(); - return; - } - - // Cache rect for performance (only measure once per frame) - if (!this.rect) { - this.rect = this.scrollableContent.getBoundingClientRect(); - } - - let vy = 0; - if (this.isDragging) { - const distTop = this.mouseY - this.rect.top; - const distBot = this.rect.bottom - this.mouseY; - - // Check top edge - if (distTop < this.INNER_ZONE) { - vy = -this.FAST_SPEED_PXS; - } else if (distTop < this.OUTER_ZONE) { - vy = -this.SLOW_SPEED_PXS; - } - // Check bottom edge - else if (distBot < this.INNER_ZONE) { - vy = this.FAST_SPEED_PXS; - } else if (distBot < this.OUTER_ZONE) { - vy = this.SLOW_SPEED_PXS; - } - } - - if (vy !== 0 && this.isDragging && this.timeGrid && this.draggedClone) { - // Check if we can scroll in the requested direction - const currentScrollTop = this.scrollableContent.scrollTop; - const scrollableHeight = this.scrollableContent.clientHeight; - const timeGridHeight = this.timeGrid.clientHeight; - - // Get dragged element position and height - const cloneRect = this.draggedClone.getBoundingClientRect(); - const cloneBottom = cloneRect.bottom; - const timeGridRect = this.timeGrid.getBoundingClientRect(); - const timeGridBottom = timeGridRect.bottom; - - // Check boundaries - const atTop = currentScrollTop <= 0 && vy < 0; - const atBottom = (cloneBottom >= timeGridBottom) && vy > 0; - - - if (atTop || atBottom) { - // At boundary - stop scrolling - if (this.isScrolling) { - this.isScrolling = false; - this.initialScrollTop = this.scrollableContent.scrollTop; - console.log('🛑 EdgeScrollManager: Edge-scroll stopped (reached boundary)'); - this.eventBus.emit('edgescroll:stopped', {}); - } - - // Continue RAF loop to detect when mouse moves away from boundary - if (this.isDragging) { - this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts)); - } - } else { - // Not at boundary - apply scroll - this.scrollableContent.scrollTop += vy * dt; - this.rect = null; // Invalidate cache for next frame - this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts)); - } - } else { - // Mouse moved away from edge - stop scrolling - if (this.isScrolling) { - this.isScrolling = false; - this.initialScrollTop = this.scrollableContent.scrollTop; // Reset for next scroll - console.log('🛑 EdgeScrollManager: Edge-scroll stopped (mouse left edge)'); - this.eventBus.emit('edgescroll:stopped', {}); - } - - // Continue RAF loop even if not scrolling, to detect edge entry - if (this.isDragging) { - this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts)); - } else { - this.stopDrag(); - } - } - } -} \ No newline at end of file +/** + * 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/src/managers/EventFilterManager.ts b/src/managers/EventFilterManager.ts deleted file mode 100644 index 1f6777a..0000000 --- a/src/managers/EventFilterManager.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * EventFilterManager - Handles fuzzy search filtering of calendar events - * Uses Fuse.js for fuzzy matching (Apache 2.0 License) - */ - -import { eventBus } from '../core/EventBus'; -import { CoreEvents } from '../constants/CoreEvents'; -import { ICalendarEvent } from '../types/CalendarTypes'; - -// Import Fuse.js from npm -import Fuse from 'fuse.js'; - -interface FuseResult { - item: ICalendarEvent; - refIndex: number; - score?: number; -} - -export class EventFilterManager { - private searchInput: HTMLInputElement | null = null; - private allEvents: ICalendarEvent[] = []; - private matchingEventIds: Set = new Set(); - private isFilterActive: boolean = false; - private frameRequest: number | null = null; - private fuse: Fuse | null = null; - - constructor() { - // Wait for DOM to be ready before initializing - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - this.init(); - }); - } else { - this.init(); - } - } - - private init(): void { - // Find search input - this.searchInput = document.querySelector('swp-search-container input[type="search"]'); - - if (!this.searchInput) { - return; - } - - // Set up event listeners - this.setupSearchListeners(); - this.subscribeToEvents(); - - // Initialization complete - } - - private setupSearchListeners(): void { - if (!this.searchInput) return; - - // Listen for input changes - this.searchInput.addEventListener('input', (e) => { - const query = (e.target as HTMLInputElement).value; - this.handleSearchInput(query); - }); - - // Listen for escape key - this.searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - this.clearFilter(); - } - }); - } - - private subscribeToEvents(): void { - // Listen for events data updates - eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.events) { - this.updateEventsList(detail.events); - } - }); - } - - private updateEventsList(events: ICalendarEvent[]): void { - this.allEvents = events; - - // Initialize Fuse with the new events list - this.fuse = new Fuse(this.allEvents, { - keys: ['title', 'description'], - threshold: 0.3, - includeScore: true, - minMatchCharLength: 2, // Minimum 2 characters for a match - shouldSort: true, - ignoreLocation: true // Search anywhere in the string - }); - - - // Re-apply filter if active - if (this.isFilterActive && this.searchInput) { - this.applyFilter(this.searchInput.value); - } - } - - private handleSearchInput(query: string): void { - // Cancel any pending filter - if (this.frameRequest) { - cancelAnimationFrame(this.frameRequest); - } - - // Debounce with requestAnimationFrame - this.frameRequest = requestAnimationFrame(() => { - if (query.length === 0) { - // Only clear when input is completely empty - this.clearFilter(); - } else { - // Let Fuse.js handle minimum character length via minMatchCharLength - this.applyFilter(query); - } - }); - } - - private applyFilter(query: string): void { - if (!this.fuse) { - return; - } - - // Perform fuzzy search - const results = this.fuse.search(query); - - // Extract matching event IDs - this.matchingEventIds.clear(); - results.forEach((result: FuseResult) => { - if (result.item && result.item.id) { - this.matchingEventIds.add(result.item.id); - } - }); - - // Update filter state - this.isFilterActive = true; - - // Update visual state - this.updateVisualState(); - - // Emit filter changed event - eventBus.emit(CoreEvents.FILTER_CHANGED, { - active: true, - query: query, - matchingIds: Array.from(this.matchingEventIds) - }); - - } - - private clearFilter(): void { - this.isFilterActive = false; - this.matchingEventIds.clear(); - - // Clear search input - if (this.searchInput) { - this.searchInput.value = ''; - } - - // Update visual state - this.updateVisualState(); - - // Emit filter cleared event - eventBus.emit(CoreEvents.FILTER_CHANGED, { - active: false, - query: '', - matchingIds: [] - }); - - } - - private updateVisualState(): void { - // Update search container styling - const searchContainer = document.querySelector('swp-search-container'); - if (searchContainer) { - if (this.isFilterActive) { - searchContainer.classList.add('filter-active'); - } else { - searchContainer.classList.remove('filter-active'); - } - } - - // Update all events layers - const eventsLayers = document.querySelectorAll('swp-events-layer'); - eventsLayers.forEach(layer => { - if (this.isFilterActive) { - layer.setAttribute('data-filter-active', 'true'); - - // Mark matching events - const events = layer.querySelectorAll('swp-event'); - events.forEach(event => { - const eventId = event.getAttribute('data-event-id'); - if (eventId && this.matchingEventIds.has(eventId)) { - event.setAttribute('data-matches', 'true'); - } else { - event.removeAttribute('data-matches'); - } - }); - } else { - layer.removeAttribute('data-filter-active'); - - // Remove all match attributes - const events = layer.querySelectorAll('swp-event'); - events.forEach(event => { - event.removeAttribute('data-matches'); - }); - } - }); - } - - /** - * Check if an event matches the current filter - */ - public eventMatchesFilter(eventId: string): boolean { - if (!this.isFilterActive) { - return true; // No filter active, all events match - } - return this.matchingEventIds.has(eventId); - } - - /** - * Get current filter state - */ - public getFilterState(): { active: boolean; matchingIds: string[] } { - return { - active: this.isFilterActive, - matchingIds: Array.from(this.matchingEventIds) - }; - } - -} \ No newline at end of file diff --git a/src/managers/EventLayoutCoordinator.ts b/src/managers/EventLayoutCoordinator.ts deleted file mode 100644 index ad777c3..0000000 --- a/src/managers/EventLayoutCoordinator.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * EventLayoutCoordinator - Coordinates event layout calculations - * - * Separates layout logic from rendering concerns. - * Calculates stack levels, groups events, and determines rendering strategy. - */ - -import { ICalendarEvent } from '../types/CalendarTypes'; -import { EventStackManager, IEventGroup, IStackLink } from './EventStackManager'; -import { PositionUtils } from '../utils/PositionUtils'; -import { Configuration } from '../configurations/CalendarConfig'; - -export interface IGridGroupLayout { - events: ICalendarEvent[]; - stackLevel: number; - position: { top: number }; - columns: ICalendarEvent[][]; // Events grouped by column (events in same array share a column) -} - -export interface IStackedEventLayout { - event: ICalendarEvent; - stackLink: IStackLink; - position: { top: number; height: number }; -} - -export interface IColumnLayout { - gridGroups: IGridGroupLayout[]; - stackedEvents: IStackedEventLayout[]; -} - -export class EventLayoutCoordinator { - private stackManager: EventStackManager; - private config: Configuration; - private positionUtils: PositionUtils; - - constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils) { - this.stackManager = stackManager; - this.config = config; - this.positionUtils = positionUtils; - } - - /** - * Calculate complete layout for a column of events (recursive approach) - */ - public calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout { - if (columnEvents.length === 0) { - return { gridGroups: [], stackedEvents: [] }; - } - - const gridGroupLayouts: IGridGroupLayout[] = []; - const stackedEventLayouts: IStackedEventLayout[] = []; - const renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> = []; - let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime()); - - // Process events recursively - while (remaining.length > 0) { - // Take first event - const firstEvent = remaining[0]; - - // Find events that could be in GRID with first event - // Use expanding search to find chains (A→B→C where each conflicts with next) - const gridSettings = this.config.gridSettings; - const thresholdMinutes = gridSettings.gridStartThresholdMinutes; - - // Use refactored method for expanding grid candidates - const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes); - - // Decide: should this group be GRID or STACK? - const group: IEventGroup = { - events: gridCandidates, - containerType: 'NONE', - startTime: firstEvent.start - }; - const containerType = this.stackManager.decideContainerType(group); - - if (containerType === 'GRID' && gridCandidates.length > 1) { - // Render as GRID - const gridStackLevel = this.calculateGridGroupStackLevelFromRendered( - gridCandidates, - renderedEventsWithLevels - ); - - // Ensure we get the earliest event (explicit sort for robustness) - const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0]; - const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end); - const columns = this.allocateColumns(gridCandidates); - - gridGroupLayouts.push({ - events: gridCandidates, - stackLevel: gridStackLevel, - position: { top: position.top + 1 }, - columns - }); - - // Mark all events in grid with their stack level - gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel })); - - // Remove all events in this grid from remaining - remaining = remaining.filter(e => !gridCandidates.includes(e)); - } else { - // Render first event as STACKED - const stackLevel = this.calculateStackLevelFromRendered( - firstEvent, - renderedEventsWithLevels - ); - - const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end); - stackedEventLayouts.push({ - event: firstEvent, - stackLink: { stackLevel }, - position: { top: position.top + 1, height: position.height - 3 } - }); - - // Mark this event with its stack level - renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel }); - - // Remove only first event from remaining - remaining = remaining.slice(1); - } - } - - return { - gridGroups: gridGroupLayouts, - stackedEvents: stackedEventLayouts - }; - } - - /** - * Calculate stack level for a grid group based on already rendered events - */ - private calculateGridGroupStackLevelFromRendered( - gridEvents: ICalendarEvent[], - renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> - ): number { - // Find highest stack level of any rendered event that overlaps with this grid - let maxOverlappingLevel = -1; - - for (const gridEvent of gridEvents) { - for (const rendered of renderedEventsWithLevels) { - if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) { - maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); - } - } - } - - return maxOverlappingLevel + 1; - } - - /** - * Calculate stack level for a single stacked event based on already rendered events - */ - private calculateStackLevelFromRendered( - event: ICalendarEvent, - renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> - ): number { - // Find highest stack level of any rendered event that overlaps with this event - let maxOverlappingLevel = -1; - - for (const rendered of renderedEventsWithLevels) { - if (this.stackManager.doEventsOverlap(event, rendered.event)) { - maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level); - } - } - - return maxOverlappingLevel + 1; - } - - /** - * Detect if two events have a conflict based on threshold - * - * @param event1 - First event - * @param event2 - Second event - * @param thresholdMinutes - Threshold in minutes - * @returns true if events conflict - */ - private detectConflict(event1: ICalendarEvent, event2: ICalendarEvent, thresholdMinutes: number): boolean { - // Check 1: Start-to-start conflict (starts within threshold) - const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60); - if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) { - return true; - } - - // Check 2: End-to-start conflict (event1 starts within threshold before event2 ends) - const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60); - if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) { - return true; - } - - // Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends) - const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60); - if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) { - return true; - } - - return false; - } - - /** - * Expand grid candidates to find all events connected by conflict chains - * - * Uses expanding search to find chains (A→B→C where each conflicts with next) - * - * @param firstEvent - The first event to start with - * @param remaining - Remaining events to check - * @param thresholdMinutes - Threshold in minutes - * @returns Array of all events in the conflict chain - */ - private expandGridCandidates( - firstEvent: ICalendarEvent, - remaining: ICalendarEvent[], - thresholdMinutes: number - ): ICalendarEvent[] { - const gridCandidates = [firstEvent]; - let candidatesChanged = true; - - // Keep expanding until no new candidates can be added - while (candidatesChanged) { - candidatesChanged = false; - - for (let i = 1; i < remaining.length; i++) { - const candidate = remaining[i]; - - // Skip if already in candidates - if (gridCandidates.includes(candidate)) continue; - - // Check if candidate conflicts with ANY event in gridCandidates - for (const existingCandidate of gridCandidates) { - if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) { - gridCandidates.push(candidate); - candidatesChanged = true; - break; // Found conflict, move to next candidate - } - } - } - } - - return gridCandidates; - } - - /** - * Allocate events to columns within a grid group - * - * Events that don't overlap can share the same column. - * Uses a greedy algorithm to minimize the number of columns. - * - * @param events - Events in the grid group (should already be sorted by start time) - * @returns Array of columns, where each column is an array of events - */ - private allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] { - if (events.length === 0) return []; - if (events.length === 1) return [[events[0]]]; - - const columns: ICalendarEvent[][] = []; - - // For each event, try to place it in an existing column where it doesn't overlap - for (const event of events) { - let placed = false; - - // Try to find a column where this event doesn't overlap with any existing event - for (const column of columns) { - const hasOverlap = column.some(colEvent => - this.stackManager.doEventsOverlap(event, colEvent) - ); - - if (!hasOverlap) { - column.push(event); - placed = true; - break; - } - } - - // If no suitable column found, create a new one - if (!placed) { - columns.push([event]); - } - } - - return columns; - } -} diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts deleted file mode 100644 index 8310c59..0000000 --- a/src/managers/EventManager.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { IEventBus, ICalendarEvent } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { Configuration } from '../configurations/CalendarConfig'; -import { DateService } from '../utils/DateService'; -import { EventService } from '../storage/events/EventService'; -import { IEntityService } from '../storage/IEntityService'; - -/** - * EventManager - Event lifecycle and CRUD operations - * Delegates all data operations to EventService - * EventService provides CRUD operations via BaseEntityService (save, delete, getAll) - */ -export class EventManager { - - private dateService: DateService; - private config: Configuration; - private eventService: EventService; - - constructor( - private eventBus: IEventBus, - dateService: DateService, - config: Configuration, - eventService: IEntityService - ) { - this.dateService = dateService; - this.config = config; - this.eventService = eventService as EventService; - } - - /** - * Load event data from service - * Ensures data is loaded (called during initialization) - */ - public async loadData(): Promise { - try { - // Just ensure service is ready - getAll() will return data - await this.eventService.getAll(); - } catch (error) { - console.error('Failed to load event data:', error); - throw error; - } - } - - /** - * Get all events from service - */ - public async getEvents(copy: boolean = false): Promise { - const events = await this.eventService.getAll(); - return copy ? [...events] : events; - } - - /** - * Get event by ID from service - */ - public async getEventById(id: string): Promise { - const event = await this.eventService.get(id); - return event || undefined; - } - - /** - * Get event by ID and return event info for navigation - * @param id Event ID to find - * @returns Event with navigation info or null if not found - */ - public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> { - const event = await this.getEventById(id); - if (!event) { - return null; - } - - // Validate event dates - const validation = this.dateService.validateDate(event.start); - if (!validation.valid) { - console.warn(`EventManager: Invalid event start date for event ${id}:`, validation.error); - return null; - } - - // Validate date range - if (!this.dateService.isValidRange(event.start, event.end)) { - console.warn(`EventManager: Invalid date range for event ${id}: start must be before end`); - return null; - } - - return { - event, - eventDate: event.start - }; - } - - /** - * Navigate to specific event by ID - * Emits navigation events for other managers to handle - * @param eventId Event ID to navigate to - * @returns true if event found and navigation initiated, false otherwise - */ - public async navigateToEvent(eventId: string): Promise { - const eventInfo = await this.getEventForNavigation(eventId); - if (!eventInfo) { - console.warn(`EventManager: Event with ID ${eventId} not found`); - return false; - } - - const { event, eventDate } = eventInfo; - - // Emit navigation request event - this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, { - eventId, - event, - eventDate, - eventStartTime: event.start - }); - - return true; - } - - /** - * Get events that overlap with a given time period - */ - public async getEventsForPeriod(startDate: Date, endDate: Date): Promise { - const events = await this.eventService.getAll(); - // Event overlaps period if it starts before period ends AND ends after period starts - return events.filter(event => { - return event.start <= endDate && event.end >= startDate; - }); - } - - /** - * Create a new event and add it to the calendar - * Generates ID and saves via EventService - */ - public async addEvent(event: Omit): Promise { - // Generate unique ID - const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - const newEvent: ICalendarEvent = { - ...event, - id, - syncStatus: 'synced' // No queue yet, mark as synced - }; - - await this.eventService.save(newEvent); - - this.eventBus.emit(CoreEvents.EVENT_CREATED, { - event: newEvent - }); - - return newEvent; - } - - /** - * Update an existing event - * Merges updates with existing event and saves - */ - public async updateEvent(id: string, updates: Partial): Promise { - try { - const existingEvent = await this.eventService.get(id); - if (!existingEvent) { - throw new Error(`Event with ID ${id} not found`); - } - - const updatedEvent: ICalendarEvent = { - ...existingEvent, - ...updates, - id, // Ensure ID doesn't change - syncStatus: 'synced' // No queue yet, mark as synced - }; - - await this.eventService.save(updatedEvent); - - this.eventBus.emit(CoreEvents.EVENT_UPDATED, { - event: updatedEvent - }); - - return updatedEvent; - } catch (error) { - console.error(`Failed to update event ${id}:`, error); - return null; - } - } - - /** - * Delete an event - * Calls EventService.delete() - */ - public async deleteEvent(id: string): Promise { - try { - await this.eventService.delete(id); - - this.eventBus.emit(CoreEvents.EVENT_DELETED, { - eventId: id - }); - - return true; - } catch (error) { - console.error(`Failed to delete event ${id}:`, error); - return false; - } - } -} diff --git a/src/v2/managers/EventPersistenceManager.ts b/src/managers/EventPersistenceManager.ts similarity index 100% rename from src/v2/managers/EventPersistenceManager.ts rename to src/managers/EventPersistenceManager.ts diff --git a/src/managers/EventStackManager.ts b/src/managers/EventStackManager.ts deleted file mode 100644 index a3fca1d..0000000 --- a/src/managers/EventStackManager.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * EventStackManager - Manages visual stacking of overlapping calendar events - * - * This class handles the creation and maintenance of "stack chains" - doubly-linked - * lists of overlapping events stored directly in DOM elements via data attributes. - * - * Implements 3-phase algorithm for grid + nested stacking: - * Phase 1: Group events by start time proximity (configurable threshold) - * Phase 2: Decide container type (GRID vs STACKING) - * Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED) - * - * @see STACKING_CONCEPT.md for detailed documentation - * @see stacking-visualization.html for visual examples - */ - -import { ICalendarEvent } from '../types/CalendarTypes'; -import { Configuration } from '../configurations/CalendarConfig'; - -export interface IStackLink { - prev?: string; // Event ID of previous event in stack - next?: string; // Event ID of next event in stack - stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.) -} - -export interface IEventGroup { - events: ICalendarEvent[]; - containerType: 'NONE' | 'GRID' | 'STACKING'; - startTime: Date; -} - -export class EventStackManager { - private static readonly STACK_OFFSET_PX = 15; - private config: Configuration; - - constructor(config: Configuration) { - this.config = config; - } - - // ============================================ - // PHASE 1: Start Time Grouping - // ============================================ - - /** - * Group events by time conflicts (both start-to-start and end-to-start within threshold) - * - * Events are grouped if: - * 1. They start within ±threshold minutes of each other (start-to-start) - * 2. One event starts within threshold minutes before another ends (end-to-start conflict) - */ - public groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[] { - if (events.length === 0) return []; - - // Get threshold from config - const gridSettings = this.config.gridSettings; - const thresholdMinutes = gridSettings.gridStartThresholdMinutes; - - // Sort events by start time - const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); - - const groups: IEventGroup[] = []; - - for (const event of sorted) { - // Find existing group that this event conflicts with - const existingGroup = groups.find(group => { - // Check if event conflicts with ANY event in the group - return group.events.some(groupEvent => { - // Start-to-start conflict: events start within threshold - const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60); - if (startToStartMinutes <= thresholdMinutes) { - return true; - } - - // End-to-start conflict: event starts within threshold before groupEvent ends - const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60); - if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) { - return true; - } - - // Also check reverse: groupEvent starts within threshold before event ends - const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60); - if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) { - return true; - } - - return false; - }); - }); - - if (existingGroup) { - existingGroup.events.push(event); - } else { - groups.push({ - events: [event], - containerType: 'NONE', - startTime: event.start - }); - } - } - - return groups; - } - - - // ============================================ - // PHASE 2: Container Type Decision - // ============================================ - - /** - * Decide container type for a group of events - * - * Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID, - * even if they overlap each other. This provides better visual indication that - * events start at the same time. - */ - public decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING' { - if (group.events.length === 1) { - return 'NONE'; - } - - // If events are grouped together (start within threshold), they should share columns (GRID) - // This is true EVEN if they overlap, because the visual priority is to show - // that they start simultaneously. - return 'GRID'; - } - - - /** - * Check if two events overlap in time - */ - public doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean { - return event1.start < event2.end && event1.end > event2.start; - } - - - // ============================================ - // Stack Level Calculation - // ============================================ - - /** - * Create optimized stack links (events share levels when possible) - */ - public createOptimizedStackLinks(events: ICalendarEvent[]): Map { - const stackLinks = new Map(); - - if (events.length === 0) return stackLinks; - - // Sort by start time - const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); - - // Step 1: Assign stack levels - for (const event of sorted) { - // Find all events this event overlaps with - const overlapping = sorted.filter(other => - other !== event && this.doEventsOverlap(event, other) - ); - - // Find the MINIMUM required level (must be above all overlapping events) - let minRequiredLevel = 0; - for (const other of overlapping) { - const otherLink = stackLinks.get(other.id); - if (otherLink) { - // Must be at least one level above the overlapping event - minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1); - } - } - - stackLinks.set(event.id, { stackLevel: minRequiredLevel }); - } - - // Step 2: Build prev/next chains for overlapping events at adjacent stack levels - for (const event of sorted) { - const currentLink = stackLinks.get(event.id)!; - - // Find overlapping events that are directly below (stackLevel - 1) - const overlapping = sorted.filter(other => - other !== event && this.doEventsOverlap(event, other) - ); - - const directlyBelow = overlapping.filter(other => { - const otherLink = stackLinks.get(other.id); - return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1; - }); - - if (directlyBelow.length > 0) { - // Use the first one in sorted order as prev - currentLink.prev = directlyBelow[0].id; - } - - // Find overlapping events that are directly above (stackLevel + 1) - const directlyAbove = overlapping.filter(other => { - const otherLink = stackLinks.get(other.id); - return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1; - }); - - if (directlyAbove.length > 0) { - // Use the first one in sorted order as next - currentLink.next = directlyAbove[0].id; - } - } - - return stackLinks; - } - - /** - * Calculate marginLeft based on stack level - */ - public calculateMarginLeft(stackLevel: number): number { - return stackLevel * EventStackManager.STACK_OFFSET_PX; - } - - /** - * Calculate zIndex based on stack level - */ - public calculateZIndex(stackLevel: number): number { - return 100 + stackLevel; - } - - /** - * Serialize stack link to JSON string - */ - public serializeStackLink(stackLink: IStackLink): string { - return JSON.stringify(stackLink); - } - - /** - * Deserialize JSON string to stack link - */ - public deserializeStackLink(json: string): IStackLink | null { - try { - return JSON.parse(json); - } catch (e) { - return null; - } - } - - /** - * Apply stack link to DOM element - */ - public applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void { - element.dataset.stackLink = this.serializeStackLink(stackLink); - } - - /** - * Get stack link from DOM element - */ - public getStackLinkFromElement(element: HTMLElement): IStackLink | null { - const data = element.dataset.stackLink; - if (!data) return null; - return this.deserializeStackLink(data); - } - - /** - * Apply visual styling to element based on stack level - */ - public applyVisualStyling(element: HTMLElement, stackLevel: number): void { - element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`; - element.style.zIndex = `${this.calculateZIndex(stackLevel)}`; - } - - /** - * Clear stack link from element - */ - public clearStackLinkFromElement(element: HTMLElement): void { - delete element.dataset.stackLink; - } - - /** - * Clear visual styling from element - */ - public clearVisualStyling(element: HTMLElement): void { - element.style.marginLeft = ''; - element.style.zIndex = ''; - } -} diff --git a/src/managers/GridManager.ts b/src/managers/GridManager.ts deleted file mode 100644 index ad02681..0000000 --- a/src/managers/GridManager.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * GridManager - Simplified grid manager using centralized GridRenderer - * Delegates DOM rendering to GridRenderer, focuses on coordination - * - * Note: Events are now provided by IColumnDataSource (each column has its own events) - */ - -import { eventBus } from '../core/EventBus'; -import { CoreEvents } from '../constants/CoreEvents'; -import { CalendarView } from '../types/CalendarTypes'; -import { GridRenderer } from '../renderers/GridRenderer'; -import { DateService } from '../utils/DateService'; -import { IColumnDataSource } from '../types/ColumnDataSource'; -import { Configuration } from '../configurations/CalendarConfig'; - -/** - * Simplified GridManager focused on coordination, delegates rendering to GridRenderer - */ -export class GridManager { - private container: HTMLElement | null = null; - private currentDate: Date = new Date(); - private currentView: CalendarView = 'week'; - private gridRenderer: GridRenderer; - private dateService: DateService; - private config: Configuration; - private dataSource: IColumnDataSource; - - constructor( - gridRenderer: GridRenderer, - dateService: DateService, - config: Configuration, - dataSource: IColumnDataSource - ) { - this.gridRenderer = gridRenderer; - this.dateService = dateService; - this.config = config; - this.dataSource = dataSource; - this.init(); - } - - private init(): void { - this.findElements(); - this.subscribeToEvents(); - } - - private findElements(): void { - this.container = document.querySelector('swp-calendar-container'); - } - - private subscribeToEvents(): void { - // Listen for view changes - eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => { - const detail = (e as CustomEvent).detail; - this.currentView = detail.currentView; - this.dataSource.setCurrentView(this.currentView); - this.render(); - }); - - // Listen for navigation events from NavigationManager - // NavigationManager has already created new grid with animation - // GridManager only needs to update state, NOT re-render - eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => { - const detail = (e as CustomEvent).detail; - this.currentDate = detail.newDate; - this.dataSource.setCurrentDate(this.currentDate); - // Do NOT call render() - NavigationManager already created new grid - }); - - // Listen for config changes that affect rendering - eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => { - this.render(); - }); - - eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => { - this.render(); - }); - } - - - /** - * Main render method - delegates to GridRenderer - * Note: CSS variables are automatically updated by ConfigManager when config changes - * Note: Events are included in columns from IColumnDataSource - */ - public async render(): Promise { - if (!this.container) { - return; - } - - // Get columns from datasource - single source of truth (includes events per column) - const columns = await this.dataSource.getColumns(); - - // Set grid columns CSS variable based on actual column count - document.documentElement.style.setProperty('--grid-columns', columns.length.toString()); - - // Delegate to GridRenderer with columns (events are inside each column) - this.gridRenderer.renderGrid( - this.container, - this.currentDate, - this.currentView, - columns - ); - - // Emit grid rendered event - eventBus.emit(CoreEvents.GRID_RENDERED, { - container: this.container, - currentDate: this.currentDate, - columns: columns - }); - } -} \ No newline at end of file diff --git a/src/managers/HeaderManager.ts b/src/managers/HeaderManager.ts deleted file mode 100644 index 41b0358..0000000 --- a/src/managers/HeaderManager.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { eventBus } from '../core/EventBus'; -import { Configuration } from '../configurations/CalendarConfig'; -import { CoreEvents } from '../constants/CoreEvents'; -import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer'; -import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes'; -import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; -import { IColumnDataSource } from '../types/ColumnDataSource'; - -/** - * HeaderManager - Handles all header-related event logic - * Separates event handling from rendering concerns - * Uses dependency injection for renderer strategy - */ -export class HeaderManager { - private headerRenderer: IHeaderRenderer; - private config: Configuration; - private dataSource: IColumnDataSource; - - constructor(headerRenderer: IHeaderRenderer, config: Configuration, dataSource: IColumnDataSource) { - this.headerRenderer = headerRenderer; - this.config = config; - this.dataSource = dataSource; - - // Bind handler methods for event listeners - this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this); - this.handleDragMouseLeaveHeader = this.handleDragMouseLeaveHeader.bind(this); - - // Listen for navigation events to update header - this.setupNavigationListener(); - } - - /** - * Setup header drag event listeners - Listen to DragDropManager events - */ - public setupHeaderDragListeners(): void { - console.log('🎯 HeaderManager: Setting up drag event listeners'); - - // Subscribe to drag events from DragDropManager - eventBus.on('drag:mouseenter-header', this.handleDragMouseEnterHeader); - eventBus.on('drag:mouseleave-header', this.handleDragMouseLeaveHeader); - - console.log('✅ HeaderManager: Drag event listeners attached'); - } - - /** - * Handle drag mouse enter header event - */ - private handleDragMouseEnterHeader(event: Event): void { - const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } = - (event as CustomEvent).detail; - - console.log('🎯 HeaderManager: Received drag:mouseenter-header', { - targetColumn: targetColumn.identifier, - originalElement: !!originalElement, - cloneElement: !!cloneElement - }); - } - - /** - * Handle drag mouse leave header event - */ - private handleDragMouseLeaveHeader(event: Event): void { - const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } = - (event as CustomEvent).detail; - - console.log('🚪 HeaderManager: Received drag:mouseleave-header', { - targetColumn: targetColumn?.identifier, - originalElement: !!originalElement, - cloneElement: !!cloneElement - }); - } - - /** - * Setup navigation event listener - */ - private setupNavigationListener(): void { - eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => { - const { currentDate } = (event as CustomEvent).detail; - this.updateHeader(currentDate); - }); - - // Also listen for date changes (including initial setup) - eventBus.on(CoreEvents.DATE_CHANGED, (event) => { - const { currentDate } = (event as CustomEvent).detail; - this.updateHeader(currentDate); - }); - - // Listen for workweek header updates after grid rebuild - //currentDate: this.currentDate, - //currentView: this.currentView, - //workweek: this.config.currentWorkWeek - eventBus.on('workweek:header-update', (event) => { - const { currentDate } = (event as CustomEvent).detail; - this.updateHeader(currentDate); - }); - - } - - /** - * Update header content for navigation - */ - private async updateHeader(currentDate: Date): Promise { - console.log('🎯 HeaderManager.updateHeader called', { - currentDate, - rendererType: this.headerRenderer.constructor.name - }); - - const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement; - if (!calendarHeader) { - console.warn('❌ HeaderManager: No calendar header found!'); - return; - } - - // Clear existing content - calendarHeader.innerHTML = ''; - - // Update DataSource with current date and get columns - this.dataSource.setCurrentDate(currentDate); - const columns = await this.dataSource.getColumns(); - - // Render new header content using injected renderer - const context: IHeaderRenderContext = { - columns: columns, - config: this.config - }; - - this.headerRenderer.render(calendarHeader, context); - - // Setup event listeners on the new content - this.setupHeaderDragListeners(); - - // Notify other managers that header is ready with period data - const payload: IHeaderReadyEventPayload = { - headerElements: ColumnDetectionUtils.getHeaderColumns(), - }; - eventBus.emit('header:ready', payload); - } -} diff --git a/src/managers/NavigationManager.ts b/src/managers/NavigationManager.ts deleted file mode 100644 index ead6dd0..0000000 --- a/src/managers/NavigationManager.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { IEventBus, CalendarView } from '../types/CalendarTypes'; -import { EventRenderingService } from '../renderers/EventRendererManager'; -import { DateService } from '../utils/DateService'; -import { CoreEvents } from '../constants/CoreEvents'; -import { WeekInfoRenderer } from '../renderers/WeekInfoRenderer'; -import { GridRenderer } from '../renderers/GridRenderer'; -import { INavButtonClickedEventPayload } from '../types/EventTypes'; -import { IColumnDataSource } from '../types/ColumnDataSource'; -import { Configuration } from '../configurations/CalendarConfig'; - -export class NavigationManager { - private eventBus: IEventBus; - private weekInfoRenderer: WeekInfoRenderer; - private gridRenderer: GridRenderer; - private dateService: DateService; - private config: Configuration; - private dataSource: IColumnDataSource; - private currentWeek: Date; - private targetWeek: Date; - private animationQueue: number = 0; - - constructor( - eventBus: IEventBus, - eventRenderer: EventRenderingService, - gridRenderer: GridRenderer, - dateService: DateService, - weekInfoRenderer: WeekInfoRenderer, - config: Configuration, - dataSource: IColumnDataSource - ) { - this.eventBus = eventBus; - this.dateService = dateService; - this.weekInfoRenderer = weekInfoRenderer; - this.gridRenderer = gridRenderer; - this.config = config; - this.currentWeek = this.getISOWeekStart(new Date()); - this.targetWeek = new Date(this.currentWeek); - this.dataSource = dataSource; - this.init(); - } - - private init(): void { - this.setupEventListeners(); - } - - /** - * Get the start of the ISO week (Monday) for a given date - * @param date - Any date in the week - * @returns The Monday of the ISO week - */ - private getISOWeekStart(date: Date): Date { - const weekBounds = this.dateService.getWeekBounds(date); - return this.dateService.startOfDay(weekBounds.start); - } - - - private setupEventListeners(): void { - - // Listen for filter changes and apply to pre-rendered grids - this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => { - const detail = (e as CustomEvent).detail; - this.weekInfoRenderer.applyFilterToPreRenderedGrids(detail); - }); - - // Listen for navigation button clicks from NavigationButtons - this.eventBus.on(CoreEvents.NAV_BUTTON_CLICKED, (event: Event) => { - const { direction, newDate } = (event as CustomEvent).detail; - - // Navigate to the new date with animation - this.navigateToDate(newDate, direction); - }); - - // Listen for external navigation requests - this.eventBus.on(CoreEvents.DATE_CHANGED, (event: Event) => { - const customEvent = event as CustomEvent; - const dateFromEvent = customEvent.detail.currentDate; - - // Validate date before processing - if (!dateFromEvent) { - console.warn('NavigationManager: No date provided in DATE_CHANGED event'); - return; - } - - const targetDate = new Date(dateFromEvent); - - // Use DateService validation - const validation = this.dateService.validateDate(targetDate); - if (!validation.valid) { - console.warn('NavigationManager: Invalid date received:', validation.error); - return; - } - - this.navigateToDate(targetDate); - }); - - // Listen for event navigation requests - this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => { - const customEvent = event as CustomEvent; - const { eventDate, eventStartTime } = customEvent.detail; - - if (!eventDate || !eventStartTime) { - console.warn('NavigationManager: Invalid event navigation data'); - return; - } - - this.navigateToEventDate(eventDate, eventStartTime); - }); - } - - /** - * Navigate to specific event date and emit scroll event after navigation - */ - private navigateToEventDate(eventDate: Date, eventStartTime: string): void { - const weekStart = this.getISOWeekStart(eventDate); - this.targetWeek = new Date(weekStart); - - const currentTime = this.currentWeek.getTime(); - const targetTime = weekStart.getTime(); - - // Store event start time for scrolling after navigation - const scrollAfterNavigation = () => { - // Emit scroll request after navigation is complete - this.eventBus.emit('scroll:to-event-time', { - eventStartTime - }); - }; - - if (currentTime < targetTime) { - this.animationQueue++; - this.animateTransition('next', weekStart); - // Listen for navigation completion to trigger scroll - this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation); - } else if (currentTime > targetTime) { - this.animationQueue++; - this.animateTransition('prev', weekStart); - // Listen for navigation completion to trigger scroll - this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation); - } else { - // Already on correct week, just scroll - scrollAfterNavigation(); - } - } - - - private navigateToDate(date: Date, direction?: 'next' | 'previous' | 'today'): void { - const weekStart = this.getISOWeekStart(date); - this.targetWeek = new Date(weekStart); - - const currentTime = this.currentWeek.getTime(); - const targetTime = weekStart.getTime(); - - // Use provided direction or calculate based on time comparison - let animationDirection: 'next' | 'prev'; - - if (direction === 'next') { - animationDirection = 'next'; - } else if (direction === 'previous') { - animationDirection = 'prev'; - } else if (direction === 'today') { - // For "today", determine direction based on current position - animationDirection = currentTime < targetTime ? 'next' : 'prev'; - } else { - // Fallback: calculate direction - animationDirection = currentTime < targetTime ? 'next' : 'prev'; - } - - if (currentTime !== targetTime) { - this.animationQueue++; - this.animateTransition(animationDirection, weekStart); - } - } - - /** - * Animation transition using pre-rendered containers when available - */ - private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise { - - const container = document.querySelector('swp-calendar-container') as HTMLElement; - const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement; - - if (!container || !currentGrid) { - return; - } - - // Reset all-day height BEFORE creating new grid to ensure base height - const root = document.documentElement; - root.style.setProperty('--all-day-row-height', '0px'); - - let newGrid: HTMLElement; - - console.group('🔧 NavigationManager.refactored'); - console.log('Calling GridRenderer instead of NavigationRenderer'); - console.log('Target week:', targetWeek); - - // Update DataSource with target week and get columns - this.dataSource.setCurrentDate(targetWeek); - const columns = await this.dataSource.getColumns(); - - // Always create a fresh container for consistent behavior - newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek); - - console.groupEnd(); - - - // Clear any existing transforms before animation - newGrid.style.transform = ''; - currentGrid.style.transform = ''; - - // Animate transition using Web Animations API - const slideOutAnimation = currentGrid.animate([ - { transform: 'translateX(0)', opacity: '1' }, - { transform: direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)', opacity: '0.5' } - ], { - duration: 400, - easing: 'ease-in-out', - fill: 'forwards' - }); - - const slideInAnimation = newGrid.animate([ - { transform: direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)' }, - { transform: 'translateX(0)' } - ], { - duration: 400, - easing: 'ease-in-out', - fill: 'forwards' - }); - - // Handle animation completion - slideInAnimation.addEventListener('finish', () => { - - // Cleanup: Remove all old grids except the new one - const allGrids = container.querySelectorAll('swp-grid-container'); - for (let i = 0; i < allGrids.length - 1; i++) { - allGrids[i].remove(); - } - - // Reset positioning - newGrid.style.position = 'relative'; - newGrid.removeAttribute('data-prerendered'); - - // Update state - this.currentWeek = new Date(targetWeek); - this.animationQueue--; - - // If this was the last queued animation, ensure we're in sync - if (this.animationQueue === 0) { - this.currentWeek = new Date(this.targetWeek); - } - - // Emit navigation completed event - this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, { - direction, - newDate: this.currentWeek - }); - - }); - } -} \ No newline at end of file diff --git a/src/managers/ResizeHandleManager.ts b/src/managers/ResizeHandleManager.ts deleted file mode 100644 index 3ea77ae..0000000 --- a/src/managers/ResizeHandleManager.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { eventBus } from '../core/EventBus'; -import { Configuration } from '../configurations/CalendarConfig'; -import { IResizeEndEventPayload } from '../types/EventTypes'; -import { PositionUtils } from '../utils/PositionUtils'; - -type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void }; - -export class ResizeHandleManager { - private isResizing = false; - private targetEl: SwpEventEl | null = null; - - private startY = 0; - private startDurationMin = 0; - - private snapMin: number; - private minDurationMin: number; - private animationId: number | null = null; - private currentHeight = 0; - private targetHeight = 0; - - private pointerCaptured = false; - private prevZ?: string; - - // Constants for better maintainability - private readonly ANIMATION_SPEED = 0.35; - private readonly Z_INDEX_RESIZING = '1000'; - private readonly EVENT_REFRESH_THRESHOLD = 0.5; - - constructor( - private config: Configuration, - private positionUtils: PositionUtils - ) { - const grid = this.config.gridSettings; - this.snapMin = grid.snapInterval; - this.minDurationMin = this.snapMin; - } - - public initialize(): void { - this.attachGlobalListeners(); - } - - public destroy(): void { - this.removeEventListeners(); - } - - private removeEventListeners(): void { - const calendarContainer = document.querySelector('swp-calendar-container'); - if (calendarContainer) { - calendarContainer.removeEventListener('mouseover', this.onMouseOver, true); - } - - document.removeEventListener('pointerdown', this.onPointerDown, true); - document.removeEventListener('pointermove', this.onPointerMove, true); - document.removeEventListener('pointerup', this.onPointerUp, true); - } - - private createResizeHandle(): HTMLElement { - const handle = document.createElement('swp-resize-handle'); - handle.setAttribute('aria-label', 'Resize event'); - handle.setAttribute('role', 'separator'); - return handle; - } - - private attachGlobalListeners(): void { - const calendarContainer = document.querySelector('swp-calendar-container'); - - if (calendarContainer) { - calendarContainer.addEventListener('mouseover', this.onMouseOver, true); - } - - document.addEventListener('pointerdown', this.onPointerDown, true); - document.addEventListener('pointermove', this.onPointerMove, true); - document.addEventListener('pointerup', this.onPointerUp, true); - } - - private onMouseOver = (e: Event): void => { - const target = e.target as HTMLElement; - const eventElement = target.closest('swp-event'); - - if (eventElement && !this.isResizing) { - // Check if handle already exists - if (!eventElement.querySelector(':scope > swp-resize-handle')) { - const handle = this.createResizeHandle(); - eventElement.appendChild(handle); - } - } - }; - - private onPointerDown = (e: PointerEvent): void => { - const handle = (e.target as HTMLElement).closest('swp-resize-handle'); - if (!handle) return; - - const element = handle.parentElement as SwpEventEl; - this.startResizing(element, e); - }; - - private startResizing(element: SwpEventEl, event: PointerEvent): void { - this.targetEl = element; - this.isResizing = true; - this.startY = event.clientY; - - const startHeight = element.offsetHeight; - this.startDurationMin = Math.max( - this.minDurationMin, - Math.round(this.positionUtils.pixelsToMinutes(startHeight)) - ); - - this.setZIndexForResizing(element); - this.capturePointer(event); - document.documentElement.classList.add('swp--resizing'); - event.preventDefault(); - } - - private setZIndexForResizing(element: SwpEventEl): void { - const container = element.closest('swp-event-group') ?? element; - this.prevZ = container.style.zIndex; - container.style.zIndex = this.Z_INDEX_RESIZING; - } - - private capturePointer(event: PointerEvent): void { - try { - (event.target as Element).setPointerCapture?.(event.pointerId); - this.pointerCaptured = true; - } catch (error) { - console.warn('Pointer capture failed:', error); - } - } - - private onPointerMove = (e: PointerEvent): void => { - if (!this.isResizing || !this.targetEl) return; - - this.updateResizeHeight(e.clientY); - }; - - private updateResizeHeight(currentY: number): void { - const deltaY = currentY - this.startY; - - const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin); - const rawHeight = startHeight + deltaY; - const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin); - - this.targetHeight = Math.max(minHeight, rawHeight); - - if (this.animationId == null) { - this.currentHeight = this.targetEl?.offsetHeight!!; - this.animate(); - } - } - - private animate = (): void => { - if (!this.isResizing || !this.targetEl) { - this.animationId = null; - return; - } - - const diff = this.targetHeight - this.currentHeight; - - if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) { - this.currentHeight += diff * this.ANIMATION_SPEED; - this.targetEl.updateHeight?.(this.currentHeight); - this.animationId = requestAnimationFrame(this.animate); - } else { - this.finalizeAnimation(); - } - }; - - private finalizeAnimation(): void { - if (!this.targetEl) return; - - this.currentHeight = this.targetHeight; - this.targetEl.updateHeight?.(this.currentHeight); - this.animationId = null; - } - - private onPointerUp = (e: PointerEvent): void => { - if (!this.isResizing || !this.targetEl) return; - - this.cleanupAnimation(); - this.snapToGrid(); - this.emitResizeEndEvent(); - this.cleanupResizing(e); - }; - - private cleanupAnimation(): void { - if (this.animationId != null) { - cancelAnimationFrame(this.animationId); - this.animationId = null; - } - } - - private snapToGrid(): void { - if (!this.targetEl) return; - - const currentHeight = this.targetEl.offsetHeight; - const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin); - const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx; - const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin); - const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines - - this.targetEl.updateHeight?.(finalHeight); - } - - private emitResizeEndEvent(): void { - if (!this.targetEl) return; - - const eventId = this.targetEl.dataset.eventId || ''; - const resizeEndPayload: IResizeEndEventPayload = { - eventId, - element: this.targetEl, - finalHeight: this.targetEl.offsetHeight - }; - - eventBus.emit('resize:end', resizeEndPayload); - } - - private cleanupResizing(event: PointerEvent): void { - this.restoreZIndex(); - this.releasePointer(event); - - this.isResizing = false; - this.targetEl = null; - - document.documentElement.classList.remove('swp--resizing'); - } - - private restoreZIndex(): void { - if (!this.targetEl || this.prevZ === undefined) return; - - const container = this.targetEl.closest('swp-event-group') ?? this.targetEl; - container.style.zIndex = this.prevZ; - this.prevZ = undefined; - } - - private releasePointer(event: PointerEvent): void { - if (!this.pointerCaptured) return; - - try { - (event.target as Element).releasePointerCapture?.(event.pointerId); - this.pointerCaptured = false; - } catch (error) { - console.warn('Pointer release failed:', error); - } - } -} \ No newline at end of file diff --git a/src/v2/managers/ResizeManager.ts b/src/managers/ResizeManager.ts similarity index 100% rename from src/v2/managers/ResizeManager.ts rename to src/managers/ResizeManager.ts diff --git a/src/managers/ScrollManager.ts b/src/managers/ScrollManager.ts deleted file mode 100644 index 518da9f..0000000 --- a/src/managers/ScrollManager.ts +++ /dev/null @@ -1,260 +0,0 @@ -// Custom scroll management for calendar week container - -import { eventBus } from '../core/EventBus'; -import { CoreEvents } from '../constants/CoreEvents'; -import { PositionUtils } from '../utils/PositionUtils'; - -/** - * Manages scrolling functionality for the calendar using native scrollbars - */ -export class ScrollManager { - private scrollableContent: HTMLElement | null = null; - private calendarContainer: HTMLElement | null = null; - private timeAxis: HTMLElement | null = null; - private calendarHeader: HTMLElement | null = null; - private resizeObserver: ResizeObserver | null = null; - private positionUtils: PositionUtils; - - constructor(positionUtils: PositionUtils) { - this.positionUtils = positionUtils; - this.init(); - } - - private init(): void { - this.subscribeToEvents(); - } - - /** - * Public method to initialize scroll after grid is rendered - */ - public initialize(): void { - this.setupScrolling(); - } - - private subscribeToEvents(): void { - // Handle navigation animation completion - sync time axis position - eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => { - this.syncTimeAxisPosition(); - this.setupScrolling(); - }); - - // Handle all-day row height changes - eventBus.on('header:height-changed', () => { - this.updateScrollableHeight(); - }); - - // Handle header ready - refresh header reference and re-sync - eventBus.on('header:ready', () => { - this.calendarHeader = document.querySelector('swp-calendar-header'); - if (this.scrollableContent && this.calendarHeader) { - this.setupHorizontalScrollSynchronization(); - this.syncCalendarHeaderPosition(); // Immediately sync position - } - this.updateScrollableHeight(); // Update height calculations - }); - - // Handle window resize - window.addEventListener('resize', () => { - this.updateScrollableHeight(); - }); - - // Listen for scroll to event time requests - eventBus.on('scroll:to-event-time', (event: Event) => { - const customEvent = event as CustomEvent; - const { eventStartTime } = customEvent.detail; - - if (eventStartTime) { - this.scrollToEventTime(eventStartTime); - } - }); - } - - /** - * Setup scrolling functionality after grid is rendered - */ - private setupScrolling(): void { - this.findElements(); - - if (this.scrollableContent && this.calendarContainer) { - this.setupResizeObserver(); - this.updateScrollableHeight(); - this.setupScrollSynchronization(); - } - - // Setup horizontal scrolling synchronization - if (this.scrollableContent && this.calendarHeader) { - this.setupHorizontalScrollSynchronization(); - } - } - - /** - * Find DOM elements needed for scrolling - */ - private findElements(): void { - this.scrollableContent = document.querySelector('swp-scrollable-content'); - this.calendarContainer = document.querySelector('swp-calendar-container'); - this.timeAxis = document.querySelector('swp-time-axis'); - this.calendarHeader = document.querySelector('swp-calendar-header'); - - } - - /** - * Scroll to specific position - */ - scrollTo(scrollTop: number): void { - if (!this.scrollableContent) return; - - this.scrollableContent.scrollTop = scrollTop; - } - - /** - * Scroll to specific hour using PositionUtils - */ - scrollToHour(hour: number): void { - // Create time string for the hour - const timeString = `${hour.toString().padStart(2, '0')}:00`; - const scrollTop = this.positionUtils.timeToPixels(timeString); - - this.scrollTo(scrollTop); - } - - /** - * Scroll to specific event time - * @param eventStartTime ISO string of event start time - */ - scrollToEventTime(eventStartTime: string): void { - try { - const eventDate = new Date(eventStartTime); - const eventHour = eventDate.getHours(); - const eventMinutes = eventDate.getMinutes(); - - // Convert to decimal hour (e.g., 14:30 becomes 14.5) - const decimalHour = eventHour + (eventMinutes / 60); - - this.scrollToHour(decimalHour); - } catch (error) { - console.warn('ScrollManager: Failed to scroll to event time:', error); - } - } - - /** - * Setup ResizeObserver to monitor container size changes - */ - private setupResizeObserver(): void { - if (!this.calendarContainer) return; - - // Clean up existing observer - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } - - this.resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - this.updateScrollableHeight(); - } - }); - - this.resizeObserver.observe(this.calendarContainer); - } - - /** - * Calculate and update scrollable content height dynamically - */ - private updateScrollableHeight(): void { - if (!this.scrollableContent || !this.calendarContainer) return; - - // Get calendar container height - const containerRect = this.calendarContainer.getBoundingClientRect(); - - // Find navigation height - const navigation = document.querySelector('swp-calendar-nav'); - const navHeight = navigation ? navigation.getBoundingClientRect().height : 0; - - // Find calendar header height - const calendarHeaderElement = document.querySelector('swp-calendar-header'); - const headerHeight = calendarHeaderElement ? calendarHeaderElement.getBoundingClientRect().height : 80; - - // Calculate available height for scrollable content - const availableHeight = containerRect.height - headerHeight; - - // Calculate available width (container width minus time-axis) - const availableWidth = containerRect.width - 60; // 60px time-axis - - // Set the height and width on scrollable content - if (availableHeight > 0) { - this.scrollableContent.style.height = `${availableHeight}px`; - } - if (availableWidth > 0) { - this.scrollableContent.style.width = `${availableWidth}px`; - } - } - - /** - * Setup scroll synchronization between scrollable content and time axis - */ - private setupScrollSynchronization(): void { - if (!this.scrollableContent || !this.timeAxis) return; - - // Throttle scroll events for better performance - let scrollTimeout: number | null = null; - - this.scrollableContent.addEventListener('scroll', () => { - if (scrollTimeout) { - cancelAnimationFrame(scrollTimeout); - } - - scrollTimeout = requestAnimationFrame(() => { - this.syncTimeAxisPosition(); - }); - }); - } - - /** - * Synchronize time axis position with scrollable content - */ - private syncTimeAxisPosition(): void { - if (!this.scrollableContent || !this.timeAxis) return; - - const scrollTop = this.scrollableContent.scrollTop; - const timeAxisContent = this.timeAxis.querySelector('swp-time-axis-content'); - - if (timeAxisContent) { - // Use transform for smooth performance - (timeAxisContent as HTMLElement).style.transform = `translateY(-${scrollTop}px)`; - - // Debug logging (can be removed later) - if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam - } - } - } - - /** - * Setup horizontal scroll synchronization between scrollable content and calendar header - */ - private setupHorizontalScrollSynchronization(): void { - if (!this.scrollableContent || !this.calendarHeader) return; - - - // Listen to horizontal scroll events - this.scrollableContent.addEventListener('scroll', () => { - this.syncCalendarHeaderPosition(); - }); - } - - /** - * Synchronize calendar header position with scrollable content horizontal scroll - */ - private syncCalendarHeaderPosition(): void { - if (!this.scrollableContent || !this.calendarHeader) return; - - const scrollLeft = this.scrollableContent.scrollLeft; - - // Use transform for smooth performance - this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`; - - // Debug logging (can be removed later) - if (scrollLeft % 100 === 0) { // Only log every 100px to avoid spam - } - } - -} \ No newline at end of file diff --git a/src/managers/WorkHoursManager.ts b/src/managers/WorkHoursManager.ts deleted file mode 100644 index 886af11..0000000 --- a/src/managers/WorkHoursManager.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Work hours management for per-column scheduling - -import { DateService } from '../utils/DateService'; -import { Configuration } from '../configurations/CalendarConfig'; -import { PositionUtils } from '../utils/PositionUtils'; - -/** - * Work hours for a specific day - */ -export interface IDayWorkHours { - start: number; // Hour (0-23) - end: number; // Hour (0-23) -} - -/** - * Work schedule configuration - */ -export interface IWorkScheduleConfig { - weeklyDefault: { - monday: IDayWorkHours | 'off'; - tuesday: IDayWorkHours | 'off'; - wednesday: IDayWorkHours | 'off'; - thursday: IDayWorkHours | 'off'; - friday: IDayWorkHours | 'off'; - saturday: IDayWorkHours | 'off'; - sunday: IDayWorkHours | 'off'; - }; - dateOverrides: { - [dateString: string]: IDayWorkHours | 'off'; // YYYY-MM-DD format - }; -} - -/** - * Manages work hours scheduling with weekly defaults and date-specific overrides - */ -export class WorkHoursManager { - private dateService: DateService; - private config: Configuration; - private positionUtils: PositionUtils; - private workSchedule: IWorkScheduleConfig; - - constructor(dateService: DateService, config: Configuration, positionUtils: PositionUtils) { - this.dateService = dateService; - this.config = config; - this.positionUtils = positionUtils; - - // Default work schedule - will be loaded from JSON later - this.workSchedule = { - weeklyDefault: { - monday: { start: 9, end: 17 }, - tuesday: { start: 9, end: 17 }, - wednesday: { start: 9, end: 17 }, - thursday: { start: 9, end: 17 }, - friday: { start: 9, end: 15 }, - saturday: 'off', - sunday: 'off' - }, - dateOverrides: { - '2025-01-20': { start: 10, end: 16 }, - '2025-01-21': { start: 8, end: 14 }, - '2025-01-22': 'off' - } - }; - } - - /** - * Get work hours for a specific date - */ - getWorkHoursForDate(date: Date): IDayWorkHours | 'off' { - const dateString = this.dateService.formatISODate(date); - - // Check for date-specific override first - if (this.workSchedule.dateOverrides[dateString]) { - return this.workSchedule.dateOverrides[dateString]; - } - - // Fall back to weekly default - const dayName = this.getDayName(date); - return this.workSchedule.weeklyDefault[dayName]; - } - - /** - * Get work hours for multiple dates (used by GridManager) - */ - getWorkHoursForDateRange(dates: Date[]): Map { - const workHoursMap = new Map(); - - dates.forEach(date => { - const dateString = this.dateService.formatISODate(date); - const workHours = this.getWorkHoursForDate(date); - workHoursMap.set(dateString, workHours); - }); - - return workHoursMap; - } - - /** - * Calculate CSS custom properties for non-work hour overlays using PositionUtils - */ - calculateNonWorkHoursStyle(workHours: IDayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null { - if (workHours === 'off') { - return null; // Full day will be colored via CSS background - } - - const gridSettings = this.config.gridSettings; - const dayStartHour = gridSettings.dayStartHour; - const hourHeight = gridSettings.hourHeight; - - // Before work: from day start to work start - const beforeWorkHeight = (workHours.start - dayStartHour) * hourHeight; - - // After work: from work end to day end - const afterWorkTop = (workHours.end - dayStartHour) * hourHeight; - - return { - beforeWorkHeight: Math.max(0, beforeWorkHeight), - afterWorkTop: Math.max(0, afterWorkTop) - }; - } - - /** - * Calculate CSS custom properties for work hours overlay using PositionUtils - */ - calculateWorkHoursStyle(workHours: IDayWorkHours | 'off'): { top: number; height: number } | null { - if (workHours === 'off') { - return null; - } - - // Create dummy time strings for start and end of work hours - const startTime = `${workHours.start.toString().padStart(2, '0')}:00`; - const endTime = `${workHours.end.toString().padStart(2, '0')}:00`; - - // Use PositionUtils for consistent position calculation - const position = this.positionUtils.calculateEventPosition(startTime, endTime); - - return { top: position.top, height: position.height }; - } - - /** - * Load work schedule from JSON (future implementation) - */ - async loadWorkSchedule(jsonData: IWorkScheduleConfig): Promise { - this.workSchedule = jsonData; - } - - /** - * Get current work schedule configuration - */ - getWorkSchedule(): IWorkScheduleConfig { - return this.workSchedule; - } - - /** - * Convert Date to day name key - */ - private getDayName(date: Date): keyof IWorkScheduleConfig['weeklyDefault'] { - const dayNames: (keyof IWorkScheduleConfig['weeklyDefault'])[] = [ - 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday' - ]; - return dayNames[date.getDay()]; - } -} diff --git a/src/renderers/AllDayEventRenderer.ts b/src/renderers/AllDayEventRenderer.ts deleted file mode 100644 index e46acb5..0000000 --- a/src/renderers/AllDayEventRenderer.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { ICalendarEvent } from '../types/CalendarTypes'; -import { SwpAllDayEventElement } from '../elements/SwpEventElement'; -import { IEventLayout } from '../utils/AllDayLayoutEngine'; -import { IColumnBounds } from '../utils/ColumnDetectionUtils'; -import { EventManager } from '../managers/EventManager'; -import { IDragStartEventPayload } from '../types/EventTypes'; -import { IEventRenderer } from './EventRenderer'; - -export class AllDayEventRenderer { - - private container: HTMLElement | null = null; - private originalEvent: HTMLElement | null = null; - private draggedClone: HTMLElement | null = null; - - constructor() { - this.getContainer(); - } - - - private getContainer(): HTMLElement | null { - - const header = document.querySelector('swp-calendar-header'); - if (header) { - this.container = header.querySelector('swp-allday-container'); - - if (!this.container) { - this.container = document.createElement('swp-allday-container'); - header.appendChild(this.container); - } - } - return this.container; - } - - - private getAllDayContainer(): HTMLElement | null { - return document.querySelector('swp-calendar-header swp-allday-container'); - } - /** - * Handle drag start for all-day events - */ - public handleDragStart(payload: IDragStartEventPayload): void { - - this.originalEvent = payload.originalElement;; - this.draggedClone = payload.draggedClone; - - if (this.draggedClone) { - - const container = this.getAllDayContainer(); - if (!container) return; - - this.draggedClone.style.gridColumn = this.originalEvent.style.gridColumn; - this.draggedClone.style.gridRow = this.originalEvent.style.gridRow; - console.log('handleDragStart:this.draggedClone', this.draggedClone); - container.appendChild(this.draggedClone); - - // Add dragging style - this.draggedClone.classList.add('dragging'); - this.draggedClone.style.zIndex = '1000'; - this.draggedClone.style.cursor = 'grabbing'; - - // Make original semi-transparent - this.originalEvent.style.opacity = '0.3'; - this.originalEvent.style.userSelect = 'none'; - } - } - - - - /** - * Render an all-day event with pre-calculated layout - */ - private renderAllDayEventWithLayout( - event: ICalendarEvent, - layout: IEventLayout - ) { - const container = this.getContainer(); - if (!container) return null; - - const dayEvent = SwpAllDayEventElement.fromCalendarEvent(event); - dayEvent.applyGridPositioning(layout.row, layout.startColumn, layout.endColumn); - - // Apply highlight class to show events with highlight color - dayEvent.classList.add('highlight'); - - container.appendChild(dayEvent); - } - - - /** - * Remove an all-day event by ID - */ - public removeAllDayEvent(eventId: string): void { - const container = this.getContainer(); - if (!container) return; - - const eventElement = container.querySelector(`swp-allday-event[data-event-id="${eventId}"]`); - if (eventElement) { - eventElement.remove(); - } - } - - /** - * Clear cache when DOM changes - */ - public clearCache(): void { - this.container = null; - } - - /** - * Render all-day events for specific period using AllDayEventRenderer - */ - public renderAllDayEventsForPeriod(eventLayouts: IEventLayout[]): void { - this.clearAllDayEvents(); - - eventLayouts.forEach(layout => { - this.renderAllDayEventWithLayout(layout.calenderEvent, layout); - }); - - } - - private clearAllDayEvents(): void { - const allDayContainer = document.querySelector('swp-allday-container'); - if (allDayContainer) { - allDayContainer.querySelectorAll('swp-allday-event:not(.max-event-indicator)').forEach(event => event.remove()); - } - } - - public handleViewChanged(event: CustomEvent): void { - this.clearAllDayEvents(); - } -} \ No newline at end of file diff --git a/src/renderers/ColumnRenderer.ts b/src/renderers/ColumnRenderer.ts deleted file mode 100644 index a74c07a..0000000 --- a/src/renderers/ColumnRenderer.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Column rendering strategy interface and implementations - -import { Configuration } from '../configurations/CalendarConfig'; -import { DateService } from '../utils/DateService'; -import { WorkHoursManager } from '../managers/WorkHoursManager'; -import { IColumnInfo } from '../types/ColumnDataSource'; - -/** - * Interface for column rendering strategies - */ -export interface IColumnRenderer { - render(columnContainer: HTMLElement, context: IColumnRenderContext): void; -} - -/** - * Context for column rendering - */ -export interface IColumnRenderContext { - columns: IColumnInfo[]; - config: Configuration; - currentDate?: Date; // Optional: Only used by ResourceColumnRenderer in resource mode -} - -/** - * Date-based column renderer (original functionality) - */ -export class DateColumnRenderer implements IColumnRenderer { - private dateService: DateService; - private workHoursManager: WorkHoursManager; - - constructor( - dateService: DateService, - workHoursManager: WorkHoursManager - ) { - this.dateService = dateService; - this.workHoursManager = workHoursManager; - } - - render(columnContainer: HTMLElement, context: IColumnRenderContext): void { - const { columns } = context; - - columns.forEach((columnInfo) => { - const date = columnInfo.data as Date; - const column = document.createElement('swp-day-column'); - - column.dataset.columnId = columnInfo.identifier; - column.dataset.date = this.dateService.formatISODate(date); - - // Apply work hours styling - this.applyWorkHoursToColumn(column, date); - - const eventsLayer = document.createElement('swp-events-layer'); - column.appendChild(eventsLayer); - - columnContainer.appendChild(column); - }); - } - - private applyWorkHoursToColumn(column: HTMLElement, date: Date): void { - const workHours = this.workHoursManager.getWorkHoursForDate(date); - - if (workHours === 'off') { - // No work hours - mark as off day (full day will be colored) - (column as any).dataset.workHours = 'off'; - } else { - // Calculate and apply non-work hours overlays (before and after work) - const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours); - if (nonWorkStyle) { - // Before work overlay (::before pseudo-element) - column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`); - - // After work overlay (::after pseudo-element) - column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`); - - } - } - } - -} diff --git a/src/renderers/DateHeaderRenderer.ts b/src/renderers/DateHeaderRenderer.ts deleted file mode 100644 index d6584fa..0000000 --- a/src/renderers/DateHeaderRenderer.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Header rendering strategy interface and implementations - -import { Configuration } from '../configurations/CalendarConfig'; -import { DateService } from '../utils/DateService'; -import { IColumnInfo } from '../types/ColumnDataSource'; - -/** - * Interface for header rendering strategies - */ -export interface IHeaderRenderer { - render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void; -} - - -/** - * Context for header rendering - */ -export interface IHeaderRenderContext { - columns: IColumnInfo[]; - config: Configuration; -} - -/** - * Date-based header renderer (original functionality) - */ -export class DateHeaderRenderer implements IHeaderRenderer { - private dateService!: DateService; - - render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void { - const { columns, config } = context; - - // FIRST: Always create all-day container as part of standard header structure - const allDayContainer = document.createElement('swp-allday-container'); - calendarHeader.appendChild(allDayContainer); - - // Initialize date service with timezone and locale from config - const locale = config.timeFormatConfig.locale; - this.dateService = new DateService(config); - - columns.forEach((columnInfo) => { - const date = columnInfo.data as Date; - const header = document.createElement('swp-day-header'); - - if (this.dateService.isSameDay(date, new Date())) { - header.dataset.today = 'true'; - } - - const dayName = this.dateService.getDayName(date, 'long', locale).toUpperCase(); - - header.innerHTML = ` - ${dayName} - ${date.getDate()} - `; - - header.dataset.columnId = columnInfo.identifier; - header.dataset.groupId = columnInfo.groupId; - - calendarHeader.appendChild(header); - }); - } -} \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts deleted file mode 100644 index 0de20cb..0000000 --- a/src/renderers/EventRenderer.ts +++ /dev/null @@ -1,386 +0,0 @@ -// Event rendering strategy interface and implementations - -import { ICalendarEvent } from '../types/CalendarTypes'; -import { IColumnInfo } from '../types/ColumnDataSource'; -import { Configuration } from '../configurations/CalendarConfig'; -import { SwpEventElement } from '../elements/SwpEventElement'; -import { PositionUtils } from '../utils/PositionUtils'; -import { IColumnBounds } from '../utils/ColumnDetectionUtils'; -import { IDragColumnChangeEventPayload, IDragMoveEventPayload, IDragStartEventPayload, IDragMouseEnterColumnEventPayload } from '../types/EventTypes'; -import { DateService } from '../utils/DateService'; -import { EventStackManager } from '../managers/EventStackManager'; -import { EventLayoutCoordinator, IGridGroupLayout, IStackedEventLayout } from '../managers/EventLayoutCoordinator'; -import { EventId } from '../types/EventId'; - -/** - * Interface for event rendering strategies - * - * Note: renderEvents now receives columns with pre-filtered events, - * not a flat array of events. Each column contains its own events. - */ -export interface IEventRenderer { - renderEvents(columns: IColumnInfo[], container: HTMLElement): void; - clearEvents(container?: HTMLElement): void; - renderSingleColumnEvents?(column: IColumnBounds, events: ICalendarEvent[]): void; - handleDragStart?(payload: IDragStartEventPayload): void; - handleDragMove?(payload: IDragMoveEventPayload): void; - handleDragAutoScroll?(eventId: string, snappedY: number): void; - handleDragEnd?(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void; - handleEventClick?(eventId: string, originalElement: HTMLElement): void; - handleColumnChange?(payload: IDragColumnChangeEventPayload): void; - handleNavigationCompleted?(): void; - handleConvertAllDayToTimed?(payload: IDragMouseEnterColumnEventPayload): void; -} - -/** - * Date-based event renderer - */ -export class DateEventRenderer implements IEventRenderer { - - private dateService: DateService; - private stackManager: EventStackManager; - private layoutCoordinator: EventLayoutCoordinator; - private config: Configuration; - private positionUtils: PositionUtils; - private draggedClone: HTMLElement | null = null; - private originalEvent: HTMLElement | null = null; - - constructor( - dateService: DateService, - stackManager: EventStackManager, - layoutCoordinator: EventLayoutCoordinator, - config: Configuration, - positionUtils: PositionUtils - ) { - this.dateService = dateService; - this.stackManager = stackManager; - this.layoutCoordinator = layoutCoordinator; - this.config = config; - this.positionUtils = positionUtils; - } - - private applyDragStyling(element: HTMLElement): void { - element.classList.add('dragging'); - element.style.removeProperty("margin-left"); - } - - - - /** - * Handle drag start event - */ - public handleDragStart(payload: IDragStartEventPayload): void { - - this.originalEvent = payload.originalElement;; - - // Use the clone from the payload instead of creating a new one - this.draggedClone = payload.draggedClone; - - if (this.draggedClone && payload.columnBounds) { - // Apply drag styling - this.applyDragStyling(this.draggedClone); - - // Add to current column's events layer (not directly to column) - const eventsLayer = payload.columnBounds.element.querySelector('swp-events-layer'); - if (eventsLayer) { - eventsLayer.appendChild(this.draggedClone); - - // Set initial position to prevent "jump to top" effect - // Calculate absolute Y position from original element - const originalRect = this.originalEvent.getBoundingClientRect(); - const columnRect = payload.columnBounds.boundingClientRect; - const initialTop = originalRect.top - columnRect.top; - - this.draggedClone.style.top = `${initialTop}px`; - } - } - - // Make original semi-transparent - this.originalEvent.style.opacity = '0.3'; - this.originalEvent.style.userSelect = 'none'; - - } - - /** - * Handle drag move event - * Only updates visual position and time - date stays the same - */ - public handleDragMove(payload: IDragMoveEventPayload): void { - const swpEvent = payload.draggedClone as SwpEventElement; - swpEvent.updatePosition(payload.snappedY); - } - - /** - * Handle column change during drag - * Only moves the element visually - no data updates here - * Data updates happen on drag:end in EventRenderingService - */ - public handleColumnChange(payload: IDragColumnChangeEventPayload): void { - const eventsLayer = payload.newColumn.element.querySelector('swp-events-layer'); - if (eventsLayer && payload.draggedClone.parentElement !== eventsLayer) { - eventsLayer.appendChild(payload.draggedClone); - } - } - - /** - * Handle conversion of all-day event to timed event - */ - public handleConvertAllDayToTimed(payload: IDragMouseEnterColumnEventPayload): void { - - console.log('🎯 DateEventRenderer: Converting all-day to timed event', { - eventId: payload.calendarEvent.id, - targetColumn: payload.targetColumn.identifier, - snappedY: payload.snappedY - }); - - let timedClone = SwpEventElement.fromCalendarEvent(payload.calendarEvent); - let position = this.calculateEventPosition(payload.calendarEvent); - - // Set position at snapped Y - //timedClone.style.top = `${snappedY}px`; - - // Set complete styling for dragged clone (matching normal event rendering) - timedClone.style.height = `${position.height - 3}px`; - timedClone.style.left = '2px'; - timedClone.style.right = '2px'; - timedClone.style.width = 'auto'; - timedClone.style.pointerEvents = 'none'; - - // Apply drag styling - this.applyDragStyling(timedClone); - - // Find the events layer in the target column - let eventsLayer = payload.targetColumn.element.querySelector('swp-events-layer'); - - // Add "clone-" prefix to match clone ID pattern - //timedClone.dataset.eventId = `clone-${payload.calendarEvent.id}`; - - // Remove old all-day clone and replace with new timed clone - payload.draggedClone.remove(); - payload.replaceClone(timedClone); - eventsLayer!!.appendChild(timedClone); - - } - - /** - * Handle drag end event - */ - public handleDragEnd(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void { - - // Only fade out and remove if it's a swp-event (not swp-allday-event) - // AllDayManager handles removal of swp-allday-event elements - if (originalElement.tagName === 'SWP-EVENT') { - this.fadeOutAndRemove(originalElement); - } - - draggedClone.dataset.eventId = EventId.from(draggedClone.dataset.eventId!); - - // Fully normalize the clone to be a regular event - draggedClone.classList.remove('dragging'); - draggedClone.style.pointerEvents = ''; // Re-enable pointer events - - // Clean up instance state - this.draggedClone = null; - this.originalEvent = null; - - - // Clean up any remaining day event clones - const dayEventClone = document.querySelector(`swp-event[data-event-id="${draggedClone.dataset.eventId}"]`); - if (dayEventClone) { - dayEventClone.remove(); - } - } - - /** - * Handle navigation completed event - */ - public handleNavigationCompleted(): void { - // Default implementation - can be overridden by subclasses - } - - /** - * Fade out and remove element - */ - private fadeOutAndRemove(element: HTMLElement): void { - element.style.transition = 'opacity 0.3s ease-out'; - element.style.opacity = '0'; - - setTimeout(() => { - element.remove(); - }, 300); - } - - - renderEvents(columns: IColumnInfo[], container: HTMLElement): void { - // Find column DOM elements in the container - const columnElements = this.getColumns(container); - - // Render events for each column using pre-filtered events from IColumnInfo - columns.forEach((columnInfo, index) => { - const columnElement = columnElements[index]; - if (!columnElement) return; - - // Filter out all-day events - they should be handled by AllDayEventRenderer - const timedEvents = columnInfo.events.filter(event => !event.allDay); - - const eventsLayer = columnElement.querySelector('swp-events-layer') as HTMLElement; - if (eventsLayer && timedEvents.length > 0) { - this.renderColumnEvents(timedEvents, eventsLayer); - } - }); - } - - /** - * Render events for a single column - * Note: events are already filtered for this column - */ - public renderSingleColumnEvents(column: IColumnBounds, events: ICalendarEvent[]): void { - // Filter out all-day events - const timedEvents = events.filter(event => !event.allDay); - const eventsLayer = column.element.querySelector('swp-events-layer') as HTMLElement; - - if (eventsLayer && timedEvents.length > 0) { - this.renderColumnEvents(timedEvents, eventsLayer); - } - } - - /** - * Render events in a column using combined stacking + grid algorithm - */ - private renderColumnEvents(columnEvents: ICalendarEvent[], eventsLayer: HTMLElement): void { - if (columnEvents.length === 0) return; - - // Get layout from coordinator - const layout = this.layoutCoordinator.calculateColumnLayout(columnEvents); - - // Render grid groups - layout.gridGroups.forEach(gridGroup => { - this.renderGridGroup(gridGroup, eventsLayer); - }); - - // Render stacked events - layout.stackedEvents.forEach(stackedEvent => { - const element = this.renderEvent(stackedEvent.event); - this.stackManager.applyStackLinkToElement(element, stackedEvent.stackLink); - this.stackManager.applyVisualStyling(element, stackedEvent.stackLink.stackLevel); - eventsLayer.appendChild(element); - }); - } - /** - * Render events in a grid container (side-by-side with column sharing) - */ - private renderGridGroup(gridGroup: IGridGroupLayout, eventsLayer: HTMLElement): void { - const groupElement = document.createElement('swp-event-group'); - - // Add grid column class based on number of columns (not events) - const colCount = gridGroup.columns.length; - groupElement.classList.add(`cols-${colCount}`); - - // Add stack level class for margin-left offset - groupElement.classList.add(`stack-level-${gridGroup.stackLevel}`); - - // Position from layout - groupElement.style.top = `${gridGroup.position.top}px`; - - // Add stack-link attribute for drag-drop (group acts as a stacked item) - const stackLink = { - stackLevel: gridGroup.stackLevel - }; - this.stackManager.applyStackLinkToElement(groupElement, stackLink); - - // Apply visual styling (margin-left and z-index) using StackManager - this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); - - // Render each column - const earliestEvent = gridGroup.events[0]; - gridGroup.columns.forEach((columnEvents: ICalendarEvent[]) => { - const columnContainer = this.renderGridColumn(columnEvents, earliestEvent.start); - groupElement.appendChild(columnContainer); - }); - - eventsLayer.appendChild(groupElement); - } - - /** - * Render a single column within a grid group - * Column may contain multiple events that don't overlap - */ - private renderGridColumn(columnEvents: ICalendarEvent[], containerStart: Date): HTMLElement { - const columnContainer = document.createElement('div'); - columnContainer.style.position = 'relative'; - - columnEvents.forEach(event => { - const element = this.renderEventInGrid(event, containerStart); - columnContainer.appendChild(element); - }); - - return columnContainer; - } - - /** - * Render event within a grid container (absolute positioning within column) - */ - private renderEventInGrid(event: ICalendarEvent, containerStart: Date): HTMLElement { - const element = SwpEventElement.fromCalendarEvent(event); - - // Calculate event height - const position = this.calculateEventPosition(event); - - // Calculate relative top offset if event starts after container start - // (e.g., if container starts at 07:00 and event starts at 08:15, offset = 75 min) - const timeDiffMs = event.start.getTime() - containerStart.getTime(); - const timeDiffMinutes = timeDiffMs / (1000 * 60); - const gridSettings = this.config.gridSettings; - const relativeTop = timeDiffMinutes > 0 ? (timeDiffMinutes / 60) * gridSettings.hourHeight : 0; - - // Events in grid columns are positioned absolutely within their column container - element.style.position = 'absolute'; - element.style.top = `${relativeTop}px`; - element.style.height = `${position.height - 3}px`; - element.style.left = '0'; - element.style.right = '0'; - - return element; - } - - - private renderEvent(event: ICalendarEvent): HTMLElement { - const element = SwpEventElement.fromCalendarEvent(event); - - // Apply positioning (moved from SwpEventElement.applyPositioning) - const position = this.calculateEventPosition(event); - element.style.position = 'absolute'; - element.style.top = `${position.top + 1}px`; - element.style.height = `${position.height - 3}px`; - element.style.left = '2px'; - element.style.right = '2px'; - - return element; - } - - protected calculateEventPosition(event: ICalendarEvent): { top: number; height: number } { - // Delegate to PositionUtils for centralized position calculation - return this.positionUtils.calculateEventPosition(event.start, event.end); - } - - clearEvents(container?: HTMLElement): void { - const eventSelector = 'swp-event'; - const groupSelector = 'swp-event-group'; - - const existingEvents = container - ? container.querySelectorAll(eventSelector) - : document.querySelectorAll(eventSelector); - - const existingGroups = container - ? container.querySelectorAll(groupSelector) - : document.querySelectorAll(groupSelector); - - existingEvents.forEach(event => event.remove()); - existingGroups.forEach(group => group.remove()); - } - - protected getColumns(container: HTMLElement): HTMLElement[] { - const columns = container.querySelectorAll('swp-day-column'); - return Array.from(columns) as HTMLElement[]; - } -} diff --git a/src/renderers/EventRendererManager.ts b/src/renderers/EventRendererManager.ts deleted file mode 100644 index 17862c0..0000000 --- a/src/renderers/EventRendererManager.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { IEventBus } from '../types/CalendarTypes'; -import { IColumnInfo, IColumnDataSource } from '../types/ColumnDataSource'; -import { CoreEvents } from '../constants/CoreEvents'; -import { EventManager } from '../managers/EventManager'; -import { IEventRenderer } from './EventRenderer'; -import { SwpEventElement } from '../elements/SwpEventElement'; -import { IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragColumnChangeEventPayload, IResizeEndEventPayload } from '../types/EventTypes'; -import { DateService } from '../utils/DateService'; - -/** - * EventRenderingService - Render events i DOM med positionering using Strategy Pattern - * Håndterer event positioning og overlap detection - */ -export class EventRenderingService { - private eventBus: IEventBus; - private eventManager: EventManager; - private strategy: IEventRenderer; - private dataSource: IColumnDataSource; - private dateService: DateService; - - private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null; - - constructor( - eventBus: IEventBus, - eventManager: EventManager, - strategy: IEventRenderer, - dataSource: IColumnDataSource, - dateService: DateService - ) { - this.eventBus = eventBus; - this.eventManager = eventManager; - this.strategy = strategy; - this.dataSource = dataSource; - this.dateService = dateService; - - this.setupEventListeners(); - } - - private setupEventListeners(): void { - - this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => { - this.handleGridRendered(event as CustomEvent); - }); - - this.eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => { - this.handleViewChanged(event as CustomEvent); - }); - - - // Handle all drag events and delegate to appropriate renderer - this.setupDragEventListeners(); - - } - - - /** - * Handle GRID_RENDERED event - render events in the current grid - * Events are now pre-filtered per column by IColumnDataSource - */ - private handleGridRendered(event: CustomEvent): void { - const { container, columns } = event.detail; - - if (!container || !columns || columns.length === 0) { - return; - } - - // Render events directly from columns (pre-filtered by IColumnDataSource) - this.renderEventsFromColumns(container, columns); - } - - /** - * Render events from pre-filtered columns - * Each column already contains its events (filtered by IColumnDataSource) - */ - private renderEventsFromColumns(container: HTMLElement, columns: IColumnInfo[]): void { - this.strategy.clearEvents(container); - this.strategy.renderEvents(columns, container); - - // Emit EVENTS_RENDERED for filtering system - const allEvents = columns.flatMap(col => col.events); - this.eventBus.emit(CoreEvents.EVENTS_RENDERED, { - events: allEvents, - container: container - }); - } - - - /** - * Handle VIEW_CHANGED event - clear and re-render for new view - */ - private handleViewChanged(event: CustomEvent): void { - // Clear all existing events since view structure may have changed - this.clearEvents(); - - // New rendering will be triggered by subsequent GRID_RENDERED event - } - - - /** - * Setup all drag event listeners - moved from EventRenderer for better separation of concerns - */ - private setupDragEventListeners(): void { - this.setupDragStartListener(); - this.setupDragMoveListener(); - this.setupDragEndListener(); - this.setupDragColumnChangeListener(); - this.setupDragMouseLeaveHeaderListener(); - this.setupDragMouseEnterColumnListener(); - this.setupResizeEndListener(); - this.setupNavigationCompletedListener(); - } - - private setupDragStartListener(): void { - this.eventBus.on('drag:start', (event: Event) => { - const dragStartPayload = (event as CustomEvent).detail; - - if (dragStartPayload.originalElement.hasAttribute('data-allday')) { - return; - } - - if (dragStartPayload.originalElement && this.strategy.handleDragStart && dragStartPayload.columnBounds) { - this.strategy.handleDragStart(dragStartPayload); - } - }); - } - - private setupDragMoveListener(): void { - this.eventBus.on('drag:move', (event: Event) => { - let dragEvent = (event as CustomEvent).detail; - - if (dragEvent.draggedClone.hasAttribute('data-allday')) { - return; - } - if (this.strategy.handleDragMove) { - this.strategy.handleDragMove(dragEvent); - } - }); - } - - private setupDragEndListener(): void { - this.eventBus.on('drag:end', async (event: Event) => { - const { originalElement, draggedClone, finalPosition, target } = (event as CustomEvent).detail; - const finalColumn = finalPosition.column; - const finalY = finalPosition.snappedY; - - // Only handle day column drops - if (target === 'swp-day-column' && finalColumn) { - const element = draggedClone as SwpEventElement; - - if (originalElement && draggedClone && this.strategy.handleDragEnd) { - this.strategy.handleDragEnd(originalElement, draggedClone, finalColumn, finalY); - } - - // Build update payload based on mode - const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = { - start: element.start, - end: element.end, - allDay: false - }; - - if (this.dataSource.isResource()) { - // Resource mode: update resourceId, keep existing date - updatePayload.resourceId = finalColumn.identifier; - } else { - // Date mode: update date from column, keep existing time - const newDate = this.dateService.parseISO(finalColumn.identifier); - const startTimeMinutes = this.dateService.getMinutesSinceMidnight(element.start); - const endTimeMinutes = this.dateService.getMinutesSinceMidnight(element.end); - updatePayload.start = this.dateService.createDateAtTime(newDate, startTimeMinutes); - updatePayload.end = this.dateService.createDateAtTime(newDate, endTimeMinutes); - } - - await this.eventManager.updateEvent(element.eventId, updatePayload); - - // Trigger full refresh to re-render with updated data - this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {}); - } - }); - } - - private setupDragColumnChangeListener(): void { - this.eventBus.on('drag:column-change', (event: Event) => { - let columnChangeEvent = (event as CustomEvent).detail; - - // Filter: Only handle events where clone is NOT an all-day event (normal timed events) - if (columnChangeEvent.draggedClone && columnChangeEvent.draggedClone.hasAttribute('data-allday')) { - return; - } - - if (this.strategy.handleColumnChange) { - this.strategy.handleColumnChange(columnChangeEvent); - } - }); - } - - private setupDragMouseLeaveHeaderListener(): void { - - this.dragMouseLeaveHeaderListener = (event: Event) => { - const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent).detail; - - if (cloneElement) - cloneElement.style.display = ''; - - console.log('🚪 EventRendererManager: Received drag:mouseleave-header', { - targetColumn: targetColumn?.identifier, - originalElement: originalElement, - cloneElement: cloneElement - }); - - }; - - this.eventBus.on('drag:mouseleave-header', this.dragMouseLeaveHeaderListener); - } - - private setupDragMouseEnterColumnListener(): void { - this.eventBus.on('drag:mouseenter-column', (event: Event) => { - const payload = (event as CustomEvent).detail; - - // Only handle if clone is an all-day event - if (!payload.draggedClone.hasAttribute('data-allday')) { - return; - } - - console.log('🎯 EventRendererManager: Received drag:mouseenter-column', { - targetColumn: payload.targetColumn, - snappedY: payload.snappedY, - calendarEvent: payload.calendarEvent - }); - - // Delegate to strategy for conversion - if (this.strategy.handleConvertAllDayToTimed) { - this.strategy.handleConvertAllDayToTimed(payload); - } - }); - } - - private setupResizeEndListener(): void { - this.eventBus.on('resize:end', async (event: Event) => { - const { eventId, element } = (event as CustomEvent).detail; - - const swpEvent = element as SwpEventElement; - await this.eventManager.updateEvent(eventId, { - start: swpEvent.start, - end: swpEvent.end - }); - - // Trigger full refresh to re-render with updated data - this.eventBus.emit(CoreEvents.REFRESH_REQUESTED, {}); - }); - } - - private setupNavigationCompletedListener(): void { - this.eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => { - // Delegate to strategy if it handles navigation - if (this.strategy.handleNavigationCompleted) { - this.strategy.handleNavigationCompleted(); - } - }); - } - - - private clearEvents(container?: HTMLElement): void { - this.strategy.clearEvents(container); - } - - public refresh(container?: HTMLElement): void { - this.clearEvents(container); - } -} \ No newline at end of file diff --git a/src/renderers/GridRenderer.ts b/src/renderers/GridRenderer.ts deleted file mode 100644 index 19f267b..0000000 --- a/src/renderers/GridRenderer.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Configuration } from '../configurations/CalendarConfig'; -import { CalendarView } from '../types/CalendarTypes'; -import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer'; -import { eventBus } from '../core/EventBus'; -import { DateService } from '../utils/DateService'; -import { CoreEvents } from '../constants/CoreEvents'; -import { TimeFormatter } from '../utils/TimeFormatter'; -import { IColumnInfo } from '../types/ColumnDataSource'; - -/** - * GridRenderer - Centralized DOM rendering for calendar grid structure - * - * ARCHITECTURE OVERVIEW: - * ===================== - * GridRenderer is responsible for creating and managing the complete DOM structure - * of the calendar grid. It follows the Strategy Pattern by delegating specific - * rendering tasks to specialized renderers (DateHeaderRenderer, ColumnRenderer). - * - * RESPONSIBILITY HIERARCHY: - * ======================== - * GridRenderer (this file) - * ├─ Creates overall grid skeleton - * ├─ Manages time axis (hour markers) - * └─ Delegates to specialized renderers: - * ├─ DateHeaderRenderer → Renders date headers - * └─ ColumnRenderer → Renders day columns - * - * DOM STRUCTURE CREATED: - * ===================== - * - * ← GridRenderer - * ← GridRenderer - * 00:00 ← GridRenderer (iterates hours) - * - * ← GridRenderer - * ← GridRenderer creates container - * ← DateHeaderRenderer (iterates dates) - * - * ← GridRenderer - * ← GridRenderer - * ← GridRenderer - * ← GridRenderer creates container - * ← ColumnRenderer (iterates dates) - * - * - * - * - * - * - * RENDERING FLOW: - * ============== - * 1. renderGrid() - Entry point called by GridManager - * ├─ First render: createCompleteGridStructure() - * └─ Updates: updateGridContent() - * - * 2. createCompleteGridStructure() - * ├─ Creates header spacer - * ├─ Creates time axis (calls createOptimizedTimeAxis) - * └─ Creates grid container (calls createOptimizedGridContainer) - * - * 3. createOptimizedGridContainer() - * ├─ Creates calendar header container - * ├─ Creates scrollable content structure - * └─ Creates column container (calls renderColumnContainer) - * - * 4. renderColumnContainer() - * └─ Delegates to ColumnRenderer.render() - * └─ ColumnRenderer iterates dates and creates columns - * - * OPTIMIZATION STRATEGY: - * ===================== - * - Caches DOM references (cachedGridContainer, cachedTimeAxis) - * - Uses DocumentFragment for batch DOM insertions - * - Only updates changed content on re-renders - * - Delegates specialized tasks to strategy renderers - * - * USAGE EXAMPLE: - * ============= - * const gridRenderer = new GridRenderer(columnRenderer, dateService, config); - * gridRenderer.renderGrid(containerElement, new Date(), 'week'); - */ -export class GridRenderer { - private cachedGridContainer: HTMLElement | null = null; - private cachedTimeAxis: HTMLElement | null = null; - private dateService: DateService; - private columnRenderer: IColumnRenderer; - private config: Configuration; - - constructor( - columnRenderer: IColumnRenderer, - dateService: DateService, - config: Configuration - ) { - this.dateService = dateService; - this.columnRenderer = columnRenderer; - this.config = config; - } - - /** - * Main entry point for rendering the complete calendar grid - * - * This method decides between full render (first time) or optimized update. - * It caches the grid reference for performance. - * - * @param grid - Container element where grid will be rendered - * @param currentDate - Base date for the current view (e.g., any date in the week) - * @param view - Calendar view type (day/week/month) - * @param columns - Array of columns to render (each column contains its events) - */ - public renderGrid( - grid: HTMLElement, - currentDate: Date, - view: CalendarView = 'week', - columns: IColumnInfo[] = [] - ): void { - - if (!grid || !currentDate) { - return; - } - - // Cache grid reference for performance - this.cachedGridContainer = grid; - - // Only clear and rebuild if grid is empty (first render) - if (grid.children.length === 0) { - this.createCompleteGridStructure(grid, currentDate, view, columns); - } else { - // Optimized update - only refresh dynamic content - this.updateGridContent(grid, currentDate, view, columns); - } - } - - /** - * Creates the complete grid structure from scratch - * - * Uses DocumentFragment for optimal performance by minimizing reflows. - * Creates all child elements in memory first, then appends everything at once. - * - * Structure created: - * 1. Header spacer (placeholder for alignment) - * 2. Time axis (hour markers 00:00-23:00) - * 3. Grid container (header + scrollable content) - * - * @param grid - Parent container - * @param currentDate - Current view date - * @param view - View type - * @param columns - Array of columns to render (each column contains its events) - */ - private createCompleteGridStructure( - grid: HTMLElement, - currentDate: Date, - view: CalendarView, - columns: IColumnInfo[] - ): void { - // Create all elements in memory first for better performance - const fragment = document.createDocumentFragment(); - - // Create header spacer - const headerSpacer = document.createElement('swp-header-spacer'); - fragment.appendChild(headerSpacer); - - // Create time axis with caching - const timeAxis = this.createOptimizedTimeAxis(); - this.cachedTimeAxis = timeAxis; - fragment.appendChild(timeAxis); - - // Create grid container with caching - const gridContainer = this.createOptimizedGridContainer(columns, currentDate); - this.cachedGridContainer = gridContainer; - fragment.appendChild(gridContainer); - - // Append all at once to minimize reflows - grid.appendChild(fragment); - } - - /** - * Creates the time axis with hour markers - * - * Iterates from dayStartHour to dayEndHour (configured in GridSettings). - * Each marker shows the hour in the configured time format. - * - * @returns Time axis element with all hour markers - */ - private createOptimizedTimeAxis(): HTMLElement { - const timeAxis = document.createElement('swp-time-axis'); - const timeAxisContent = document.createElement('swp-time-axis-content'); - const gridSettings = this.config.gridSettings; - const startHour = gridSettings.dayStartHour; - const endHour = gridSettings.dayEndHour; - - const fragment = document.createDocumentFragment(); - for (let hour = startHour; hour < endHour; hour++) { - const marker = document.createElement('swp-hour-marker'); - const date = new Date(2024, 0, 1, hour, 0); - marker.textContent = TimeFormatter.formatTime(date); - fragment.appendChild(marker); - } - - timeAxisContent.appendChild(fragment); - timeAxisContent.style.top = '-1px'; - timeAxis.appendChild(timeAxisContent); - return timeAxis; - } - - /** - * Creates the main grid container with header and columns - * - * This is the scrollable area containing: - * - Calendar header (dates/resources) - created here, populated by DateHeaderRenderer - * - Time grid (grid lines + day columns) - structure created here - * - Column container - created here, populated by ColumnRenderer - * - * @param columns - Array of columns to render (each column contains its events) - * @param currentDate - Current view date - * @returns Complete grid container element - */ - private createOptimizedGridContainer( - columns: IColumnInfo[], - currentDate: Date - ): HTMLElement { - const gridContainer = document.createElement('swp-grid-container'); - - // Create calendar header as first child - always exists now! - const calendarHeader = document.createElement('swp-calendar-header'); - gridContainer.appendChild(calendarHeader); - - // Create scrollable content structure - const scrollableContent = document.createElement('swp-scrollable-content'); - const timeGrid = document.createElement('swp-time-grid'); - - // Add grid lines - const gridLines = document.createElement('swp-grid-lines'); - timeGrid.appendChild(gridLines); - - // Create column container - const columnContainer = document.createElement('swp-day-columns'); - this.renderColumnContainer(columnContainer, columns, currentDate); - timeGrid.appendChild(columnContainer); - - scrollableContent.appendChild(timeGrid); - gridContainer.appendChild(scrollableContent); - - return gridContainer; - } - - - /** - * Renders columns by delegating to ColumnRenderer - * - * GridRenderer delegates column creation to ColumnRenderer. - * Event rendering is handled by EventRenderingService listening to GRID_RENDERED. - * - * @param columnContainer - Empty container to populate - * @param columns - Array of columns to render (each column contains its events) - * @param currentDate - Current view date - */ - private renderColumnContainer( - columnContainer: HTMLElement, - columns: IColumnInfo[], - currentDate: Date - ): void { - // Delegate to ColumnRenderer - this.columnRenderer.render(columnContainer, { - columns: columns, - config: this.config, - currentDate: currentDate - }); - } - - /** - * Optimized update of grid content without full rebuild - * - * Only updates the column container content, leaving the structure intact. - * This is much faster than recreating the entire grid. - * - * @param grid - Existing grid element - * @param currentDate - New view date - * @param view - View type - * @param columns - Array of columns to render (each column contains its events) - */ - private updateGridContent( - grid: HTMLElement, - currentDate: Date, - view: CalendarView, - columns: IColumnInfo[] - ): void { - // Update column container if needed - const columnContainer = grid.querySelector('swp-day-columns'); - if (columnContainer) { - columnContainer.innerHTML = ''; - this.renderColumnContainer(columnContainer as HTMLElement, columns, currentDate); - } - } - /** - * Creates a new grid for slide animations during navigation - * - * Used by NavigationManager for smooth week-to-week transitions. - * Creates a complete grid positioned absolutely for animation. - * - * Note: Positioning is handled by Animation API, not here. - * Events will be rendered by EventRenderingService when GRID_RENDERED emits. - * - * @param parentContainer - Container for the new grid - * @param columns - Array of columns to render - * @param currentDate - Current view date - * @returns New grid element ready for animation - */ - public createNavigationGrid(parentContainer: HTMLElement, columns: IColumnInfo[], currentDate: Date): HTMLElement { - // Create grid structure (events are in columns, rendered by EventRenderingService) - const newGrid = this.createOptimizedGridContainer(columns, currentDate); - - // Position new grid for animation - NO transform here, let Animation API handle it - newGrid.style.position = 'absolute'; - newGrid.style.top = '0'; - newGrid.style.left = '0'; - newGrid.style.width = '100%'; - newGrid.style.height = '100%'; - - // Add to parent container - parentContainer.appendChild(newGrid); - - return newGrid; - } -} \ No newline at end of file diff --git a/src/renderers/ResourceColumnRenderer.ts b/src/renderers/ResourceColumnRenderer.ts deleted file mode 100644 index 627546d..0000000 --- a/src/renderers/ResourceColumnRenderer.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { WorkHoursManager } from '../managers/WorkHoursManager'; -import { IColumnRenderer, IColumnRenderContext } from './ColumnRenderer'; -import { DateService } from '../utils/DateService'; - -/** - * Resource-based column renderer - * - * In resource mode, columns represent resources (people, rooms, etc.) - * Work hours are hardcoded (09:00-18:00) for all columns. - * TODO: Each resource should have its own work hours. - */ -export class ResourceColumnRenderer implements IColumnRenderer { - private workHoursManager: WorkHoursManager; - private dateService: DateService; - - constructor(workHoursManager: WorkHoursManager, dateService: DateService) { - this.workHoursManager = workHoursManager; - this.dateService = dateService; - } - - render(columnContainer: HTMLElement, context: IColumnRenderContext): void { - const { columns, currentDate } = context; - - if (!currentDate) { - throw new Error('ResourceColumnRenderer requires currentDate in context'); - } - - // Hardcoded work hours for all resources: 09:00 - 18:00 - const workHours = { start: 9, end: 18 }; - - columns.forEach((columnInfo) => { - const column = document.createElement('swp-day-column'); - - column.dataset.columnId = columnInfo.identifier; - column.dataset.date = this.dateService.formatISODate(currentDate); - - // Apply hardcoded work hours to all resource columns - this.applyWorkHoursToColumn(column, workHours); - - const eventsLayer = document.createElement('swp-events-layer'); - column.appendChild(eventsLayer); - - columnContainer.appendChild(column); - }); - } - - private applyWorkHoursToColumn(column: HTMLElement, workHours: { start: number; end: number }): void { - const nonWorkStyle = this.workHoursManager.calculateNonWorkHoursStyle(workHours); - if (nonWorkStyle) { - column.style.setProperty('--before-work-height', `${nonWorkStyle.beforeWorkHeight}px`); - column.style.setProperty('--after-work-top', `${nonWorkStyle.afterWorkTop}px`); - } - } -} diff --git a/src/renderers/ResourceHeaderRenderer.ts b/src/renderers/ResourceHeaderRenderer.ts deleted file mode 100644 index dd8bd29..0000000 --- a/src/renderers/ResourceHeaderRenderer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { IHeaderRenderer, IHeaderRenderContext } from './DateHeaderRenderer'; -import { IResource } from '../types/ResourceTypes'; - -/** - * ResourceHeaderRenderer - Renders resource-based headers - * - * Displays resource information (avatar, name) instead of dates. - * Used in resource mode where columns represent people/rooms/equipment. - */ -export class ResourceHeaderRenderer implements IHeaderRenderer { - render(calendarHeader: HTMLElement, context: IHeaderRenderContext): void { - const { columns } = context; - - // Create all-day container (same structure as date mode) - const allDayContainer = document.createElement('swp-allday-container'); - calendarHeader.appendChild(allDayContainer); - - columns.forEach((columnInfo) => { - const resource = columnInfo.data as IResource; - const header = document.createElement('swp-day-header'); - - // Build header content - let avatarHtml = ''; - if (resource.avatarUrl) { - avatarHtml = `${resource.displayName}`; - } else { - // Fallback: initials - const initials = this.getInitials(resource.displayName); - const bgColor = resource.color || '#6366f1'; - avatarHtml = `${initials}`; - } - - header.innerHTML = ` -
    - ${avatarHtml} - ${resource.displayName} -
    - `; - - header.dataset.columnId = columnInfo.identifier; - header.dataset.resourceId = resource.id; - header.dataset.groupId = columnInfo.groupId; - - calendarHeader.appendChild(header); - }); - } - - /** - * Get initials from display name - */ - private getInitials(name: string): string { - return name - .split(' ') - .map(part => part.charAt(0)) - .join('') - .toUpperCase() - .substring(0, 2); - } -} diff --git a/src/renderers/WeekInfoRenderer.ts b/src/renderers/WeekInfoRenderer.ts deleted file mode 100644 index bcf516a..0000000 --- a/src/renderers/WeekInfoRenderer.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IEventBus } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { EventRenderingService } from './EventRendererManager'; -import { DateService } from '../utils/DateService'; - -/** - * WeekInfoRenderer - Handles DOM rendering for week info display - * Updates swp-week-number and swp-date-range elements - * - * Renamed from NavigationRenderer to better reflect its actual responsibility - */ - -export class WeekInfoRenderer { - private eventBus: IEventBus; - private dateService: DateService; - - constructor( - eventBus: IEventBus, - eventRenderer: EventRenderingService, - dateService: DateService - ) { - this.eventBus = eventBus; - this.dateService = dateService; - this.setupEventListeners(); - } - - - - /** - * Setup event listeners for DOM updates - */ - private setupEventListeners(): void { - this.eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event: Event) => { - const customEvent = event as CustomEvent; - const { newDate } = customEvent.detail; - - // Calculate week number and date range from the new date - const weekNumber = this.dateService.getWeekNumber(newDate); - const weekEnd = this.dateService.addDays(newDate, 6); - const dateRange = this.dateService.formatDateRange(newDate, weekEnd); - - this.updateWeekInfoInDOM(weekNumber, dateRange); - }); - } - - - private updateWeekInfoInDOM(weekNumber: number, dateRange: string): void { - - const weekNumberElement = document.querySelector('swp-week-number'); - const dateRangeElement = document.querySelector('swp-date-range'); - - if (weekNumberElement) { - weekNumberElement.textContent = `Week ${weekNumber}`; - } - - if (dateRangeElement) { - dateRangeElement.textContent = dateRange; - } - } - - /** - * Apply filter state to pre-rendered grids - */ - public applyFilterToPreRenderedGrids(filterState: { active: boolean; matchingIds: string[] }): void { - // Find all grid containers (including pre-rendered ones) - const allGridContainers = document.querySelectorAll('swp-grid-container'); - - allGridContainers.forEach(container => { - const eventsLayers = container.querySelectorAll('swp-events-layer'); - - eventsLayers.forEach(layer => { - if (filterState.active) { - // Apply filter active state - layer.setAttribute('data-filter-active', 'true'); - - // Mark matching events in this layer - const events = layer.querySelectorAll('swp-event'); - events.forEach(event => { - const eventId = event.getAttribute('data-event-id'); - if (eventId && filterState.matchingIds.includes(eventId)) { - event.setAttribute('data-matches', 'true'); - } else { - event.removeAttribute('data-matches'); - } - }); - } else { - // Remove filter state - layer.removeAttribute('data-filter-active'); - - // Remove all match attributes - const events = layer.querySelectorAll('swp-event'); - events.forEach(event => { - event.removeAttribute('data-matches'); - }); - } - }); - }); - } - -} diff --git a/src/repositories/ApiBookingRepository.ts b/src/repositories/ApiBookingRepository.ts deleted file mode 100644 index 4e63ac7..0000000 --- a/src/repositories/ApiBookingRepository.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { IBooking } from '../types/BookingTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { Configuration } from '../configurations/CalendarConfig'; -import { IApiRepository } from './IApiRepository'; - -/** - * ApiBookingRepository - * Handles communication with backend API for bookings - * - * Implements IApiRepository for generic sync infrastructure. - * Used by SyncManager to send queued booking operations to the server. - */ -export class ApiBookingRepository implements IApiRepository { - readonly entityType: EntityType = 'Booking'; - private apiEndpoint: string; - - constructor(config: Configuration) { - this.apiEndpoint = config.apiEndpoint; - } - - /** - * Send create operation to API - */ - async sendCreate(booking: IBooking): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/bookings`, { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(booking) - // }); - // - // if (!response.ok) { - // throw new Error(`API create failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiBookingRepository.sendCreate not implemented yet'); - } - - /** - * Send update operation to API - */ - async sendUpdate(id: string, updates: Partial): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, { - // method: 'PATCH', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(updates) - // }); - // - // if (!response.ok) { - // throw new Error(`API update failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiBookingRepository.sendUpdate not implemented yet'); - } - - /** - * Send delete operation to API - */ - async sendDelete(id: string): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/bookings/${id}`, { - // method: 'DELETE' - // }); - // - // if (!response.ok) { - // throw new Error(`API delete failed: ${response.statusText}`); - // } - - throw new Error('ApiBookingRepository.sendDelete not implemented yet'); - } - - /** - * Fetch all bookings from API - */ - async fetchAll(): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/bookings`); - // - // if (!response.ok) { - // throw new Error(`API fetch failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiBookingRepository.fetchAll not implemented yet'); - } -} diff --git a/src/repositories/ApiCustomerRepository.ts b/src/repositories/ApiCustomerRepository.ts deleted file mode 100644 index ab067f4..0000000 --- a/src/repositories/ApiCustomerRepository.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ICustomer } from '../types/CustomerTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { Configuration } from '../configurations/CalendarConfig'; -import { IApiRepository } from './IApiRepository'; - -/** - * ApiCustomerRepository - * Handles communication with backend API for customers - * - * Implements IApiRepository for generic sync infrastructure. - * Used by SyncManager to send queued customer operations to the server. - */ -export class ApiCustomerRepository implements IApiRepository { - readonly entityType: EntityType = 'Customer'; - private apiEndpoint: string; - - constructor(config: Configuration) { - this.apiEndpoint = config.apiEndpoint; - } - - /** - * Send create operation to API - */ - async sendCreate(customer: ICustomer): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/customers`, { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(customer) - // }); - // - // if (!response.ok) { - // throw new Error(`API create failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiCustomerRepository.sendCreate not implemented yet'); - } - - /** - * Send update operation to API - */ - async sendUpdate(id: string, updates: Partial): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/customers/${id}`, { - // method: 'PATCH', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(updates) - // }); - // - // if (!response.ok) { - // throw new Error(`API update failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiCustomerRepository.sendUpdate not implemented yet'); - } - - /** - * Send delete operation to API - */ - async sendDelete(id: string): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/customers/${id}`, { - // method: 'DELETE' - // }); - // - // if (!response.ok) { - // throw new Error(`API delete failed: ${response.statusText}`); - // } - - throw new Error('ApiCustomerRepository.sendDelete not implemented yet'); - } - - /** - * Fetch all customers from API - */ - async fetchAll(): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/customers`); - // - // if (!response.ok) { - // throw new Error(`API fetch failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiCustomerRepository.fetchAll not implemented yet'); - } -} diff --git a/src/repositories/ApiEventRepository.ts b/src/repositories/ApiEventRepository.ts deleted file mode 100644 index 8a04d94..0000000 --- a/src/repositories/ApiEventRepository.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { ICalendarEvent, EntityType } from '../types/CalendarTypes'; -import { Configuration } from '../configurations/CalendarConfig'; -import { IApiRepository } from './IApiRepository'; - -/** - * ApiEventRepository - * Handles communication with backend API for calendar events - * - * Implements IApiRepository for generic sync infrastructure. - * Used by SyncManager to send queued operations to the server. - * NOT used directly by EventManager (which uses IndexedDBEventRepository). - * - * Future enhancements: - * - SignalR real-time updates - * - Conflict resolution - * - Batch operations - */ -export class ApiEventRepository implements IApiRepository { - readonly entityType: EntityType = 'Event'; - private apiEndpoint: string; - - constructor(config: Configuration) { - this.apiEndpoint = config.apiEndpoint; - } - - /** - * Send create operation to API - */ - async sendCreate(event: ICalendarEvent): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/events`, { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(event) - // }); - // - // if (!response.ok) { - // throw new Error(`API create failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiEventRepository.sendCreate not implemented yet'); - } - - /** - * Send update operation to API - */ - async sendUpdate(id: string, updates: Partial): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/events/${id}`, { - // method: 'PATCH', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(updates) - // }); - // - // if (!response.ok) { - // throw new Error(`API update failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiEventRepository.sendUpdate not implemented yet'); - } - - /** - * Send delete operation to API - */ - async sendDelete(id: string): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/events/${id}`, { - // method: 'DELETE' - // }); - // - // if (!response.ok) { - // throw new Error(`API delete failed: ${response.statusText}`); - // } - - throw new Error('ApiEventRepository.sendDelete not implemented yet'); - } - - /** - * Fetch all events from API - */ - async fetchAll(): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/events`); - // - // if (!response.ok) { - // throw new Error(`API fetch failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiEventRepository.fetchAll not implemented yet'); - } - - // ======================================== - // Future: SignalR Integration - // ======================================== - - /** - * Initialize SignalR connection - * Placeholder for future implementation - */ - async initializeSignalR(): Promise { - // TODO: Setup SignalR connection - // - Connect to hub - // - Register event handlers - // - Handle reconnection - // - // Example: - // const connection = new signalR.HubConnectionBuilder() - // .withUrl(`${this.apiEndpoint}/hubs/calendar`) - // .build(); - // - // connection.on('EventCreated', (event: ICalendarEvent) => { - // // Handle remote create - // }); - // - // connection.on('EventUpdated', (event: ICalendarEvent) => { - // // Handle remote update - // }); - // - // connection.on('EventDeleted', (eventId: string) => { - // // Handle remote delete - // }); - // - // await connection.start(); - - throw new Error('SignalR not implemented yet'); - } -} diff --git a/src/repositories/ApiResourceRepository.ts b/src/repositories/ApiResourceRepository.ts deleted file mode 100644 index 6f419b3..0000000 --- a/src/repositories/ApiResourceRepository.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { IResource } from '../types/ResourceTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { Configuration } from '../configurations/CalendarConfig'; -import { IApiRepository } from './IApiRepository'; - -/** - * ApiResourceRepository - * Handles communication with backend API for resources - * - * Implements IApiRepository for generic sync infrastructure. - * Used by SyncManager to send queued resource operations to the server. - */ -export class ApiResourceRepository implements IApiRepository { - readonly entityType: EntityType = 'Resource'; - private apiEndpoint: string; - - constructor(config: Configuration) { - this.apiEndpoint = config.apiEndpoint; - } - - /** - * Send create operation to API - */ - async sendCreate(resource: IResource): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/resources`, { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(resource) - // }); - // - // if (!response.ok) { - // throw new Error(`API create failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiResourceRepository.sendCreate not implemented yet'); - } - - /** - * Send update operation to API - */ - async sendUpdate(id: string, updates: Partial): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/resources/${id}`, { - // method: 'PATCH', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify(updates) - // }); - // - // if (!response.ok) { - // throw new Error(`API update failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiResourceRepository.sendUpdate not implemented yet'); - } - - /** - * Send delete operation to API - */ - async sendDelete(id: string): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/resources/${id}`, { - // method: 'DELETE' - // }); - // - // if (!response.ok) { - // throw new Error(`API delete failed: ${response.statusText}`); - // } - - throw new Error('ApiResourceRepository.sendDelete not implemented yet'); - } - - /** - * Fetch all resources from API - */ - async fetchAll(): Promise { - // TODO: Implement API call - // const response = await fetch(`${this.apiEndpoint}/resources`); - // - // if (!response.ok) { - // throw new Error(`API fetch failed: ${response.statusText}`); - // } - // - // return await response.json(); - - throw new Error('ApiResourceRepository.fetchAll not implemented yet'); - } -} diff --git a/src/repositories/IApiRepository.ts b/src/repositories/IApiRepository.ts index 7c442d3..a50791f 100644 --- a/src/repositories/IApiRepository.ts +++ b/src/repositories/IApiRepository.ts @@ -1,60 +1,33 @@ -import { EntityType } from '../types/CalendarTypes'; - -/** - * IApiRepository - Generic interface for backend API communication - * - * All entity-specific API repositories (Event, Booking, Customer, Resource) - * must implement this interface to ensure consistent sync behavior. - * - * Used by SyncManager to route operations to the correct API endpoints - * based on entity type (dataEntity.typename). - * - * Pattern: - * - Each entity has its own concrete implementation (ApiEventRepository, ApiBookingRepository, etc.) - * - SyncManager maintains a map of entityType → IApiRepository - * - Operations are routed at runtime based on IQueueOperation.dataEntity.typename - */ -export interface IApiRepository { - /** - * Entity type discriminator - used for runtime routing - * Must match EntityType values ('Event', 'Booking', 'Customer', 'Resource') - */ - readonly entityType: EntityType; - - /** - * Send create operation to backend API - * - * @param data - Entity data to create - * @returns Promise - Created entity from server (with server-generated fields) - * @throws Error if API call fails - */ - sendCreate(data: T): Promise; - - /** - * Send update operation to backend API - * - * @param id - Entity ID - * @param updates - Partial entity data to update - * @returns Promise - Updated entity from server - * @throws Error if API call fails - */ - sendUpdate(id: string, updates: Partial): Promise; - - /** - * Send delete operation to backend API - * - * @param id - Entity ID to delete - * @returns Promise - * @throws Error if API call fails - */ - sendDelete(id: string): Promise; - - /** - * Fetch all entities from backend API - * Used for initial sync and full refresh - * - * @returns Promise - Array of all entities - * @throws Error if API call fails - */ - fetchAll(): Promise; -} +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/src/repositories/MockAuditRepository.ts b/src/repositories/MockAuditRepository.ts index 753f4b4..211fc4f 100644 --- a/src/repositories/MockAuditRepository.ts +++ b/src/repositories/MockAuditRepository.ts @@ -26,7 +26,7 @@ export class MockAuditRepository implements IApiRepository { return entity; } - async sendUpdate(_id: string, entity: IAuditEntry): Promise { + async sendUpdate(_id: string, _entity: IAuditEntry): Promise { // Audit entries are immutable - updates should not happen throw new Error('Audit entries cannot be updated'); } diff --git a/src/repositories/MockBookingRepository.ts b/src/repositories/MockBookingRepository.ts index 7637076..449d5a3 100644 --- a/src/repositories/MockBookingRepository.ts +++ b/src/repositories/MockBookingRepository.ts @@ -1,90 +1,73 @@ -import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawBookingData { - id: string; - customerId: string; - status: string; - createdAt: string | Date; - services: RawBookingService[]; - totalPrice?: number; - tags?: string[]; - notes?: string; - [key: string]: unknown; -} - -interface RawBookingService { - serviceId: string; - serviceName: string; - baseDuration: number; - basePrice: number; - customPrice?: number; - resourceId: string; -} - -/** - * MockBookingRepository - Loads booking data from local JSON file - * - * This repository implementation fetches mock booking data from a static JSON file. - * Used for development and testing instead of API calls. - * - * Data Source: data/mock-bookings.json - * - * NOTE: Create/Update/Delete operations are not supported - throws errors. - * Only fetchAll() is implemented for loading initial mock data. - */ -export class MockBookingRepository implements IApiRepository { - public readonly entityType: EntityType = 'Booking'; - private readonly dataUrl = 'data/mock-bookings.json'; - - /** - * Fetch all bookings from mock JSON file - */ - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`); - } - - const rawData: RawBookingData[] = await response.json(); - - return this.processBookingData(rawData); - } catch (error) { - console.error('Failed to load booking data:', error); - throw error; - } - } - - /** - * NOT SUPPORTED - MockBookingRepository is read-only - */ - public async sendCreate(booking: IBooking): Promise { - throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockBookingRepository is read-only - */ - public async sendUpdate(id: string, updates: Partial): Promise { - throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockBookingRepository is read-only - */ - public async sendDelete(id: string): Promise { - throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.'); - } - - private processBookingData(data: RawBookingData[]): IBooking[] { - return data.map((booking): IBooking => ({ - ...booking, - createdAt: new Date(booking.createdAt), - status: booking.status as BookingStatus, - syncStatus: 'synced' as const - })); - } -} +import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes'; +import { IApiRepository } from './IApiRepository'; + +interface RawBookingData { + id: string; + customerId: string; + status: string; + createdAt: string | Date; + services: RawBookingService[]; + totalPrice?: number; + tags?: string[]; + notes?: string; + [key: string]: unknown; +} + +interface RawBookingService { + serviceId: string; + serviceName: string; + baseDuration: number; + basePrice: number; + customPrice?: number; + resourceId: string; +} + +/** + * MockBookingRepository - Loads booking data from local JSON file + */ +export class MockBookingRepository implements IApiRepository { + public readonly entityType: EntityType = 'Booking'; + private readonly dataUrl = 'data/mock-bookings.json'; + + public async fetchAll(): Promise { + try { + const response = await fetch(this.dataUrl); + + if (!response.ok) { + throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`); + } + + const rawData: RawBookingData[] = await response.json(); + return this.processBookingData(rawData); + } catch (error) { + console.error('Failed to load booking data:', error); + throw error; + } + } + + public async sendCreate(_booking: IBooking): Promise { + throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.'); + } + + private processBookingData(data: RawBookingData[]): IBooking[] { + return data.map((booking): IBooking => ({ + id: booking.id, + customerId: booking.customerId, + status: booking.status as BookingStatus, + createdAt: new Date(booking.createdAt), + services: booking.services as IBookingService[], + totalPrice: booking.totalPrice, + tags: booking.tags, + notes: booking.notes, + syncStatus: 'synced' as const + })); + } +} diff --git a/src/repositories/MockCustomerRepository.ts b/src/repositories/MockCustomerRepository.ts index 8b5f71c..4bf079c 100644 --- a/src/repositories/MockCustomerRepository.ts +++ b/src/repositories/MockCustomerRepository.ts @@ -1,76 +1,58 @@ -import { ICustomer } from '../types/CustomerTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawCustomerData { - id: string; - name: string; - phone: string; - email?: string; - metadata?: Record; - [key: string]: unknown; -} - -/** - * MockCustomerRepository - Loads customer data from local JSON file - * - * This repository implementation fetches mock customer data from a static JSON file. - * Used for development and testing instead of API calls. - * - * Data Source: data/mock-customers.json - * - * NOTE: Create/Update/Delete operations are not supported - throws errors. - * Only fetchAll() is implemented for loading initial mock data. - */ -export class MockCustomerRepository implements IApiRepository { - public readonly entityType: EntityType = 'Customer'; - private readonly dataUrl = 'data/mock-customers.json'; - - /** - * Fetch all customers from mock JSON file - */ - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`); - } - - const rawData: RawCustomerData[] = await response.json(); - - return this.processCustomerData(rawData); - } catch (error) { - console.error('Failed to load customer data:', error); - throw error; - } - } - - /** - * NOT SUPPORTED - MockCustomerRepository is read-only - */ - public async sendCreate(customer: ICustomer): Promise { - throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockCustomerRepository is read-only - */ - public async sendUpdate(id: string, updates: Partial): Promise { - throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockCustomerRepository is read-only - */ - public async sendDelete(id: string): Promise { - throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.'); - } - - private processCustomerData(data: RawCustomerData[]): ICustomer[] { - return data.map((customer): ICustomer => ({ - ...customer, - syncStatus: 'synced' as const - })); - } -} +import { ICustomer, EntityType } from '../types/CalendarTypes'; +import { IApiRepository } from './IApiRepository'; + +interface RawCustomerData { + id: string; + name: string; + phone: string; + email?: string; + metadata?: Record; + [key: string]: unknown; +} + +/** + * MockCustomerRepository - Loads customer data from local JSON file + */ +export class MockCustomerRepository implements IApiRepository { + public readonly entityType: EntityType = 'Customer'; + private readonly dataUrl = 'data/mock-customers.json'; + + public async fetchAll(): Promise { + try { + const response = await fetch(this.dataUrl); + + if (!response.ok) { + throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`); + } + + const rawData: RawCustomerData[] = await response.json(); + return this.processCustomerData(rawData); + } catch (error) { + console.error('Failed to load customer data:', error); + throw error; + } + } + + public async sendCreate(_customer: ICustomer): Promise { + throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.'); + } + + private processCustomerData(data: RawCustomerData[]): ICustomer[] { + return data.map((customer): ICustomer => ({ + id: customer.id, + name: customer.name, + phone: customer.phone, + email: customer.email, + metadata: customer.metadata, + syncStatus: 'synced' as const + })); + } +} diff --git a/src/v2/repositories/MockDepartmentRepository.ts b/src/repositories/MockDepartmentRepository.ts similarity index 100% rename from src/v2/repositories/MockDepartmentRepository.ts rename to src/repositories/MockDepartmentRepository.ts diff --git a/src/repositories/MockEventRepository.ts b/src/repositories/MockEventRepository.ts index 9740eb1..939569b 100644 --- a/src/repositories/MockEventRepository.ts +++ b/src/repositories/MockEventRepository.ts @@ -1,41 +1,26 @@ -import { ICalendarEvent, EntityType } from '../types/CalendarTypes'; -import { CalendarEventType } from '../types/BookingTypes'; +import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes'; import { IApiRepository } from './IApiRepository'; interface RawEventData { - // Core fields (required) id: string; title: string; start: string | Date; end: string | Date; type: string; allDay?: boolean; - - // Denormalized references (CRITICAL for booking architecture) - bookingId?: string; // Reference to booking (customer events only) - resourceId?: string; // Which resource owns this slot - customerId?: string; // Customer reference (denormalized from booking) - - // Optional fields - description?: string; // Detailed event notes - recurringId?: string; // For recurring events - metadata?: Record; // Flexible metadata - - // Legacy (deprecated, keep for backward compatibility) - color?: string; // UI-specific field + bookingId?: string; + resourceId?: string; + customerId?: string; + description?: string; + recurringId?: string; + metadata?: Record; [key: string]: unknown; } /** * MockEventRepository - Loads event data from local JSON file * - * This repository implementation fetches mock event data from a static JSON file. - * Used for development and testing instead of API calls. - * - * Data Source: data/mock-events.json - * - * NOTE: Create/Update/Delete operations are not supported - throws errors. - * Only fetchAll() is implemented for loading initial mock data. + * Used for development and testing. Only fetchAll() is implemented. */ export class MockEventRepository implements IApiRepository { public readonly entityType: EntityType = 'Event'; @@ -53,7 +38,6 @@ export class MockEventRepository implements IApiRepository { } const rawData: RawEventData[] = await response.json(); - return this.processCalendarData(rawData); } catch (error) { console.error('Failed to load event data:', error); @@ -61,40 +45,25 @@ export class MockEventRepository implements IApiRepository { } } - /** - * NOT SUPPORTED - MockEventRepository is read-only - */ - public async sendCreate(event: ICalendarEvent): Promise { + public async sendCreate(_event: ICalendarEvent): Promise { throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.'); } - /** - * NOT SUPPORTED - MockEventRepository is read-only - */ - public async sendUpdate(id: string, updates: Partial): Promise { + public async sendUpdate(_id: string, _updates: Partial): Promise { throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.'); } - /** - * NOT SUPPORTED - MockEventRepository is read-only - */ - public async sendDelete(id: string): Promise { + public async sendDelete(_id: string): Promise { throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.'); } private processCalendarData(data: RawEventData[]): ICalendarEvent[] { return data.map((event): ICalendarEvent => { - // Validate event type constraints + // Validate customer event constraints if (event.type === 'customer') { - if (!event.bookingId) { - console.warn(`Customer event ${event.id} missing bookingId`); - } - if (!event.resourceId) { - console.warn(`Customer event ${event.id} missing resourceId`); - } - if (!event.customerId) { - console.warn(`Customer event ${event.id} missing customerId`); - } + if (!event.bookingId) console.warn(`Customer event ${event.id} missing bookingId`); + if (!event.resourceId) console.warn(`Customer event ${event.id} missing resourceId`); + if (!event.customerId) console.warn(`Customer event ${event.id} missing customerId`); } return { @@ -105,16 +74,11 @@ export class MockEventRepository implements IApiRepository { end: new Date(event.end), type: event.type as CalendarEventType, allDay: event.allDay || false, - - // Denormalized references (CRITICAL for booking architecture) bookingId: event.bookingId, resourceId: event.resourceId, customerId: event.customerId, - - // Optional fields recurringId: event.recurringId, metadata: event.metadata, - syncStatus: 'synced' as const }; }); diff --git a/src/repositories/MockResourceRepository.ts b/src/repositories/MockResourceRepository.ts index 28bc838..0f2217f 100644 --- a/src/repositories/MockResourceRepository.ts +++ b/src/repositories/MockResourceRepository.ts @@ -1,80 +1,66 @@ -import { IResource, ResourceType } from '../types/ResourceTypes'; -import { EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawResourceData { - id: string; - name: string; - displayName: string; - type: string; - avatarUrl?: string; - color?: string; - isActive?: boolean; - metadata?: Record; - [key: string]: unknown; -} - -/** - * MockResourceRepository - Loads resource data from local JSON file - * - * This repository implementation fetches mock resource data from a static JSON file. - * Used for development and testing instead of API calls. - * - * Data Source: data/mock-resources.json - * - * NOTE: Create/Update/Delete operations are not supported - throws errors. - * Only fetchAll() is implemented for loading initial mock data. - */ -export class MockResourceRepository implements IApiRepository { - public readonly entityType: EntityType = 'Resource'; - private readonly dataUrl = 'data/mock-resources.json'; - - /** - * Fetch all resources from mock JSON file - */ - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`); - } - - const rawData: RawResourceData[] = await response.json(); - - return this.processResourceData(rawData); - } catch (error) { - console.error('Failed to load resource data:', error); - throw error; - } - } - - /** - * NOT SUPPORTED - MockResourceRepository is read-only - */ - public async sendCreate(resource: IResource): Promise { - throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockResourceRepository is read-only - */ - public async sendUpdate(id: string, updates: Partial): Promise { - throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.'); - } - - /** - * NOT SUPPORTED - MockResourceRepository is read-only - */ - public async sendDelete(id: string): Promise { - throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.'); - } - - private processResourceData(data: RawResourceData[]): IResource[] { - return data.map((resource): IResource => ({ - ...resource, - type: resource.type as ResourceType, - syncStatus: 'synced' as const - })); - } -} +import { IResource, ResourceType, EntityType } from '../types/CalendarTypes'; +import { IApiRepository } from './IApiRepository'; +import { IWeekSchedule } from '../types/ScheduleTypes'; + +interface RawResourceData { + id: string; + name: string; + displayName: string; + type: string; + avatarUrl?: string; + color?: string; + isActive?: boolean; + defaultSchedule?: IWeekSchedule; + metadata?: Record; +} + +/** + * MockResourceRepository - Loads resource data from local JSON file + */ +export class MockResourceRepository implements IApiRepository { + public readonly entityType: EntityType = 'Resource'; + private readonly dataUrl = 'data/mock-resources.json'; + + public async fetchAll(): Promise { + try { + const response = await fetch(this.dataUrl); + + if (!response.ok) { + throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`); + } + + const rawData: RawResourceData[] = await response.json(); + return this.processResourceData(rawData); + } catch (error) { + console.error('Failed to load resource data:', error); + throw error; + } + } + + public async sendCreate(_resource: IResource): Promise { + throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.'); + } + + public async sendUpdate(_id: string, _updates: Partial): Promise { + throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.'); + } + + public async sendDelete(_id: string): Promise { + throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.'); + } + + private processResourceData(data: RawResourceData[]): IResource[] { + return data.map((resource): IResource => ({ + id: resource.id, + name: resource.name, + displayName: resource.displayName, + type: resource.type as ResourceType, + avatarUrl: resource.avatarUrl, + color: resource.color, + isActive: resource.isActive, + defaultSchedule: resource.defaultSchedule, + metadata: resource.metadata, + syncStatus: 'synced' as const + })); + } +} diff --git a/src/v2/repositories/MockSettingsRepository.ts b/src/repositories/MockSettingsRepository.ts similarity index 100% rename from src/v2/repositories/MockSettingsRepository.ts rename to src/repositories/MockSettingsRepository.ts diff --git a/src/v2/repositories/MockTeamRepository.ts b/src/repositories/MockTeamRepository.ts similarity index 100% rename from src/v2/repositories/MockTeamRepository.ts rename to src/repositories/MockTeamRepository.ts diff --git a/src/v2/repositories/MockViewConfigRepository.ts b/src/repositories/MockViewConfigRepository.ts similarity index 100% rename from src/v2/repositories/MockViewConfigRepository.ts rename to src/repositories/MockViewConfigRepository.ts diff --git a/src/storage/BaseEntityService.ts b/src/storage/BaseEntityService.ts index c889885..ed8d3a1 100644 --- a/src/storage/BaseEntityService.ts +++ b/src/storage/BaseEntityService.ts @@ -1,266 +1,181 @@ -import { ISync, EntityType, SyncStatus, IEventBus } 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'; -import { IEntitySavedPayload, IEntityDeletedPayload } from '../types/EventTypes'; - -/** - * BaseEntityService - Abstract base class for all entity services - * - * HYBRID PATTERN: Inheritance + Composition - * - Services EXTEND this base class (inheritance for structure) - * - Sync logic is COMPOSED via SyncPlugin (pluggable) - * - * PROVIDES: - * - Generic CRUD operations (get, getAll, save, delete) - * - Sync status management (delegates to SyncPlugin) - * - Serialization hooks (override in subclass if needed) - * - Lazy database access via IndexedDBContext - * - * SUBCLASSES MUST IMPLEMENT: - * - storeName: string (IndexedDB object store name) - * - entityType: EntityType (for runtime routing) - * - * SUBCLASSES MAY OVERRIDE: - * - serialize(entity: T): any (default: no serialization) - * - deserialize(data: any): T (default: no deserialization) - * - * BENEFITS: - * - DRY: Single source of truth for CRUD logic - * - Type safety: Generic T ensures compile-time checking - * - Pluggable: SyncPlugin can be swapped for testing/different implementations - * - Open/Closed: New entities just extend this class - * - Lazy database access: db requested when needed, not at construction time - */ -export abstract class BaseEntityService implements IEntityService { - // Abstract properties - must be implemented by subclasses - abstract readonly storeName: string; - abstract readonly entityType: EntityType; - - // Internal composition - sync functionality - private syncPlugin: SyncPlugin; - - // IndexedDB context - provides database connection - private context: IndexedDBContext; - - // EventBus for emitting entity events - protected eventBus: IEventBus; - - /** - * @param context - IndexedDBContext instance (injected dependency) - * @param eventBus - EventBus for emitting entity events - */ - constructor(context: IndexedDBContext, eventBus: IEventBus) { - this.context = context; - this.eventBus = eventBus; - this.syncPlugin = new SyncPlugin(this); - } - - /** - * Get IDBDatabase instance (lazy access) - * Protected getter accessible to subclasses and methods in this class - */ - protected get db(): IDBDatabase { - return this.context.getDatabase(); - } - - /** - * Serialize entity before storing in IndexedDB - * Override in subclass if entity has Date fields or needs transformation - * - * @param entity - Entity to serialize - * @returns Serialized data (default: entity itself) - */ - protected serialize(entity: T): any { - return entity; // Default: no serialization - } - - /** - * Deserialize data from IndexedDB back to entity - * Override in subclass if entity has Date fields or needs transformation - * - * @param data - Raw data from IndexedDB - * @returns Deserialized entity (default: data itself) - */ - protected deserialize(data: any): T { - return data as T; // Default: no deserialization - } - - /** - * Get a single entity by ID - * - * @param id - Entity ID - * @returns Entity or null if not found - */ - 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; - if (data) { - resolve(this.deserialize(data)); - } else { - resolve(null); - } - }; - - request.onerror = () => { - reject(new Error(`Failed to get ${this.entityType} ${id}: ${request.error}`)); - }; - }); - } - - /** - * Get all entities - * - * @returns Array of 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 any[]; - 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 - * - * @param entity - Entity to save - */ - async save(entity: T): Promise { - const entityId = (entity as any).id; - - // Check if entity exists to determine create vs update - const existingEntity = await this.get(entityId); - const isCreate = existingEntity === null; - - // Calculate changes: full entity for create, diff for update - let changes: any; - if (isCreate) { - changes = entity; - } else { - // Calculate diff between existing and new entity - 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 = () => { - // Emit ENTITY_SAVED event - 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 - * - * @param id - Entity ID to delete - */ - 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 = () => { - // Emit ENTITY_DELETED event - 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 (IEntityService implementation) - Delegates to SyncPlugin - // ============================================================================ - - /** - * Mark entity as successfully synced (IEntityService implementation) - * Delegates to SyncPlugin - * - * @param id - Entity ID - */ - async markAsSynced(id: string): Promise { - return this.syncPlugin.markAsSynced(id); - } - - /** - * Mark entity as sync error (IEntityService implementation) - * Delegates to SyncPlugin - * - * @param id - Entity ID - */ - async markAsError(id: string): Promise { - return this.syncPlugin.markAsError(id); - } - - /** - * Get sync status for an entity (IEntityService implementation) - * Delegates to SyncPlugin - * - * @param id - Entity ID - * @returns SyncStatus or null if entity not found - */ - async getSyncStatus(id: string): Promise { - return this.syncPlugin.getSyncStatus(id); - } - - /** - * Get entities by sync status - * Delegates to SyncPlugin - uses IndexedDB syncStatus index - * - * @param syncStatus - Sync status ('synced', 'pending', 'error') - * @returns Array of entities with this sync status - */ - async getBySyncStatus(syncStatus: string): Promise { - return this.syncPlugin.getBySyncStatus(syncStatus); - } -} +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/src/storage/IEntityService.ts b/src/storage/IEntityService.ts index c717598..800ea62 100644 --- a/src/storage/IEntityService.ts +++ b/src/storage/IEntityService.ts @@ -1,70 +1,40 @@ -import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes'; - -/** - * IEntityService - Generic interface for entity services with sync capabilities - * - * All entity services (Event, Booking, Customer, Resource) implement this interface - * to enable polymorphic operations across different entity types. - * - * ENCAPSULATION: Services encapsulate sync status manipulation. - * SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service. - * - * POLYMORPHISM: Both SyncManager and DataSeeder work with Array> - * and use entityType property for runtime routing, avoiding switch statements. - */ -export interface IEntityService { - /** - * Entity type discriminator for runtime routing - * Must match EntityType values: 'Event', 'Booking', 'Customer', 'Resource' - */ - readonly entityType: EntityType; - - // ============================================================================ - // CRUD Operations (used by DataSeeder and other consumers) - // ============================================================================ - - /** - * Get all entities from IndexedDB - * Used by DataSeeder to check if store is empty before seeding - * - * @returns Promise - Array of all entities - */ - getAll(): Promise; - - /** - * Save an entity (create or update) to IndexedDB - * Used by DataSeeder to persist fetched data - * - * @param entity - Entity to save - */ - save(entity: T): Promise; - - // ============================================================================ - // SYNC Methods (used by SyncManager) - // ============================================================================ - - /** - * Mark entity as successfully synced with backend - * Sets syncStatus = 'synced' and persists to IndexedDB - * - * @param id - Entity ID - */ - markAsSynced(id: string): Promise; - - /** - * Mark entity as sync error (max retries exceeded) - * Sets syncStatus = 'error' and persists to IndexedDB - * - * @param id - Entity ID - */ - markAsError(id: string): Promise; - - /** - * Get current sync status for an entity - * Used by SyncManager to check entity state - * - * @param id - Entity ID - * @returns SyncStatus or null if entity not found - */ - getSyncStatus(id: string): Promise; -} +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/src/storage/IStore.ts b/src/storage/IStore.ts index d4ed3e7..91ac873 100644 --- a/src/storage/IStore.ts +++ b/src/storage/IStore.ts @@ -1,25 +1,18 @@ -/** - * IStore - Interface for IndexedDB ObjectStore definitions - * - * Each entity store (bookings, customers, resources, events) implements this interface - * to define its schema and creation logic. - * - * This enables Open/Closed Principle: IndexedDBService can work with any IStore - * implementation without modification. Adding new entities only requires: - * 1. Create new Store class implementing IStore - * 2. Register in DI container as 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) - * - * @param db - IDBDatabase instance - */ - create(db: IDBDatabase): void; -} +/** + * 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/src/storage/IndexedDBContext.ts b/src/storage/IndexedDBContext.ts index da2d6fe..2136e2b 100644 --- a/src/storage/IndexedDBContext.ts +++ b/src/storage/IndexedDBContext.ts @@ -1,128 +1,92 @@ -import { IStore } from './IStore'; - -/** - * IndexedDBContext - Database connection manager and provider - * - * RESPONSIBILITY: - * - Opens and manages IDBDatabase connection lifecycle - * - Creates object stores via injected IStore implementations - * - Provides shared IDBDatabase instance to all services - * - * SEPARATION OF CONCERNS: - * - This class: Connection management ONLY - * - OperationQueue: Queue and sync state operations - * - Entity Services: CRUD operations for specific entities - * - * USAGE: - * Services inject IndexedDBContext and call getDatabase() to access db. - * This lazy access pattern ensures db is ready when requested. - */ -export class IndexedDBContext { - private static readonly DB_NAME = 'CalendarDB'; - private static readonly DB_VERSION = 5; // Bumped to add syncStatus index to resources - static readonly QUEUE_STORE = 'operationQueue'; - static readonly SYNC_STATE_STORE = 'syncState'; - - private db: IDBDatabase | null = null; - private initialized: boolean = false; - private stores: IStore[]; - - /** - * @param stores - Array of IStore implementations injected via DI - */ - constructor(stores: IStore[]) { - this.stores = stores; - } - - /** - * Initialize and open the database - * Creates all entity stores, queue store, and sync state store - */ - async initialize(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION); - - 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 - // Open/Closed Principle: Adding new entity only requires DI registration - this.stores.forEach(store => { - if (!db.objectStoreNames.contains(store.storeName)) { - store.create(db); - } - }); - - // Create operation queue store (sync infrastructure) - if (!db.objectStoreNames.contains(IndexedDBContext.QUEUE_STORE)) { - const queueStore = db.createObjectStore(IndexedDBContext.QUEUE_STORE, { keyPath: 'id' }); - queueStore.createIndex('timestamp', 'timestamp', { unique: false }); - } - - // Create sync state store (sync metadata) - if (!db.objectStoreNames.contains(IndexedDBContext.SYNC_STATE_STORE)) { - db.createObjectStore(IndexedDBContext.SYNC_STATE_STORE, { keyPath: 'key' }); - } - }; - }); - } - - /** - * Check if database is initialized - */ - public isInitialized(): boolean { - return this.initialized; - } - - /** - * Get IDBDatabase instance - * Used by services to access the database - * - * @throws Error if database not initialized - * @returns 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(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Failed to delete database: ${request.error}`)); - }; - }); - } -} +import { IStore } from './IStore'; + +/** + * 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 static readonly DB_NAME = 'CalendarDB'; + private static readonly DB_VERSION = 4; + + private db: IDBDatabase | null = null; + private initialized: boolean = false; + private stores: IStore[]; + + constructor(stores: IStore[]) { + this.stores = stores; + } + + /** + * Initialize and open the database + */ + async initialize(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION); + + 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(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`)); + }); + } +} diff --git a/src/storage/SyncPlugin.ts b/src/storage/SyncPlugin.ts index 785e625..7774da6 100644 --- a/src/storage/SyncPlugin.ts +++ b/src/storage/SyncPlugin.ts @@ -1,90 +1,64 @@ -import { ISync, SyncStatus, EntityType } 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) - * - Allows sync functionality to be swapped/mocked for testing - * - Single Responsibility: Only handles sync status management - * - * DESIGN: - * - Takes reference to BaseEntityService for calling get/save - * - Implements sync methods that delegate to service's CRUD - * - Uses IndexedDB syncStatus index for efficient queries - */ -export class SyncPlugin { - /** - * @param service - Reference to BaseEntityService for CRUD operations - */ - constructor(private service: any) { - // Type is 'any' to avoid circular dependency at compile time - // Runtime: service is BaseEntityService - } - - /** - * Mark entity as successfully synced - * Sets syncStatus = 'synced' and persists to IndexedDB - * - * @param id - Entity ID - */ - 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 (max retries exceeded) - * Sets syncStatus = 'error' and persists to IndexedDB - * - * @param id - Entity ID - */ - 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 - * - * @param id - Entity ID - * @returns SyncStatus or null if entity not found - */ - async getSyncStatus(id: string): Promise { - const entity = await this.service.get(id); - return entity ? entity.syncStatus : null; - } - - /** - * Get entities by sync status - * Uses IndexedDB syncStatus index for efficient querying - * - * @param syncStatus - Sync status ('synced', 'pending', 'error') - * @returns Array of entities with this sync status - */ - 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 any[]; - const entities = data.map(item => this.service.deserialize(item)); - resolve(entities); - }; - - request.onerror = () => { - reject(new Error(`Failed to get ${this.service.entityType}s by sync status ${syncStatus}: ${request.error}`)); - }; - }); - } -} +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/src/storage/audit/AuditService.ts b/src/storage/audit/AuditService.ts index 238ed87..bf69664 100644 --- a/src/storage/audit/AuditService.ts +++ b/src/storage/audit/AuditService.ts @@ -1,9 +1,8 @@ import { BaseEntityService } from '../BaseEntityService'; import { IndexedDBContext } from '../IndexedDBContext'; -import { IAuditEntry } from '../../types/AuditTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; +import { IAuditEntry, IAuditLoggedPayload } from '../../types/AuditTypes'; +import { EntityType, IEventBus, IEntitySavedPayload, IEntityDeletedPayload } from '../../types/CalendarTypes'; import { CoreEvents } from '../../constants/CoreEvents'; -import { IEntitySavedPayload, IEntityDeletedPayload, IAuditLoggedPayload } from '../../types/EventTypes'; /** * AuditService - Entity service for audit entries diff --git a/src/storage/audit/AuditStore.ts b/src/storage/audit/AuditStore.ts index bdef64e..00caf8b 100644 --- a/src/storage/audit/AuditStore.ts +++ b/src/storage/audit/AuditStore.ts @@ -11,15 +11,17 @@ import { IStore } from '../IStore'; * 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'; + 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 }); - } + 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/src/storage/bookings/BookingSerialization.ts b/src/storage/bookings/BookingSerialization.ts deleted file mode 100644 index 20f4828..0000000 --- a/src/storage/bookings/BookingSerialization.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { IBooking } from '../../types/BookingTypes'; - -/** - * BookingSerialization - Handles Date field serialization for IndexedDB - * - * IndexedDB doesn't store Date objects directly, so we convert: - * - Date → ISO string (serialize) when writing to IndexedDB - * - ISO string → Date (deserialize) when reading from IndexedDB - */ -export class BookingSerialization { - /** - * Serialize booking for IndexedDB storage - * Converts Date fields to ISO strings - * - * @param booking - IBooking with Date objects - * @returns Plain object with ISO string dates - */ - static serialize(booking: IBooking): any { - return { - ...booking, - createdAt: booking.createdAt instanceof Date - ? booking.createdAt.toISOString() - : booking.createdAt - }; - } - - /** - * Deserialize booking from IndexedDB storage - * Converts ISO string dates back to Date objects - * - * @param data - Plain object from IndexedDB with ISO string dates - * @returns IBooking with Date objects - */ - static deserialize(data: any): IBooking { - return { - ...data, - createdAt: typeof data.createdAt === 'string' - ? new Date(data.createdAt) - : data.createdAt - }; - } -} diff --git a/src/storage/bookings/BookingService.ts b/src/storage/bookings/BookingService.ts index 3550627..ae7a9f9 100644 --- a/src/storage/bookings/BookingService.ts +++ b/src/storage/bookings/BookingService.ts @@ -1,97 +1,75 @@ -import { IBooking } from '../../types/BookingTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; -import { BookingStore } from './BookingStore'; -import { BookingSerialization } from './BookingSerialization'; -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; - -/** - * BookingService - CRUD operations for bookings in IndexedDB - * - * ARCHITECTURE: - * - Extends BaseEntityService for shared CRUD and sync logic - * - Overrides serialize/deserialize for Date field conversion (createdAt) - * - Provides booking-specific query methods (by customer, by status) - * - * INHERITED METHODS (from BaseEntityService): - * - get(id), getAll(), save(entity), delete(id) - * - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status) - * - * BOOKING-SPECIFIC METHODS: - * - getByCustomer(customerId) - * - getByStatus(status) - */ -export class BookingService extends BaseEntityService { - readonly storeName = BookingStore.STORE_NAME; - readonly entityType: EntityType = 'Booking'; - - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - - /** - * Serialize booking for IndexedDB storage - * Converts Date objects to ISO strings - */ - protected serialize(booking: IBooking): any { - return BookingSerialization.serialize(booking); - } - - /** - * Deserialize booking from IndexedDB - * Converts ISO strings back to Date objects - */ - protected deserialize(data: any): IBooking { - return BookingSerialization.deserialize(data); - } - - /** - * Get bookings by customer ID - * - * @param customerId - Customer ID - * @returns Array of bookings for this 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 any[]; - 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 - * - * @param status - Booking status - * @returns Array of bookings with this status - */ - async getByStatus(status: 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('status'); - const request = index.getAll(status); - - request.onsuccess = () => { - const data = request.result as any[]; - const bookings = data.map(item => this.deserialize(item)); - resolve(bookings); - }; - - request.onerror = () => { - reject(new Error(`Failed to get bookings with status ${status}: ${request.error}`)); - }; - }); - } -} +import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes'; +import { BookingStore } from './BookingStore'; +import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../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/src/storage/bookings/BookingStore.ts b/src/storage/bookings/BookingStore.ts index b1458b8..c412f85 100644 --- a/src/storage/bookings/BookingStore.ts +++ b/src/storage/bookings/BookingStore.ts @@ -1,38 +1,18 @@ -import { IStore } from '../IStore'; - -/** - * BookingStore - IndexedDB ObjectStore definition for bookings - * - * Defines schema, indexes, and store creation logic. - * Part of modular storage architecture where each entity has its own folder. - */ -export class BookingStore implements IStore { - /** ObjectStore name in IndexedDB (static for backward compatibility) */ - static readonly STORE_NAME = 'bookings'; - - /** ObjectStore name in IndexedDB (instance property for IStore interface) */ - readonly storeName = BookingStore.STORE_NAME; - - /** - * Create the bookings ObjectStore with indexes - * Called during database upgrade (onupgradeneeded) - * - * @param db - IDBDatabase instance - */ - create(db: IDBDatabase): void { - // Create ObjectStore with 'id' as keyPath - const store = db.createObjectStore(BookingStore.STORE_NAME, { keyPath: 'id' }); - - // Index: customerId (for querying bookings by customer) - store.createIndex('customerId', 'customerId', { unique: false }); - - // Index: status (for filtering by booking status) - store.createIndex('status', 'status', { unique: false }); - - // Index: syncStatus (for querying by sync status - used by SyncPlugin) - store.createIndex('syncStatus', 'syncStatus', { unique: false }); - - // Index: createdAt (for sorting bookings chronologically) - store.createIndex('createdAt', 'createdAt', { unique: false }); - } -} +import { IStore } from '../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/src/storage/customers/CustomerService.ts b/src/storage/customers/CustomerService.ts index 8b076f0..6cfd888 100644 --- a/src/storage/customers/CustomerService.ts +++ b/src/storage/customers/CustomerService.ts @@ -1,68 +1,46 @@ -import { ICustomer } from '../../types/CustomerTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; -import { CustomerStore } from './CustomerStore'; -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; - -/** - * CustomerService - CRUD operations for customers in IndexedDB - * - * ARCHITECTURE: - * - Extends BaseEntityService for shared CRUD and sync logic - * - No serialization needed (ICustomer has no Date fields) - * - Provides customer-specific query methods (by phone, search by name) - * - * INHERITED METHODS (from BaseEntityService): - * - get(id), getAll(), save(entity), delete(id) - * - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status) - * - * CUSTOMER-SPECIFIC METHODS: - * - getByPhone(phone) - * - searchByName(searchTerm) - */ -export class CustomerService extends BaseEntityService { - readonly storeName = CustomerStore.STORE_NAME; - readonly entityType: EntityType = 'Customer'; - - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - - /** - * Get customers by phone number - * - * @param phone - Phone number - * @returns Array of customers with this 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.getAll(phone); - - request.onsuccess = () => { - resolve(request.result as ICustomer[]); - }; - - request.onerror = () => { - reject(new Error(`Failed to get customers by phone ${phone}: ${request.error}`)); - }; - }); - } - - /** - * Search customers by name (partial match) - * - * @param searchTerm - Search term (case insensitive) - * @returns Array of customers matching search - */ - async searchByName(searchTerm: string): Promise { - const allCustomers = await this.getAll(); - const lowerSearch = searchTerm.toLowerCase(); - - return allCustomers.filter(customer => - customer.name.toLowerCase().includes(lowerSearch) - ); - } -} +import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes'; +import { CustomerStore } from './CustomerStore'; +import { BaseEntityService } from '../BaseEntityService'; +import { IndexedDBContext } from '../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/src/storage/customers/CustomerStore.ts b/src/storage/customers/CustomerStore.ts index 65cd9e7..9afcf9e 100644 --- a/src/storage/customers/CustomerStore.ts +++ b/src/storage/customers/CustomerStore.ts @@ -1,35 +1,17 @@ -import { IStore } from '../IStore'; - -/** - * CustomerStore - IndexedDB ObjectStore definition for customers - * - * Defines schema, indexes, and store creation logic. - * Part of modular storage architecture where each entity has its own folder. - */ -export class CustomerStore implements IStore { - /** ObjectStore name in IndexedDB (static for backward compatibility) */ - static readonly STORE_NAME = 'customers'; - - /** ObjectStore name in IndexedDB (instance property for IStore interface) */ - readonly storeName = CustomerStore.STORE_NAME; - - /** - * Create the customers ObjectStore with indexes - * Called during database upgrade (onupgradeneeded) - * - * @param db - IDBDatabase instance - */ - create(db: IDBDatabase): void { - // Create ObjectStore with 'id' as keyPath - const store = db.createObjectStore(CustomerStore.STORE_NAME, { keyPath: 'id' }); - - // Index: name (for customer search/lookup) - store.createIndex('name', 'name', { unique: false }); - - // Index: phone (for customer lookup by phone) - store.createIndex('phone', 'phone', { unique: false }); - - // Index: syncStatus (for querying by sync status - used by SyncPlugin) - store.createIndex('syncStatus', 'syncStatus', { unique: false }); - } -} +import { IStore } from '../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/src/v2/storage/departments/DepartmentService.ts b/src/storage/departments/DepartmentService.ts similarity index 100% rename from src/v2/storage/departments/DepartmentService.ts rename to src/storage/departments/DepartmentService.ts diff --git a/src/v2/storage/departments/DepartmentStore.ts b/src/storage/departments/DepartmentStore.ts similarity index 100% rename from src/v2/storage/departments/DepartmentStore.ts rename to src/storage/departments/DepartmentStore.ts diff --git a/src/storage/events/EventSerialization.ts b/src/storage/events/EventSerialization.ts index 61b64d0..583fa79 100644 --- a/src/storage/events/EventSerialization.ts +++ b/src/storage/events/EventSerialization.ts @@ -1,40 +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 to IndexedDB - * - ISO string → Date (deserialize) when reading from IndexedDB - */ -export class EventSerialization { - /** - * Serialize event for IndexedDB storage - * Converts Date fields to ISO strings - * - * @param event - ICalendarEvent with Date objects - * @returns Plain object with ISO string dates - */ - static serialize(event: ICalendarEvent): any { - 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 - * Converts ISO string dates back to Date objects - * - * @param data - Plain object from IndexedDB with ISO string dates - * @returns ICalendarEvent with Date objects - */ - static deserialize(data: any): 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 - }; - } -} +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/src/storage/events/EventService.ts b/src/storage/events/EventService.ts index 7207898..0ccd5a5 100644 --- a/src/storage/events/EventService.ts +++ b/src/storage/events/EventService.ts @@ -1,174 +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 - * - * ARCHITECTURE: - * - Extends BaseEntityService for shared CRUD and sync logic - * - Overrides serialize/deserialize for Date field conversion - * - Provides event-specific query methods (by date range, resource, customer, booking) - * - * INHERITED METHODS (from BaseEntityService): - * - get(id), getAll(), save(entity), delete(id) - * - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status) - * - * EVENT-SPECIFIC METHODS: - * - getByDateRange(start, end) - * - getByResource(resourceId) - * - getByCustomer(customerId) - * - getByBooking(bookingId) - * - getByResourceAndDateRange(resourceId, start, end) - */ -export class EventService extends BaseEntityService { - readonly storeName = EventStore.STORE_NAME; - readonly entityType: EntityType = 'Event'; - - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - - /** - * Serialize event for IndexedDB storage - * Converts Date objects to ISO strings - */ - protected serialize(event: ICalendarEvent): any { - return EventSerialization.serialize(event); - } - - /** - * Deserialize event from IndexedDB - * Converts ISO strings back to Date objects - */ - protected deserialize(data: any): ICalendarEvent { - return EventSerialization.deserialize(data); - } - - /** - * Get events within a date range - * Uses start index + in-memory filtering for simplicity and performance - * - * @param start - Start date (inclusive) - * @param end - End date (inclusive) - * @returns Array of events in 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'); - - // Get all events starting from start date - const range = IDBKeyRange.lowerBound(start.toISOString()); - const request = index.getAll(range); - - request.onsuccess = () => { - const data = request.result as any[]; - - // Deserialize and filter in memory - 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 - * - * @param resourceId - Resource ID - * @returns Array of events for this 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 any[]; - 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 specific customer - * - * @param customerId - Customer ID - * @returns Array of events for this 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 any[]; - const events = data.map(item => this.deserialize(item)); - resolve(events); - }; - - request.onerror = () => { - reject(new Error(`Failed to get events for customer ${customerId}: ${request.error}`)); - }; - }); - } - - /** - * Get events for a specific booking - * - * @param bookingId - Booking ID - * @returns Array of events for this booking - */ - async getByBooking(bookingId: 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('bookingId'); - const request = index.getAll(bookingId); - - request.onsuccess = () => { - const data = request.result as any[]; - const events = data.map(item => this.deserialize(item)); - resolve(events); - }; - - request.onerror = () => { - reject(new Error(`Failed to get events for booking ${bookingId}: ${request.error}`)); - }; - }); - } - - /** - * Get events for a resource within a date range - * Combines resource and date filtering - * - * @param resourceId - Resource ID - * @param start - Start date - * @param end - End date - * @returns Array of events for this resource in range - */ - async getByResourceAndDateRange(resourceId: string, start: Date, end: Date): Promise { - // Get events for resource, then filter by date in memory - const resourceEvents = await this.getByResource(resourceId); - return resourceEvents.filter(event => event.start >= start && event.start <= end); - } -} +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/src/storage/events/EventStore.ts b/src/storage/events/EventStore.ts index a399a1c..21c7be0 100644 --- a/src/storage/events/EventStore.ts +++ b/src/storage/events/EventStore.ts @@ -1,47 +1,37 @@ -import { IStore } from '../IStore'; - -/** - * EventStore - IndexedDB ObjectStore definition for calendar events - * - * Defines schema, indexes, and store creation logic. - * Part of modular storage architecture where each entity has its own folder. - */ -export class EventStore implements IStore { - /** ObjectStore name in IndexedDB (static for backward compatibility) */ - static readonly STORE_NAME = 'events'; - - /** ObjectStore name in IndexedDB (instance property for IStore interface) */ - readonly storeName = EventStore.STORE_NAME; - - /** - * Create the events ObjectStore with indexes - * Called during database upgrade (onupgradeneeded) - * - * @param db - IDBDatabase instance - */ - create(db: IDBDatabase): void { - // Create ObjectStore with 'id' as keyPath - 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 (CRITICAL 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 }); - } -} +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/src/storage/resources/ResourceService.ts b/src/storage/resources/ResourceService.ts index 8fd868e..769210c 100644 --- a/src/storage/resources/ResourceService.ts +++ b/src/storage/resources/ResourceService.ts @@ -1,55 +1,55 @@ -import { IResource } from '../../types/ResourceTypes'; -import { EntityType, IEventBus } from '../../types/CalendarTypes'; -import { ResourceStore } from './ResourceStore'; -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../IndexedDBContext'; - -/** - * ResourceService - CRUD operations for resources in IndexedDB - * - * ARCHITECTURE: - * - Extends BaseEntityService for shared CRUD and sync logic - * - No serialization needed (IResource has no Date fields) - * - Provides resource-specific query methods (by type, active/inactive) - * - * INHERITED METHODS (from BaseEntityService): - * - get(id), getAll(), save(entity), delete(id) - * - markAsSynced(id), markAsError(id), getSyncStatus(id), getBySyncStatus(status) - * - * RESOURCE-SPECIFIC METHODS: - * - getByType(type) - * - getActive() - * - getInactive() - */ -export class ResourceService extends BaseEntityService { - readonly storeName = ResourceStore.STORE_NAME; - readonly entityType: EntityType = 'Resource'; - - constructor(context: IndexedDBContext, eventBus: IEventBus) { - super(context, eventBus); - } - - /** - * Get resources by type - */ - async getByType(type: string): Promise { - const all = await this.getAll(); - return all.filter(r => r.type === type); - } - - /** - * Get active resources only - */ - async getActive(): Promise { - const all = await this.getAll(); - return all.filter(r => r.isActive === true); - } - - /** - * Get inactive resources - */ - async getInactive(): Promise { - const all = await this.getAll(); - return all.filter(r => r.isActive === false); - } -} +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/src/storage/resources/ResourceStore.ts b/src/storage/resources/ResourceStore.ts index 05ed171..38e39b6 100644 --- a/src/storage/resources/ResourceStore.ts +++ b/src/storage/resources/ResourceStore.ts @@ -1,26 +1,17 @@ -import { IStore } from '../IStore'; - -/** - * ResourceStore - IndexedDB ObjectStore definition for resources - * - * Defines schema, indexes, and store creation logic. - * Part of modular storage architecture where each entity has its own folder. - */ -export class ResourceStore implements IStore { - /** ObjectStore name in IndexedDB (static for backward compatibility) */ - static readonly STORE_NAME = 'resources'; - - /** ObjectStore name in IndexedDB (instance property for IStore interface) */ - readonly storeName = ResourceStore.STORE_NAME; - - /** - * Create the resources ObjectStore with indexes - * Called during database upgrade (onupgradeneeded) - * - * @param db - IDBDatabase instance - */ - create(db: IDBDatabase): void { - const store = db.createObjectStore(ResourceStore.STORE_NAME, { keyPath: 'id' }); - store.createIndex('syncStatus', 'syncStatus', { unique: false }); - } -} +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/src/v2/storage/schedules/ResourceScheduleService.ts b/src/storage/schedules/ResourceScheduleService.ts similarity index 100% rename from src/v2/storage/schedules/ResourceScheduleService.ts rename to src/storage/schedules/ResourceScheduleService.ts diff --git a/src/v2/storage/schedules/ScheduleOverrideService.ts b/src/storage/schedules/ScheduleOverrideService.ts similarity index 100% rename from src/v2/storage/schedules/ScheduleOverrideService.ts rename to src/storage/schedules/ScheduleOverrideService.ts diff --git a/src/v2/storage/schedules/ScheduleOverrideStore.ts b/src/storage/schedules/ScheduleOverrideStore.ts similarity index 100% rename from src/v2/storage/schedules/ScheduleOverrideStore.ts rename to src/storage/schedules/ScheduleOverrideStore.ts diff --git a/src/v2/storage/settings/SettingsService.ts b/src/storage/settings/SettingsService.ts similarity index 100% rename from src/v2/storage/settings/SettingsService.ts rename to src/storage/settings/SettingsService.ts diff --git a/src/v2/storage/settings/SettingsStore.ts b/src/storage/settings/SettingsStore.ts similarity index 100% rename from src/v2/storage/settings/SettingsStore.ts rename to src/storage/settings/SettingsStore.ts diff --git a/src/v2/storage/teams/TeamService.ts b/src/storage/teams/TeamService.ts similarity index 100% rename from src/v2/storage/teams/TeamService.ts rename to src/storage/teams/TeamService.ts diff --git a/src/v2/storage/teams/TeamStore.ts b/src/storage/teams/TeamStore.ts similarity index 100% rename from src/v2/storage/teams/TeamStore.ts rename to src/storage/teams/TeamStore.ts diff --git a/src/v2/storage/viewconfigs/ViewConfigService.ts b/src/storage/viewconfigs/ViewConfigService.ts similarity index 100% rename from src/v2/storage/viewconfigs/ViewConfigService.ts rename to src/storage/viewconfigs/ViewConfigService.ts diff --git a/src/v2/storage/viewconfigs/ViewConfigStore.ts b/src/storage/viewconfigs/ViewConfigStore.ts similarity index 100% rename from src/v2/storage/viewconfigs/ViewConfigStore.ts rename to src/storage/viewconfigs/ViewConfigStore.ts diff --git a/src/types/AuditTypes.ts b/src/types/AuditTypes.ts index 9710bb1..3c0eb9f 100644 --- a/src/types/AuditTypes.ts +++ b/src/types/AuditTypes.ts @@ -9,30 +9,38 @@ import { ISync, EntityType } from './CalendarTypes'; * - Change history */ export interface IAuditEntry extends ISync { - /** Unique audit entry ID */ - id: string; + /** Unique audit entry ID */ + id: string; - /** Type of entity that was changed */ - entityType: EntityType; + /** Type of entity that was changed */ + entityType: EntityType; - /** ID of the entity that was changed */ - entityId: string; + /** ID of the entity that was changed */ + entityId: string; - /** Type of operation performed */ - operation: 'create' | 'update' | 'delete'; + /** Type of operation performed */ + operation: 'create' | 'update' | 'delete'; - /** User who made the change */ - userId: string; + /** User who made the change */ + userId: string; - /** Timestamp when change was made */ - timestamp: number; + /** Timestamp when change was made */ + timestamp: number; - /** Changes made (full entity for create, diff for update, { id } for delete) */ - changes: any; + /** 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; - - /** Sync status inherited from ISync */ - syncStatus: 'synced' | 'pending' | 'error'; + /** 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/src/types/BookingTypes.ts b/src/types/BookingTypes.ts deleted file mode 100644 index de8ab31..0000000 --- a/src/types/BookingTypes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ISync } from './CalendarTypes'; - -/** - * Booking entity - represents customer service bookings ONLY - * - * IMPORTANT ARCHITECTURE: - * - Booking = Customer + Services (business container) - * - Booking does NOT have start/end - timing is on CalendarEvent - * - Booking does NOT have resourceId - resources assigned per service - * - One booking can have multiple CalendarEvents (split sessions or resources) - * - Booking can exist without events (queued/pending state) - * - Vacation/break/meeting are NOT bookings - only CalendarEvents - * - * Resource Assignment: - * - Resources are assigned at SERVICE level (IBookingService.resourceId) - * - One service can be performed by one resource - * - Multiple services can be performed by different resources - * - Example: Hårvask by Student, Bundfarve by Master (same booking, 2 resources) - * - Equal-split: Two services with same type but different resources (e.g., "Bryllupsfrisure Del 1" by Karina, "Bryllupsfrisure Del 2" by Nanna) - * - * Matches backend Booking table structure - */ -export interface IBooking extends ISync { - id: string; - customerId: string; // REQUIRED - booking is always for a customer - status: BookingStatus; - createdAt: Date; - - // Services (REQUIRED - booking always has services) - services: IBookingService[]; - - // Payment - totalPrice?: number; // Can differ from sum of service prices - - // Metadata - tags?: string[]; - notes?: string; -} - -/** - * CalendarEventType - Used by ICalendarEvent.type - * Note: Only 'customer' events have associated IBooking - * Other types (vacation/break/meeting/blocked) are CalendarEvents without bookings - */ -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) - -export type BookingStatus = - | 'created' // AftaleOprettet - | 'arrived' // Ankommet - | 'paid' // Betalt - | 'noshow' // Udeblevet - | 'cancelled'; // Aflyst - -export interface IBookingService { - serviceId: string; - serviceName: string; // Denormalized for display - baseDuration: number; // Minutes (from Service.duration) - basePrice: number; // From Service.basePrice - customPrice?: number; // Override if different from basePrice - resourceId: string; // Resource who performs THIS service -} diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts index 0b8a785..c7aa21c 100644 --- a/src/types/CalendarTypes.ts +++ b/src/types/CalendarTypes.ts @@ -1,21 +1,26 @@ -// Calendar type definitions -import { CalendarEventType } from './BookingTypes'; +/** + * Calendar Type Definitions + */ -// Time period view types (how much time to display) -export type ViewPeriod = 'day' | 'week' | 'month'; - -// Type aliases -export type CalendarView = ViewPeriod; +import { IWeekSchedule } from './ScheduleTypes'; export type SyncStatus = 'synced' | 'pending' | 'error'; -/** - * EntityType - Discriminator for all syncable entities - */ -export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Audit'; +export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings' | 'ViewConfig'; /** - * ISync - Interface composition for sync status tracking + * 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 { @@ -24,17 +29,10 @@ export interface ISync { /** * IDataEntity - Wrapper for entity data with typename discriminator - * Used in queue operations and API calls to preserve type information at runtime */ export interface IDataEntity { typename: EntityType; - data: any; -} - -export interface IRenderContext { - container: HTMLElement; - startDate: Date; - endDate: Date; + data: unknown; } export interface ICalendarEvent extends ISync { @@ -43,7 +41,7 @@ export interface ICalendarEvent extends ISync { description?: string; start: Date; end: Date; - type: CalendarEventType; // Event type - only 'customer' has associated booking + type: CalendarEventType; allDay: boolean; // References (denormalized for IndexedDB performance) @@ -52,37 +50,10 @@ export interface ICalendarEvent extends ISync { customerId?: string; // Denormalized from Booking.customerId recurringId?: string; - metadata?: Record; -} - -export interface ICalendarConfig { - // Scrollbar styling - scrollbarWidth: number; // Width of scrollbar in pixels - scrollbarColor: string; // Scrollbar thumb color - scrollbarTrackColor: string; // Scrollbar track color - scrollbarHoverColor: string; // Scrollbar thumb hover color - scrollbarBorderRadius: number; // Border radius for scrollbar thumb - - // Interaction settings - allowDrag: boolean; - allowResize: boolean; - allowCreate: boolean; - - // API settings - apiEndpoint: string; - dateFormat: string; - timeFormat: string; - - // Feature flags - enableSearch: boolean; - enableTouch: boolean; - - // Event defaults - defaultEventDuration: number; // Minutes - minEventDuration: number; // Minutes - maxEventDuration: number; // Minutes + metadata?: Record; } +// EventBus types export interface IEventLogEntry { type: string; detail: unknown; @@ -102,4 +73,98 @@ export interface IEventBus { emit(eventType: string, detail?: unknown): boolean; getEventLog(eventType?: string): IEventLogEntry[]; setDebug(enabled: boolean): void; -} \ No newline at end of file +} + +// 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/src/types/ColumnDataSource.ts b/src/types/ColumnDataSource.ts deleted file mode 100644 index 6df14ea..0000000 --- a/src/types/ColumnDataSource.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { IResource } from './ResourceTypes'; -import { CalendarView, ICalendarEvent } from './CalendarTypes'; - -/** - * Column information container - * Contains both identifier and actual data for a column - */ -export interface IColumnInfo { - identifier: string; // "2024-11-13" (date mode) or "person-1" (resource mode) - data: Date | IResource; // Date for date-mode, IResource for resource-mode - events: ICalendarEvent[]; // Events for this column (pre-filtered by datasource) - groupId: string; // Group ID for spanning logic - events can only span columns with same groupId -} - -/** - * IColumnDataSource - Defines the contract for providing column data - * - * This interface abstracts away whether columns represent dates or resources, - * allowing the calendar to switch between date-based and resource-based views. - */ -export interface IColumnDataSource { - /** - * Get the list of columns to render - * @returns Array of column information - */ - getColumns(): Promise; - - /** - * Get the type of columns this datasource provides - */ - getType(): 'date' | 'resource'; - - /** - * Check if this datasource is in resource mode - */ - isResource(): boolean; - - /** - * Update the current date for column calculations - * @param date - The new current date - */ - setCurrentDate(date: Date): void; - - /** - * Get the current date - */ - getCurrentDate(): Date; - - /** - * Update the current view (day/week/month) - * @param view - The new calendar view - */ - setCurrentView(view: CalendarView): void; -} diff --git a/src/types/CustomerTypes.ts b/src/types/CustomerTypes.ts deleted file mode 100644 index b27dba3..0000000 --- a/src/types/CustomerTypes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ISync } from './CalendarTypes'; - -/** - * Customer entity - * Matches backend Customer table structure - */ -export interface ICustomer extends ISync { - id: string; - name: string; - phone: string; - email?: string; - metadata?: Record; -} diff --git a/src/types/DragDropTypes.ts b/src/types/DragDropTypes.ts deleted file mode 100644 index fe9ce7b..0000000 --- a/src/types/DragDropTypes.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Type definitions for drag and drop functionality - */ - -export interface IMousePosition { - x: number; - y: number; - clientX?: number; - clientY?: number; -} - -export interface IDragOffset { - x: number; - y: number; - offsetX?: number; - offsetY?: number; -} - -export interface IDragState { - isDragging: boolean; - draggedElement: HTMLElement | null; - draggedClone: HTMLElement | null; - eventId: string | null; - startColumn: string | null; - currentColumn: string | null; - mouseOffset: IDragOffset; -} - -export interface IDragEndPosition { - column: string; - y: number; - snappedY: number; - time?: Date; -} - -export interface IStackLinkData { - prev?: string; - next?: string; - isFirst?: boolean; - isLast?: boolean; -} - -export interface IDragEventHandlers { - handleDragStart?(originalElement: HTMLElement, eventId: string, mouseOffset: IDragOffset, column: string): void; - handleDragMove?(eventId: string, snappedY: number, column: string, mouseOffset: IDragOffset): void; - handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: string, finalY: number): void; -} \ No newline at end of file diff --git a/src/v2/types/DragTypes.ts b/src/types/DragTypes.ts similarity index 100% rename from src/v2/types/DragTypes.ts rename to src/types/DragTypes.ts diff --git a/src/types/EventId.ts b/src/types/EventId.ts deleted file mode 100644 index 114372b..0000000 --- a/src/types/EventId.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Branded type for Event IDs - * Ensures type-safety and centralizes ID normalization logic - */ -export type EventId = string & { readonly __brand: 'EventId' }; - -/** - * EventId utility functions - */ -export const EventId = { - /** - * Create EventId from string, normalizing clone- prefix - */ - from(id: string): EventId { - return id.replace('clone-', '') as EventId; - }, - - /** - * Check if raw ID is a clone - */ - isClone(id: string): boolean { - return id.startsWith('clone-'); - }, - - /** - * Create clone ID string from EventId - */ - toCloneId(id: EventId): string { - return `clone-${id}`; - } -}; diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts deleted file mode 100644 index db5468e..0000000 --- a/src/types/EventTypes.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Type definitions for calendar events and drag operations - */ - -import { IColumnBounds } from "../utils/ColumnDetectionUtils"; -import { ICalendarEvent, EntityType } from "./CalendarTypes"; - -/** - * Drag Event Payload Interfaces - * Type-safe interfaces for drag and drop events - */ - -// Common position interface -export interface IMousePosition { - x: number; - y: number; -} - -// Drag start event payload -export interface IDragStartEventPayload { - originalElement: HTMLElement; - draggedClone: HTMLElement | null; - mousePosition: IMousePosition; - mouseOffset: IMousePosition; - columnBounds: IColumnBounds | null; -} - -// Drag move event payload -export interface IDragMoveEventPayload { - originalElement: HTMLElement; - draggedClone: HTMLElement; - mousePosition: IMousePosition; - mouseOffset: IMousePosition; - columnBounds: IColumnBounds | null; - snappedY: number; -} - -// Drag end event payload -export interface IDragEndEventPayload { - originalElement: HTMLElement; - draggedClone: HTMLElement | null; - mousePosition: IMousePosition; - originalSourceColumn: IColumnBounds; // Original column where drag started - finalPosition: { - column: IColumnBounds | null; // Where drag ended - date: Date; // Always present - the date for this position - resourceId?: string; // Only in resource mode - snappedY: number; - }; - target: 'swp-day-column' | 'swp-day-header' | null; -} - -// Drag mouse enter header event payload -export interface IDragMouseEnterHeaderEventPayload { - targetColumn: IColumnBounds; - mousePosition: IMousePosition; - originalElement: HTMLElement | null; - draggedClone: HTMLElement; - calendarEvent: ICalendarEvent; - replaceClone: (newClone: HTMLElement) => void; -} - -// Drag mouse leave header event payload -export interface IDragMouseLeaveHeaderEventPayload { - targetColumn: IColumnBounds | null; - mousePosition: IMousePosition; - originalElement: HTMLElement| null; - draggedClone: HTMLElement| null; -} - -// Drag mouse enter column event payload -export interface IDragMouseEnterColumnEventPayload { - targetColumn: IColumnBounds; - mousePosition: IMousePosition; - snappedY: number; - originalElement: HTMLElement | null; - draggedClone: HTMLElement; - calendarEvent: ICalendarEvent; - replaceClone: (newClone: HTMLElement) => void; -} - -// Drag column change event payload -export interface IDragColumnChangeEventPayload { - originalElement: HTMLElement; - draggedClone: HTMLElement; - previousColumn: IColumnBounds | null; - newColumn: IColumnBounds; - mousePosition: IMousePosition; -} - -// Header ready event payload -export interface IHeaderReadyEventPayload { - headerElements: IColumnBounds[]; - -} - -// Resize end event payload -export interface IResizeEndEventPayload { - eventId: string; - element: HTMLElement; - finalHeight: number; -} - -// Navigation button clicked event payload -export interface INavButtonClickedEventPayload { - direction: 'next' | 'previous' | 'today'; - newDate: Date; -} - -// Entity saved event payload -export interface IEntitySavedPayload { - entityType: EntityType; - entityId: string; - operation: 'create' | 'update'; - changes: any; - timestamp: number; -} - -// Entity deleted event payload -export interface IEntityDeletedPayload { - entityType: EntityType; - entityId: string; - operation: 'delete'; - timestamp: number; -} - -// Audit logged event payload -export interface IAuditLoggedPayload { - auditId: string; - entityType: EntityType; - entityId: string; - operation: 'create' | 'update' | 'delete'; - timestamp: number; -} \ No newline at end of file diff --git a/src/types/ManagerTypes.ts b/src/types/ManagerTypes.ts deleted file mode 100644 index af8c5a5..0000000 --- a/src/types/ManagerTypes.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { IEventBus, ICalendarEvent, CalendarView } from './CalendarTypes'; - -/** - * Complete type definition for all managers returned by ManagerFactory - */ -export interface ICalendarManagers { - eventManager: IEventManager; - eventRenderer: IEventRenderingService; - gridManager: IGridManager; - scrollManager: IScrollManager; - navigationManager: unknown; // Avoid interface conflicts - viewManager: IViewManager; - calendarManager: ICalendarManager; - dragDropManager: unknown; // Avoid interface conflicts - allDayManager: unknown; // Avoid interface conflicts - resizeHandleManager: IResizeHandleManager; - edgeScrollManager: unknown; // Avoid interface conflicts - dragHoverManager: unknown; // Avoid interface conflicts - headerManager: unknown; // Avoid interface conflicts -} - -/** - * Base interface for managers with optional initialization and refresh - */ -interface IManager { - initialize?(): Promise | void; - refresh?(): void; -} - -export interface IEventManager extends IManager { - loadData(): Promise; - getEvents(): ICalendarEvent[]; - getEventsForPeriod(startDate: Date, endDate: Date): ICalendarEvent[]; - navigateToEvent(eventId: string): boolean; -} - -export interface IEventRenderingService extends IManager { - // EventRenderingService doesn't have a render method in current implementation -} - -export interface IGridManager extends IManager { - render(): Promise; -} - -export interface IScrollManager extends IManager { - scrollTo(scrollTop: number): void; - scrollToHour(hour: number): void; -} - -// Use a more flexible interface that matches actual implementation -export interface INavigationManager extends IManager { - [key: string]: unknown; // Allow any properties from actual implementation -} - -export interface IViewManager extends IManager { - // ViewManager doesn't have setView in current implementation - getCurrentView?(): CalendarView; -} - -export interface ICalendarManager extends IManager { - setView(view: CalendarView): void; - setCurrentDate(date: Date): void; -} - -export interface IDragDropManager extends IManager { - // DragDropManager has different interface in current implementation -} - -export interface IAllDayManager extends IManager { - [key: string]: unknown; // Allow any properties from actual implementation -} - -export interface IResizeHandleManager extends IManager { - // ResizeHandleManager handles hover effects for resize handles -} diff --git a/src/v2/types/ResizeTypes.ts b/src/types/ResizeTypes.ts similarity index 100% rename from src/v2/types/ResizeTypes.ts rename to src/types/ResizeTypes.ts diff --git a/src/types/ResourceTypes.ts b/src/types/ResourceTypes.ts deleted file mode 100644 index cdc8724..0000000 --- a/src/types/ResourceTypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ISync } from './CalendarTypes'; - -/** - * Resource entity - represents people, rooms, equipment, etc. - * Matches backend Resource table structure - */ -export interface IResource extends ISync { - id: string; // Primary key (e.g., "EMP001", "ROOM-A") - name: string; // Machine name (e.g., "karina.knudsen") - displayName: string; // Human-readable name (e.g., "Karina Knudsen") - type: ResourceType; // Resource category - avatarUrl?: string; // Avatar/icon URL (e.g., "/avatars/karina.jpg") - color?: string; // Color for visual distinction in calendar - isActive?: boolean; // Whether resource is currently active - metadata?: Record; // Flexible extension point -} - -export type ResourceType = - | 'person' // Employees, team members - | 'room' // Meeting rooms, offices - | 'equipment' // Shared equipment, tools - | 'vehicle' // Company vehicles - | 'custom'; // User-defined types diff --git a/src/v2/types/ScheduleTypes.ts b/src/types/ScheduleTypes.ts similarity index 100% rename from src/v2/types/ScheduleTypes.ts rename to src/types/ScheduleTypes.ts diff --git a/src/v2/types/SettingsTypes.ts b/src/types/SettingsTypes.ts similarity index 100% rename from src/v2/types/SettingsTypes.ts rename to src/types/SettingsTypes.ts diff --git a/src/v2/types/SwpEvent.ts b/src/types/SwpEvent.ts similarity index 100% rename from src/v2/types/SwpEvent.ts rename to src/types/SwpEvent.ts diff --git a/src/utils/AllDayLayoutEngine.ts b/src/utils/AllDayLayoutEngine.ts deleted file mode 100644 index 1afe7b9..0000000 --- a/src/utils/AllDayLayoutEngine.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ICalendarEvent } from '../types/CalendarTypes'; -import { IColumnInfo } from '../types/ColumnDataSource'; - -export interface IEventLayout { - calenderEvent: ICalendarEvent; - gridArea: string; // "row-start / col-start / row-end / col-end" - startColumn: number; - endColumn: number; - row: number; - columnSpan: number; -} - -export class AllDayLayoutEngine { - private columnIdentifiers: string[]; // Column identifiers (date or resource ID) - private columnGroups: string[]; // Group ID for each column (same index as columnIdentifiers) - private tracks: boolean[][]; - - constructor(columns: IColumnInfo[]) { - this.columnIdentifiers = columns.map(col => col.identifier); - this.columnGroups = columns.map(col => col.groupId); - this.tracks = []; - } - - /** - * Calculate layout for all events using clean day-based logic - */ - public calculateLayout(events: ICalendarEvent[]): IEventLayout[] { - - let layouts: IEventLayout[] = []; - // Reset tracks for new calculation - this.tracks = [new Array(this.columnIdentifiers.length).fill(false)]; - - // Process events in input order (no sorting) - // Events are already filtered by DataSource before reaching this engine - for (const event of events) { - const startDay = this.getEventStartDay(event); - const endDay = this.getEventEndDay(event); - - if (startDay > 0 && endDay > 0) { - const track = this.findAvailableTrack(startDay - 1, endDay - 1); // Convert to 0-based for tracks - - // Mark days as occupied - for (let day = startDay - 1; day <= endDay - 1; day++) { - this.tracks[track][day] = true; - } - - const layout: IEventLayout = { - calenderEvent: event, - gridArea: `${track + 1} / ${startDay} / ${track + 2} / ${endDay + 1}`, - startColumn: startDay, - endColumn: endDay, - row: track + 1, - columnSpan: endDay - startDay + 1 - }; - layouts.push(layout); - - } - } - - return layouts; - } - - /** - * Find available track for event spanning from startDay to endDay (0-based indices) - */ - private findAvailableTrack(startDay: number, endDay: number): number { - for (let trackIndex = 0; trackIndex < this.tracks.length; trackIndex++) { - if (this.isTrackAvailable(trackIndex, startDay, endDay)) { - return trackIndex; - } - } - - // Create new track if none available - this.tracks.push(new Array(this.columnIdentifiers.length).fill(false)); - return this.tracks.length - 1; - } - - /** - * Check if track is available for the given day range (0-based indices) - */ - private isTrackAvailable(trackIndex: number, startDay: number, endDay: number): boolean { - for (let day = startDay; day <= endDay; day++) { - if (this.tracks[trackIndex][day]) { - return false; - } - } - return true; - } - - /** - * Get start day index for event (1-based, 0 if not visible) - * Clips to group boundaries - events can only span columns with same groupId - */ - private getEventStartDay(event: ICalendarEvent): number { - const eventStartDate = this.formatDate(event.start); - const firstVisibleDate = this.columnIdentifiers[0]; - - // If event starts before visible range, clip to first visible day - const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate; - - const dayIndex = this.columnIdentifiers.indexOf(clippedStartDate); - if (dayIndex < 0) return 0; - - // Find group start boundary for this column - const groupId = this.columnGroups[dayIndex]; - const groupStart = this.getGroupStartIndex(dayIndex, groupId); - - // Return the later of event start and group start (1-based) - return Math.max(groupStart, dayIndex) + 1; - } - - /** - * Get end day index for event (1-based, 0 if not visible) - * Clips to group boundaries - events can only span columns with same groupId - */ - private getEventEndDay(event: ICalendarEvent): number { - const eventEndDate = this.formatDate(event.end); - const lastVisibleDate = this.columnIdentifiers[this.columnIdentifiers.length - 1]; - - // If event ends after visible range, clip to last visible day - const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate; - - const dayIndex = this.columnIdentifiers.indexOf(clippedEndDate); - if (dayIndex < 0) return 0; - - // Find group end boundary for this column - const groupId = this.columnGroups[dayIndex]; - const groupEnd = this.getGroupEndIndex(dayIndex, groupId); - - // Return the earlier of event end and group end (1-based) - return Math.min(groupEnd, dayIndex) + 1; - } - - /** - * Find the start index of a group (0-based) - * Scans backwards from columnIndex to find where this group starts - */ - private getGroupStartIndex(columnIndex: number, groupId: string): number { - let startIndex = columnIndex; - while (startIndex > 0 && this.columnGroups[startIndex - 1] === groupId) { - startIndex--; - } - return startIndex; - } - - /** - * Find the end index of a group (0-based) - * Scans forwards from columnIndex to find where this group ends - */ - private getGroupEndIndex(columnIndex: number, groupId: string): number { - let endIndex = columnIndex; - while (endIndex < this.columnGroups.length - 1 && this.columnGroups[endIndex + 1] === groupId) { - endIndex++; - } - return endIndex; - } - - /** - * Format date to YYYY-MM-DD string using local date - */ - private formatDate(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - } -} \ No newline at end of file diff --git a/src/utils/ColumnDetectionUtils.ts b/src/utils/ColumnDetectionUtils.ts deleted file mode 100644 index d650477..0000000 --- a/src/utils/ColumnDetectionUtils.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * ColumnDetectionUtils - Shared utility for column detection and caching - * Used by both DragDropManager and AllDayManager for consistent column detection - */ - -import { IMousePosition } from "../types/DragDropTypes"; - - -export interface IColumnBounds { - identifier: string; - left: number; - right: number; - boundingClientRect: DOMRect, - element : HTMLElement, - index: number -} - -export class ColumnDetectionUtils { - private static columnBoundsCache: IColumnBounds[] = []; - - /** - * Update column bounds cache for coordinate-based column detection - */ - public static updateColumnBoundsCache(): void { - // Reset cache - this.columnBoundsCache = []; - - // Find alle kolonner - const columns = document.querySelectorAll('swp-day-column'); - let index = 1; - // Cache hver kolonnes x-grænser - columns.forEach(column => { - const rect = column.getBoundingClientRect(); - const identifier = (column as HTMLElement).dataset.columnId; - - if (identifier) { - this.columnBoundsCache.push({ - boundingClientRect : rect, - element: column as HTMLElement, - identifier, - left: rect.left, - right: rect.right, - index: index++ - }); - } - }); - - // Sorter efter x-position (fra venstre til højre) - this.columnBoundsCache.sort((a, b) => a.left - b.left); - } - - /** - * Get column date from X coordinate using cached bounds - */ - public static getColumnBounds(position: IMousePosition): IColumnBounds | null{ - if (this.columnBoundsCache.length === 0) { - this.updateColumnBoundsCache(); - } - - // Find den kolonne hvor x-koordinaten er indenfor grænserne - let column = this.columnBoundsCache.find(col => - position.x >= col.left && position.x <= col.right - ); - if (column) - return column; - - return null; - } - - /** - * Get column bounds by identifier - */ - public static getColumnBoundsByIdentifier(identifier: string): IColumnBounds | null { - if (this.columnBoundsCache.length === 0) { - this.updateColumnBoundsCache(); - } - - // Find column that matches the identifier - let column = this.columnBoundsCache.find(col => col.identifier === identifier); - return column || null; - } - - - public static getColumns(): IColumnBounds[] { - return [...this.columnBoundsCache]; - } - public static getHeaderColumns(): IColumnBounds[] { - - let dayHeaders: IColumnBounds[] = []; - - const dayColumns = document.querySelectorAll('swp-calendar-header swp-day-header'); - let index = 1; - // Cache hver kolonnes x-grænser - dayColumns.forEach(column => { - const rect = column.getBoundingClientRect(); - const identifier = (column as HTMLElement).dataset.columnId; - - if (identifier) { - dayHeaders.push({ - boundingClientRect : rect, - element: column as HTMLElement, - identifier, - left: rect.left, - right: rect.right, - index: index++ - }); - } - }); - - // Sorter efter x-position (fra venstre til højre) - dayHeaders.sort((a, b) => a.left - b.left); - return dayHeaders; - - } -} \ No newline at end of file diff --git a/src/utils/DateService.ts b/src/utils/DateService.ts deleted file mode 100644 index c638b8c..0000000 --- a/src/utils/DateService.ts +++ /dev/null @@ -1,496 +0,0 @@ -/** - * DateService - Unified date/time service using day.js - * Handles all date operations, timezone conversions, and formatting - */ - -import dayjs, { Dayjs } from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; -import isoWeek from 'dayjs/plugin/isoWeek'; -import customParseFormat from 'dayjs/plugin/customParseFormat'; -import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; -import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; - -import { Configuration } from '../configurations/CalendarConfig'; - -// Enable day.js plugins -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(isoWeek); -dayjs.extend(customParseFormat); -dayjs.extend(isSameOrAfter); -dayjs.extend(isSameOrBefore); - -export class DateService { - private timezone: string; - - constructor(config: Configuration) { - this.timezone = config.timeFormatConfig.timezone; - } - - // ============================================ - // CORE CONVERSIONS - // ============================================ - - /** - * Convert local date to UTC ISO string - * @param localDate - Date in local timezone - * @returns ISO string in UTC (with 'Z' suffix) - */ - public toUTC(localDate: Date): string { - return dayjs.tz(localDate, this.timezone).utc().toISOString(); - } - - /** - * Convert UTC ISO string to local date - * @param utcString - ISO string in UTC - * @returns Date in local timezone - */ - public fromUTC(utcString: string): Date { - return dayjs.utc(utcString).tz(this.timezone).toDate(); - } - - // ============================================ - // FORMATTING - // ============================================ - - /** - * Format time as HH:mm or HH:mm:ss - * @param date - Date to format - * @param showSeconds - Include seconds in output - * @returns Formatted time string - */ - public formatTime(date: Date, showSeconds = false): string { - const pattern = showSeconds ? 'HH:mm:ss' : 'HH:mm'; - return dayjs(date).format(pattern); - } - - /** - * Format time range as "HH:mm - HH:mm" - * @param start - Start date - * @param end - End date - * @returns Formatted time range - */ - public formatTimeRange(start: Date, end: Date): string { - return `${this.formatTime(start)} - ${this.formatTime(end)}`; - } - - /** - * Format date and time in technical format: yyyy-MM-dd HH:mm:ss - * @param date - Date to format - * @returns Technical datetime string - */ - public formatTechnicalDateTime(date: Date): string { - return dayjs(date).format('YYYY-MM-DD HH:mm:ss'); - } - - /** - * Format date as yyyy-MM-dd - * @param date - Date to format - * @returns ISO date string - */ - public formatDate(date: Date): string { - return dayjs(date).format('YYYY-MM-DD'); - } - - /** - * Format date as "Month Year" (e.g., "January 2025") - * @param date - Date to format - * @param locale - Locale for month name (default: 'en-US') - * @returns Formatted month and year - */ - public formatMonthYear(date: Date, locale: string = 'en-US'): string { - return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); - } - - /** - * Format date as ISO string (same as formatDate for compatibility) - * @param date - Date to format - * @returns ISO date string - */ - public formatISODate(date: Date): string { - return this.formatDate(date); - } - - /** - * Format time in 12-hour format with AM/PM - * @param date - Date to format - * @returns Time string in 12-hour format (e.g., "2:30 PM") - */ - public formatTime12(date: Date): string { - return dayjs(date).format('h:mm A'); - } - - /** - * Get day name for a date - * @param date - Date to get day name for - * @param format - 'short' (e.g., 'Mon') or 'long' (e.g., 'Monday') - * @param locale - Locale for day name (default: 'da-DK') - * @returns Day name - */ - public getDayName(date: Date, format: 'short' | 'long' = 'short', locale: string = 'da-DK'): string { - const formatter = new Intl.DateTimeFormat(locale, { - weekday: format - }); - return formatter.format(date); - } - - /** - * Format a date range with customizable options - * @param start - Start date - * @param end - End date - * @param options - Formatting options - * @returns Formatted date range string - */ - public formatDateRange( - start: Date, - end: Date, - options: { - locale?: string; - month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'; - day?: 'numeric' | '2-digit'; - year?: 'numeric' | '2-digit'; - } = {} - ): string { - const { locale = 'en-US', month = 'short', day = 'numeric' } = options; - - const startYear = start.getFullYear(); - const endYear = end.getFullYear(); - - const formatter = new Intl.DateTimeFormat(locale, { - month, - day, - year: startYear !== endYear ? 'numeric' : undefined - }); - - // @ts-ignore - formatRange is available in modern browsers - if (typeof formatter.formatRange === 'function') { - // @ts-ignore - return formatter.formatRange(start, end); - } - - return `${formatter.format(start)} - ${formatter.format(end)}`; - } - - // ============================================ - // TIME CALCULATIONS - // ============================================ - - /** - * Convert time string (HH:mm or HH:mm:ss) to total minutes since midnight - * @param timeString - Time in format HH:mm or HH:mm:ss - * @returns Total minutes since midnight - */ - public timeToMinutes(timeString: string): number { - const parts = timeString.split(':').map(Number); - const hours = parts[0] || 0; - const minutes = parts[1] || 0; - return hours * 60 + minutes; - } - - /** - * Convert total minutes since midnight to time string HH:mm - * @param totalMinutes - Minutes since midnight - * @returns Time string in format HH:mm - */ - public minutesToTime(totalMinutes: number): string { - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return dayjs().hour(hours).minute(minutes).format('HH:mm'); - } - - /** - * Format time from total minutes (alias for minutesToTime) - * @param totalMinutes - Minutes since midnight - * @returns Time string in format HH:mm - */ - public formatTimeFromMinutes(totalMinutes: number): string { - return this.minutesToTime(totalMinutes); - } - - /** - * Get minutes since midnight for a given date - * @param date - Date to calculate from - * @returns Minutes since midnight - */ - public getMinutesSinceMidnight(date: Date): number { - const d = dayjs(date); - return d.hour() * 60 + d.minute(); - } - - /** - * Calculate duration in minutes between two dates - * @param start - Start date or ISO string - * @param end - End date or ISO string - * @returns Duration in minutes - */ - public getDurationMinutes(start: Date | string, end: Date | string): number { - const startDate = dayjs(start); - const endDate = dayjs(end); - return endDate.diff(startDate, 'minute'); - } - - // ============================================ - // WEEK OPERATIONS - // ============================================ - - /** - * Get start and end of week (Monday to Sunday) - * @param date - Reference date - * @returns Object with start and end dates - */ - public getWeekBounds(date: Date): { start: Date; end: Date } { - const d = dayjs(date); - return { - start: d.startOf('week').add(1, 'day').toDate(), // Monday (day.js week starts on Sunday) - end: d.endOf('week').add(1, 'day').toDate() // Sunday - }; - } - - /** - * Add weeks to a date - * @param date - Base date - * @param weeks - Number of weeks to add (can be negative) - * @returns New date - */ - public addWeeks(date: Date, weeks: number): Date { - return dayjs(date).add(weeks, 'week').toDate(); - } - - /** - * Add months to a date - * @param date - Base date - * @param months - Number of months to add (can be negative) - * @returns New date - */ - public addMonths(date: Date, months: number): Date { - return dayjs(date).add(months, 'month').toDate(); - } - - /** - * Get ISO week number (1-53) - * @param date - Date to get week number for - * @returns ISO week number - */ - public getWeekNumber(date: Date): number { - return dayjs(date).isoWeek(); - } - - /** - * Get all dates in a full week (7 days starting from given date) - * @param weekStart - Start date of the week - * @returns Array of 7 dates - */ - public getFullWeekDates(weekStart: Date): Date[] { - const dates: Date[] = []; - for (let i = 0; i < 7; i++) { - dates.push(this.addDays(weekStart, i)); - } - return dates; - } - - /** - * Get dates for work week using ISO 8601 day numbering (Monday=1, Sunday=7) - * @param weekStart - Any date in the week - * @param workDays - Array of ISO day numbers (1=Monday, 7=Sunday) - * @returns Array of dates for the specified work days - */ - public getWorkWeekDates(weekStart: Date, workDays: number[]): Date[] { - const dates: Date[] = []; - - // Get Monday of the week - const weekBounds = this.getWeekBounds(weekStart); - const mondayOfWeek = this.startOfDay(weekBounds.start); - - // Calculate dates for each work day using ISO numbering - workDays.forEach(isoDay => { - const date = new Date(mondayOfWeek); - // ISO day 1=Monday is +0 days, ISO day 7=Sunday is +6 days - const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1; - date.setDate(mondayOfWeek.getDate() + daysFromMonday); - dates.push(date); - }); - - return dates; - } - - // ============================================ - // GRID HELPERS - // ============================================ - - /** - * Create a date at a specific time (minutes since midnight) - * @param baseDate - Base date (date component) - * @param totalMinutes - Minutes since midnight - * @returns New date with specified time - */ - public createDateAtTime(baseDate: Date, totalMinutes: number): Date { - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return dayjs(baseDate).startOf('day').hour(hours).minute(minutes).toDate(); - } - - /** - * Snap date to nearest interval - * @param date - Date to snap - * @param intervalMinutes - Snap interval in minutes - * @returns Snapped date - */ - public snapToInterval(date: Date, intervalMinutes: number): Date { - const minutes = this.getMinutesSinceMidnight(date); - const snappedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes; - return this.createDateAtTime(date, snappedMinutes); - } - - // ============================================ - // UTILITY METHODS - // ============================================ - - /** - * Check if two dates are the same day - * @param date1 - First date - * @param date2 - Second date - * @returns True if same day - */ - public isSameDay(date1: Date, date2: Date): boolean { - return dayjs(date1).isSame(date2, 'day'); - } - - /** - * Get start of day - * @param date - Date - * @returns Start of day (00:00:00) - */ - public startOfDay(date: Date): Date { - return dayjs(date).startOf('day').toDate(); - } - - /** - * Get end of day - * @param date - Date - * @returns End of day (23:59:59.999) - */ - public endOfDay(date: Date): Date { - return dayjs(date).endOf('day').toDate(); - } - - /** - * Add days to a date - * @param date - Base date - * @param days - Number of days to add (can be negative) - * @returns New date - */ - public addDays(date: Date, days: number): Date { - return dayjs(date).add(days, 'day').toDate(); - } - - /** - * Add minutes to a date - * @param date - Base date - * @param minutes - Number of minutes to add (can be negative) - * @returns New date - */ - public addMinutes(date: Date, minutes: number): Date { - return dayjs(date).add(minutes, 'minute').toDate(); - } - - /** - * Parse ISO string to date - * @param isoString - ISO date string - * @returns Parsed date - */ - public parseISO(isoString: string): Date { - return dayjs(isoString).toDate(); - } - - /** - * Check if date is valid - * @param date - Date to check - * @returns True if valid - */ - public isValid(date: Date): boolean { - return dayjs(date).isValid(); - } - - /** - * Calculate difference in calendar days between two dates - * @param date1 - First date - * @param date2 - Second date - * @returns Number of calendar days between dates (can be negative) - */ - public differenceInCalendarDays(date1: Date, date2: Date): number { - const d1 = dayjs(date1).startOf('day'); - const d2 = dayjs(date2).startOf('day'); - return d1.diff(d2, 'day'); - } - - /** - * Validate date range (start must be before or equal to end) - * @param start - Start date - * @param end - End date - * @returns True if valid range - */ - public isValidRange(start: Date, end: Date): boolean { - if (!this.isValid(start) || !this.isValid(end)) { - return false; - } - return start.getTime() <= end.getTime(); - } - - /** - * Check if date is within reasonable bounds (1900-2100) - * @param date - Date to check - * @returns True if within bounds - */ - public isWithinBounds(date: Date): boolean { - if (!this.isValid(date)) { - return false; - } - const year = date.getFullYear(); - return year >= 1900 && year <= 2100; - } - - /** - * Validate date with comprehensive checks - * @param date - Date to validate - * @param options - Validation options - * @returns Validation result with error message - */ - public validateDate( - date: Date, - options: { - requireFuture?: boolean; - requirePast?: boolean; - minDate?: Date; - maxDate?: Date; - } = {} - ): { valid: boolean; error?: string } { - if (!this.isValid(date)) { - return { valid: false, error: 'Invalid date' }; - } - - if (!this.isWithinBounds(date)) { - return { valid: false, error: 'Date out of bounds (1900-2100)' }; - } - - const now = new Date(); - - if (options.requireFuture && date <= now) { - return { valid: false, error: 'Date must be in the future' }; - } - - if (options.requirePast && date >= now) { - return { valid: false, error: 'Date must be in the past' }; - } - - if (options.minDate && date < options.minDate) { - return { valid: false, error: `Date must be after ${this.formatDate(options.minDate)}` }; - } - - if (options.maxDate && date > options.maxDate) { - return { valid: false, error: `Date must be before ${this.formatDate(options.maxDate)}` }; - } - - return { valid: true }; - } -} diff --git a/src/utils/PositionUtils.ts b/src/utils/PositionUtils.ts index 3ec70dc..5c99e4b 100644 --- a/src/utils/PositionUtils.ts +++ b/src/utils/PositionUtils.ts @@ -1,263 +1,55 @@ -import { Configuration } from '../configurations/CalendarConfig'; -import { IColumnBounds } from './ColumnDetectionUtils'; -import { DateService } from './DateService'; -import { TimeFormatter } from './TimeFormatter'; +/** + * 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 +} /** - * PositionUtils - Positioning utilities with dependency injection - * Focuses on pixel/position calculations while delegating date operations - * - * Note: Uses DateService with date-fns for all date/time operations + * Calculate pixel position for an event based on its times */ -export class PositionUtils { - private dateService: DateService; - private config: Configuration; +export function calculateEventPosition( + start: Date, + end: Date, + config: IGridConfig +): EventPosition { + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); - constructor(dateService: DateService, config: Configuration) { - this.dateService = dateService; - this.config = config; - } + const dayStartMinutes = config.dayStartHour * 60; + const minuteHeight = config.hourHeight / 60; - /** - * Convert minutes to pixels - */ - public minutesToPixels(minutes: number): number { - const gridSettings = this.config.gridSettings; - const pixelsPerHour = gridSettings.hourHeight; - return (minutes / 60) * pixelsPerHour; - } + const top = (startMinutes - dayStartMinutes) * minuteHeight; + const height = (endMinutes - startMinutes) * minuteHeight; - /** - * Convert pixels to minutes - */ - public pixelsToMinutes(pixels: number): number { - const gridSettings = this.config.gridSettings; - const pixelsPerHour = gridSettings.hourHeight; - return (pixels / pixelsPerHour) * 60; - } + return { top, height }; +} - /** - * Convert time (HH:MM) to pixels from day start using DateService - */ - public timeToPixels(timeString: string): number { - const totalMinutes = this.dateService.timeToMinutes(timeString); - const gridSettings = this.config.gridSettings; - const dayStartMinutes = gridSettings.dayStartHour * 60; - const minutesFromDayStart = totalMinutes - dayStartMinutes; +/** + * Convert minutes to pixels + */ +export function minutesToPixels(minutes: number, config: IGridConfig): number { + return (minutes / 60) * config.hourHeight; +} - return this.minutesToPixels(minutesFromDayStart); - } +/** + * Convert pixels to minutes + */ +export function pixelsToMinutes(pixels: number, config: IGridConfig): number { + return (pixels / config.hourHeight) * 60; +} - /** - * Convert Date object to pixels from day start using DateService - */ - public dateToPixels(date: Date): number { - const totalMinutes = this.dateService.getMinutesSinceMidnight(date); - const gridSettings = this.config.gridSettings; - const dayStartMinutes = gridSettings.dayStartHour * 60; - const minutesFromDayStart = totalMinutes - dayStartMinutes; - - return this.minutesToPixels(minutesFromDayStart); - } - - /** - * Convert pixels to time using DateService - */ - public pixelsToTime(pixels: number): string { - const minutes = this.pixelsToMinutes(pixels); - const gridSettings = this.config.gridSettings; - const dayStartMinutes = gridSettings.dayStartHour * 60; - const totalMinutes = dayStartMinutes + minutes; - - return this.dateService.minutesToTime(totalMinutes); - } - - /** - * Beregn event position og størrelse - */ - public calculateEventPosition(startTime: string | Date, endTime: string | Date): { - top: number; - height: number; - duration: number; - } { - let startPixels: number; - let endPixels: number; - - if (typeof startTime === 'string') { - startPixels = this.timeToPixels(startTime); - } else { - startPixels = this.dateToPixels(startTime); - } - - if (typeof endTime === 'string') { - endPixels = this.timeToPixels(endTime); - } else { - endPixels = this.dateToPixels(endTime); - } - - const height = Math.max(endPixels - startPixels, this.getMinimumEventHeight()); - const duration = this.pixelsToMinutes(height); - - return { - top: startPixels, - height, - duration - }; - } - - /** - * Snap position til grid interval - */ - public snapToGrid(pixels: number): number { - const gridSettings = this.config.gridSettings; - const snapInterval = gridSettings.snapInterval; - const snapPixels = this.minutesToPixels(snapInterval); - - return Math.round(pixels / snapPixels) * snapPixels; - } - - /** - * Snap time to interval using DateService - */ - public snapTimeToInterval(timeString: string): string { - const totalMinutes = this.dateService.timeToMinutes(timeString); - const gridSettings = this.config.gridSettings; - const snapInterval = gridSettings.snapInterval; - - const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval; - return this.dateService.minutesToTime(snappedMinutes); - } - - /** - * Beregn kolonne position for overlappende events - */ - public calculateColumnPosition(eventIndex: number, totalColumns: number, containerWidth: number): { - left: number; - width: number; - } { - const columnWidth = containerWidth / totalColumns; - const left = eventIndex * columnWidth; - - // Lav lidt margin mellem kolonnerne - const margin = 2; - const adjustedWidth = columnWidth - margin; - - return { - left: left + (margin / 2), - width: Math.max(adjustedWidth, 50) // Minimum width - }; - } - - /** - * Check om to events overlapper i tid - */ - public eventsOverlap( - start1: string | Date, - end1: string | Date, - start2: string | Date, - end2: string | Date - ): boolean { - const pos1 = this.calculateEventPosition(start1, end1); - const pos2 = this.calculateEventPosition(start2, end2); - - const event1End = pos1.top + pos1.height; - const event2End = pos2.top + pos2.height; - - return !(event1End <= pos2.top || event2End <= pos1.top); - } - - /** - * Beregn Y position fra mouse/touch koordinat - */ - public getPositionFromCoordinate(clientY: number, column: IColumnBounds): number { - - const relativeY = clientY - column.boundingClientRect.top; - - // Snap til grid - return this.snapToGrid(relativeY); - } - - /** - * Valider at tid er inden for arbejdstimer - */ - public isWithinWorkHours(timeString: string): boolean { - const [hours] = timeString.split(':').map(Number); - const gridSettings = this.config.gridSettings; - return hours >= gridSettings.workStartHour && hours < gridSettings.workEndHour; - } - - /** - * Valider at tid er inden for dag grænser - */ - public isWithinDayBounds(timeString: string): boolean { - const [hours] = timeString.split(':').map(Number); - const gridSettings = this.config.gridSettings; - return hours >= gridSettings.dayStartHour && hours < gridSettings.dayEndHour; - } - - /** - * Hent minimum event højde i pixels - */ - public getMinimumEventHeight(): number { - // Minimum 15 minutter - return this.minutesToPixels(15); - } - - /** - * Hent maksimum event højde i pixels (hele dagen) - */ - public getMaximumEventHeight(): number { - const gridSettings = this.config.gridSettings; - const dayDurationHours = gridSettings.dayEndHour - gridSettings.dayStartHour; - return dayDurationHours * gridSettings.hourHeight; - } - - /** - * Beregn total kalender højde - */ - public getTotalCalendarHeight(): number { - return this.getMaximumEventHeight(); - } - - /** - * Convert ISO datetime to time string with UTC-to-local conversion - */ - public isoToTimeString(isoString: string): string { - const date = new Date(isoString); - return TimeFormatter.formatTime(date); - } - - /** - * Convert time string to ISO datetime using DateService with timezone handling - */ - public timeStringToIso(timeString: string, date: Date = new Date()): string { - const totalMinutes = this.dateService.timeToMinutes(timeString); - const newDate = this.dateService.createDateAtTime(date, totalMinutes); - return this.dateService.toUTC(newDate); - } - - /** - * Calculate event duration using DateService - */ - public calculateDuration(startTime: string | Date, endTime: string | Date): number { - return this.dateService.getDurationMinutes(startTime, endTime); - } - - /** - * Format duration to readable text (Danish) - */ - public formatDuration(minutes: number): string { - if (minutes < 60) { - return `${minutes} min`; - } - - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - - if (remainingMinutes === 0) { - return `${hours} time${hours !== 1 ? 'r' : ''}`; - } - - return `${hours}t ${remainingMinutes}m`; - } -} \ No newline at end of file +/** + * 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/src/utils/TimeFormatter.ts b/src/utils/TimeFormatter.ts deleted file mode 100644 index 3b84e08..0000000 --- a/src/utils/TimeFormatter.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * TimeFormatter - Centralized time formatting with timezone support - * Now uses DateService internally for all date/time operations - * - * Handles conversion from UTC/Zulu time to configured timezone (default: Europe/Copenhagen) - * Supports both 12-hour and 24-hour format configuration - * - * All events in the system are stored in UTC and must be converted to local timezone - */ - -import { DateService } from './DateService'; -import { ITimeFormatConfig } from '../configurations/TimeFormatConfig'; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; - -// Enable day.js plugins for timezone formatting -dayjs.extend(utc); -dayjs.extend(timezone); - -export class TimeFormatter { - private static settings: ITimeFormatConfig | null = null; - - // DateService will be initialized lazily to avoid circular dependency with CalendarConfig - private static dateService: DateService | null = null; - - private static getDateService(): DateService { - if (!TimeFormatter.dateService) { - if (!TimeFormatter.settings) { - throw new Error('TimeFormatter must be configured before use. Call TimeFormatter.configure() first.'); - } - // Create a minimal config object for DateService - const config = { - timeFormatConfig: { - timezone: TimeFormatter.settings.timezone - } - }; - TimeFormatter.dateService = new DateService(config as any); - } - return TimeFormatter.dateService; - } - - /** - * Configure time formatting settings - * Must be called before using TimeFormatter - */ - static configure(settings: ITimeFormatConfig): void { - TimeFormatter.settings = settings; - // Reset DateService to pick up new timezone - TimeFormatter.dateService = null; - } - - /** - * Convert UTC date to configured timezone (internal helper) - * @param utcDate - Date in UTC (or ISO string) - * @returns Date object adjusted to configured timezone - */ - private static convertToLocalTime(utcDate: Date | string): Date { - if (typeof utcDate === 'string') { - return TimeFormatter.getDateService().fromUTC(utcDate); - } - - // If it's already a Date object, convert to UTC string first, then back to local - const utcString = utcDate.toISOString(); - return TimeFormatter.getDateService().fromUTC(utcString); - } - - /** - * Format time in 24-hour format using DateService (internal helper) - * @param date - Date to format - * @returns Formatted time string (e.g., "09:00") - */ - private static format24Hour(date: Date): string { - if (!TimeFormatter.settings) { - throw new Error('TimeFormatter must be configured before use. Call TimeFormatter.configure() first.'); - } - - // Use day.js directly to format with timezone awareness - const pattern = TimeFormatter.settings.showSeconds ? 'HH:mm:ss' : 'HH:mm'; - return dayjs.utc(date).tz(TimeFormatter.settings.timezone).format(pattern); - } - - /** - * Format time according to current configuration - * @param date - Date to format - * @returns Formatted time string - */ - static formatTime(date: Date): string { - // Always use 24-hour format (12-hour support removed as unused) - return TimeFormatter.format24Hour(date); - } - - /** - * Format time range (start - end) using DateService - * @param startDate - Start date - * @param endDate - End date - * @returns Formatted time range string (e.g., "09:00 - 10:30") - */ - static formatTimeRange(startDate: Date, endDate: Date): string { - const localStart = TimeFormatter.convertToLocalTime(startDate); - const localEnd = TimeFormatter.convertToLocalTime(endDate); - return TimeFormatter.getDateService().formatTimeRange(localStart, localEnd); - } -} \ No newline at end of file diff --git a/src/utils/URLManager.ts b/src/utils/URLManager.ts deleted file mode 100644 index 26750de..0000000 --- a/src/utils/URLManager.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { EventBus } from '../core/EventBus'; -import { IEventBus } from '../types/CalendarTypes'; - -/** - * URLManager handles URL query parameter parsing and deep linking functionality - * Follows event-driven architecture with no global state - */ -export class URLManager { - private eventBus: IEventBus; - - constructor(eventBus: IEventBus) { - this.eventBus = eventBus; - } - - /** - * Parse eventId from URL query parameters - * @returns eventId string or null if not found - */ - public parseEventIdFromURL(): string | null { - try { - const urlParams = new URLSearchParams(window.location.search); - const eventId = urlParams.get('eventId'); - - if (eventId && eventId.trim() !== '') { - return eventId.trim(); - } - - return null; - } catch (error) { - console.warn('URLManager: Failed to parse URL parameters:', error); - return null; - } - } - - /** - * Get all query parameters as an object - * @returns object with all query parameters - */ - public getAllQueryParams(): Record { - try { - const urlParams = new URLSearchParams(window.location.search); - const params: Record = {}; - - for (const [key, value] of urlParams.entries()) { - params[key] = value; - } - - return params; - } catch (error) { - console.warn('URLManager: Failed to parse URL parameters:', error); - return {}; - } - } - - /** - * Update URL without page reload (for future use) - * @param params object with parameters to update - */ - public updateURL(params: Record): void { - try { - const url = new URL(window.location.href); - - // Update or remove parameters - Object.entries(params).forEach(([key, value]) => { - if (value === null) { - url.searchParams.delete(key); - } else { - url.searchParams.set(key, value); - } - }); - - // Update URL without page reload - window.history.replaceState({}, '', url.toString()); - } catch (error) { - console.warn('URLManager: Failed to update URL:', error); - } - } - - /** - * Check if current URL has any query parameters - * @returns true if URL has query parameters - */ - public hasQueryParams(): boolean { - return window.location.search.length > 0; - } -} \ No newline at end of file diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts deleted file mode 100644 index 9c25100..0000000 --- a/src/v2/constants/CoreEvents.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * CoreEvents - Consolidated essential events for the calendar V2 - */ -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/src/v2/core/EventBus.ts b/src/v2/core/EventBus.ts deleted file mode 100644 index 469a73e..0000000 --- a/src/v2/core/EventBus.ts +++ /dev/null @@ -1,174 +0,0 @@ -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/src/v2/demo/index.ts b/src/v2/demo/index.ts deleted file mode 100644 index afbabf3..0000000 --- a/src/v2/demo/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createV2Container } from '../V2CompositionRoot'; -import { DemoApp } from './DemoApp'; - -const container = createV2Container(); -container.resolveType().init().catch(console.error); diff --git a/src/v2/entry.ts b/src/v2/entry.ts deleted file mode 100644 index 566cb54..0000000 --- a/src/v2/entry.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * V2 Calendar - Standalone Entry Point - * No dependencies on existing calendar system - */ - -// Re-export everything from index -export * from './index'; diff --git a/src/v2/index.ts b/src/v2/index.ts deleted file mode 100644 index 6e390a0..0000000 --- a/src/v2/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Core exports -export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; -export { IRenderer as Renderer, IRenderContext as RenderContext } from './core/IGroupingRenderer'; -export { IGroupingStore } from './core/IGroupingStore'; -export { CalendarOrchestrator } from './core/CalendarOrchestrator'; -export { NavigationAnimator } from './core/NavigationAnimator'; -export { buildPipeline, Pipeline } from './core/RenderBuilder'; - -// Feature exports -export { DateRenderer } from './features/date'; -export { DateService } from './core/DateService'; -export { ITimeFormatConfig } from './core/ITimeFormatConfig'; -export { EventRenderer } from './features/event'; -export { ResourceRenderer } from './features/resource'; -export { TeamRenderer } from './features/team'; -export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; - diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts deleted file mode 100644 index 4bf50b9..0000000 --- a/src/v2/managers/DragDropManager.ts +++ /dev/null @@ -1,581 +0,0 @@ -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/src/v2/managers/EdgeScrollManager.ts b/src/v2/managers/EdgeScrollManager.ts deleted file mode 100644 index d1b5584..0000000 --- a/src/v2/managers/EdgeScrollManager.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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/src/v2/repositories/IApiRepository.ts b/src/v2/repositories/IApiRepository.ts deleted file mode 100644 index a50791f..0000000 --- a/src/v2/repositories/IApiRepository.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/src/v2/repositories/MockAuditRepository.ts b/src/v2/repositories/MockAuditRepository.ts deleted file mode 100644 index 211fc4f..0000000 --- a/src/v2/repositories/MockAuditRepository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { IApiRepository } from './IApiRepository'; -import { IAuditEntry } from '../types/AuditTypes'; -import { EntityType } from '../types/CalendarTypes'; - -/** - * MockAuditRepository - Mock API repository for audit entries - * - * In production, this would send audit entries to the backend. - * For development/testing, it just logs the operations. - */ -export class MockAuditRepository implements IApiRepository { - readonly entityType: EntityType = 'Audit'; - - async sendCreate(entity: IAuditEntry): Promise { - // Simulate API call delay - await new Promise(resolve => setTimeout(resolve, 100)); - - console.log('MockAuditRepository: Audit entry synced to backend:', { - id: entity.id, - entityType: entity.entityType, - entityId: entity.entityId, - operation: entity.operation, - timestamp: new Date(entity.timestamp).toISOString() - }); - - return entity; - } - - async sendUpdate(_id: string, _entity: IAuditEntry): Promise { - // Audit entries are immutable - updates should not happen - throw new Error('Audit entries cannot be updated'); - } - - async sendDelete(_id: string): Promise { - // Audit entries should never be deleted - throw new Error('Audit entries cannot be deleted'); - } - - async fetchAll(): Promise { - // For now, return empty array - audit entries are local-first - // In production, this could fetch audit history from backend - return []; - } - - async fetchById(_id: string): Promise { - // For now, return null - audit entries are local-first - return null; - } -} diff --git a/src/v2/repositories/MockBookingRepository.ts b/src/v2/repositories/MockBookingRepository.ts deleted file mode 100644 index 449d5a3..0000000 --- a/src/v2/repositories/MockBookingRepository.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawBookingData { - id: string; - customerId: string; - status: string; - createdAt: string | Date; - services: RawBookingService[]; - totalPrice?: number; - tags?: string[]; - notes?: string; - [key: string]: unknown; -} - -interface RawBookingService { - serviceId: string; - serviceName: string; - baseDuration: number; - basePrice: number; - customPrice?: number; - resourceId: string; -} - -/** - * MockBookingRepository - Loads booking data from local JSON file - */ -export class MockBookingRepository implements IApiRepository { - public readonly entityType: EntityType = 'Booking'; - private readonly dataUrl = 'data/mock-bookings.json'; - - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`); - } - - const rawData: RawBookingData[] = await response.json(); - return this.processBookingData(rawData); - } catch (error) { - console.error('Failed to load booking data:', error); - throw error; - } - } - - public async sendCreate(_booking: IBooking): Promise { - throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.'); - } - - public async sendUpdate(_id: string, _updates: Partial): Promise { - throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.'); - } - - public async sendDelete(_id: string): Promise { - throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.'); - } - - private processBookingData(data: RawBookingData[]): IBooking[] { - return data.map((booking): IBooking => ({ - id: booking.id, - customerId: booking.customerId, - status: booking.status as BookingStatus, - createdAt: new Date(booking.createdAt), - services: booking.services as IBookingService[], - totalPrice: booking.totalPrice, - tags: booking.tags, - notes: booking.notes, - syncStatus: 'synced' as const - })); - } -} diff --git a/src/v2/repositories/MockCustomerRepository.ts b/src/v2/repositories/MockCustomerRepository.ts deleted file mode 100644 index 4bf079c..0000000 --- a/src/v2/repositories/MockCustomerRepository.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ICustomer, EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawCustomerData { - id: string; - name: string; - phone: string; - email?: string; - metadata?: Record; - [key: string]: unknown; -} - -/** - * MockCustomerRepository - Loads customer data from local JSON file - */ -export class MockCustomerRepository implements IApiRepository { - public readonly entityType: EntityType = 'Customer'; - private readonly dataUrl = 'data/mock-customers.json'; - - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`); - } - - const rawData: RawCustomerData[] = await response.json(); - return this.processCustomerData(rawData); - } catch (error) { - console.error('Failed to load customer data:', error); - throw error; - } - } - - public async sendCreate(_customer: ICustomer): Promise { - throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.'); - } - - public async sendUpdate(_id: string, _updates: Partial): Promise { - throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.'); - } - - public async sendDelete(_id: string): Promise { - throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.'); - } - - private processCustomerData(data: RawCustomerData[]): ICustomer[] { - return data.map((customer): ICustomer => ({ - id: customer.id, - name: customer.name, - phone: customer.phone, - email: customer.email, - metadata: customer.metadata, - syncStatus: 'synced' as const - })); - } -} diff --git a/src/v2/repositories/MockEventRepository.ts b/src/v2/repositories/MockEventRepository.ts deleted file mode 100644 index 939569b..0000000 --- a/src/v2/repositories/MockEventRepository.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ICalendarEvent, EntityType, CalendarEventType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; - -interface RawEventData { - id: string; - title: string; - start: string | Date; - end: string | Date; - type: string; - allDay?: boolean; - bookingId?: string; - resourceId?: string; - customerId?: string; - description?: string; - recurringId?: string; - metadata?: Record; - [key: string]: unknown; -} - -/** - * MockEventRepository - Loads event data from local JSON file - * - * Used for development and testing. Only fetchAll() is implemented. - */ -export class MockEventRepository implements IApiRepository { - public readonly entityType: EntityType = 'Event'; - private readonly dataUrl = 'data/mock-events.json'; - - /** - * Fetch all events from mock JSON file - */ - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock events: ${response.status} ${response.statusText}`); - } - - const rawData: RawEventData[] = await response.json(); - return this.processCalendarData(rawData); - } catch (error) { - console.error('Failed to load event data:', error); - throw error; - } - } - - public async sendCreate(_event: ICalendarEvent): Promise { - throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.'); - } - - public async sendUpdate(_id: string, _updates: Partial): Promise { - throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.'); - } - - public async sendDelete(_id: string): Promise { - throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.'); - } - - private processCalendarData(data: RawEventData[]): ICalendarEvent[] { - return data.map((event): ICalendarEvent => { - // Validate customer event constraints - if (event.type === 'customer') { - if (!event.bookingId) console.warn(`Customer event ${event.id} missing bookingId`); - if (!event.resourceId) console.warn(`Customer event ${event.id} missing resourceId`); - if (!event.customerId) console.warn(`Customer event ${event.id} missing customerId`); - } - - return { - id: event.id, - title: event.title, - description: event.description, - start: new Date(event.start), - end: new Date(event.end), - type: event.type as CalendarEventType, - allDay: event.allDay || false, - bookingId: event.bookingId, - resourceId: event.resourceId, - customerId: event.customerId, - recurringId: event.recurringId, - metadata: event.metadata, - syncStatus: 'synced' as const - }; - }); - } -} diff --git a/src/v2/repositories/MockResourceRepository.ts b/src/v2/repositories/MockResourceRepository.ts deleted file mode 100644 index 0f2217f..0000000 --- a/src/v2/repositories/MockResourceRepository.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { IResource, ResourceType, EntityType } from '../types/CalendarTypes'; -import { IApiRepository } from './IApiRepository'; -import { IWeekSchedule } from '../types/ScheduleTypes'; - -interface RawResourceData { - id: string; - name: string; - displayName: string; - type: string; - avatarUrl?: string; - color?: string; - isActive?: boolean; - defaultSchedule?: IWeekSchedule; - metadata?: Record; -} - -/** - * MockResourceRepository - Loads resource data from local JSON file - */ -export class MockResourceRepository implements IApiRepository { - public readonly entityType: EntityType = 'Resource'; - private readonly dataUrl = 'data/mock-resources.json'; - - public async fetchAll(): Promise { - try { - const response = await fetch(this.dataUrl); - - if (!response.ok) { - throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`); - } - - const rawData: RawResourceData[] = await response.json(); - return this.processResourceData(rawData); - } catch (error) { - console.error('Failed to load resource data:', error); - throw error; - } - } - - public async sendCreate(_resource: IResource): Promise { - throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.'); - } - - public async sendUpdate(_id: string, _updates: Partial): Promise { - throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.'); - } - - public async sendDelete(_id: string): Promise { - throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.'); - } - - private processResourceData(data: RawResourceData[]): IResource[] { - return data.map((resource): IResource => ({ - id: resource.id, - name: resource.name, - displayName: resource.displayName, - type: resource.type as ResourceType, - avatarUrl: resource.avatarUrl, - color: resource.color, - isActive: resource.isActive, - defaultSchedule: resource.defaultSchedule, - metadata: resource.metadata, - syncStatus: 'synced' as const - })); - } -} diff --git a/src/v2/storage/BaseEntityService.ts b/src/v2/storage/BaseEntityService.ts deleted file mode 100644 index ed8d3a1..0000000 --- a/src/v2/storage/BaseEntityService.ts +++ /dev/null @@ -1,181 +0,0 @@ -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/src/v2/storage/IEntityService.ts b/src/v2/storage/IEntityService.ts deleted file mode 100644 index 800ea62..0000000 --- a/src/v2/storage/IEntityService.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/src/v2/storage/IStore.ts b/src/v2/storage/IStore.ts deleted file mode 100644 index 91ac873..0000000 --- a/src/v2/storage/IStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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/src/v2/storage/IndexedDBContext.ts b/src/v2/storage/IndexedDBContext.ts deleted file mode 100644 index a0e80f0..0000000 --- a/src/v2/storage/IndexedDBContext.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { IStore } from './IStore'; - -/** - * 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 static readonly DB_NAME = 'CalendarV2DB'; - private static readonly DB_VERSION = 4; - - private db: IDBDatabase | null = null; - private initialized: boolean = false; - private stores: IStore[]; - - constructor(stores: IStore[]) { - this.stores = stores; - } - - /** - * Initialize and open the database - */ - async initialize(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(IndexedDBContext.DB_NAME, IndexedDBContext.DB_VERSION); - - 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(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(IndexedDBContext.DB_NAME); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(new Error(`Failed to delete database: ${request.error}`)); - }); - } -} diff --git a/src/v2/storage/SyncPlugin.ts b/src/v2/storage/SyncPlugin.ts deleted file mode 100644 index 7774da6..0000000 --- a/src/v2/storage/SyncPlugin.ts +++ /dev/null @@ -1,64 +0,0 @@ -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/src/v2/storage/audit/AuditService.ts b/src/v2/storage/audit/AuditService.ts deleted file mode 100644 index bf69664..0000000 --- a/src/v2/storage/audit/AuditService.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../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/src/v2/storage/audit/AuditStore.ts b/src/v2/storage/audit/AuditStore.ts deleted file mode 100644 index 00caf8b..0000000 --- a/src/v2/storage/audit/AuditStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IStore } from '../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/src/v2/storage/bookings/BookingService.ts b/src/v2/storage/bookings/BookingService.ts deleted file mode 100644 index ae7a9f9..0000000 --- a/src/v2/storage/bookings/BookingService.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { IBooking, EntityType, IEventBus, BookingStatus } from '../../types/CalendarTypes'; -import { BookingStore } from './BookingStore'; -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../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/src/v2/storage/bookings/BookingStore.ts b/src/v2/storage/bookings/BookingStore.ts deleted file mode 100644 index c412f85..0000000 --- a/src/v2/storage/bookings/BookingStore.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IStore } from '../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/src/v2/storage/customers/CustomerService.ts b/src/v2/storage/customers/CustomerService.ts deleted file mode 100644 index 6cfd888..0000000 --- a/src/v2/storage/customers/CustomerService.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ICustomer, EntityType, IEventBus } from '../../types/CalendarTypes'; -import { CustomerStore } from './CustomerStore'; -import { BaseEntityService } from '../BaseEntityService'; -import { IndexedDBContext } from '../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/src/v2/storage/customers/CustomerStore.ts b/src/v2/storage/customers/CustomerStore.ts deleted file mode 100644 index 9afcf9e..0000000 --- a/src/v2/storage/customers/CustomerStore.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IStore } from '../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/src/v2/storage/events/EventSerialization.ts b/src/v2/storage/events/EventSerialization.ts deleted file mode 100644 index 583fa79..0000000 --- a/src/v2/storage/events/EventSerialization.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/src/v2/storage/events/EventService.ts b/src/v2/storage/events/EventService.ts deleted file mode 100644 index 0ccd5a5..0000000 --- a/src/v2/storage/events/EventService.ts +++ /dev/null @@ -1,84 +0,0 @@ -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/src/v2/storage/events/EventStore.ts b/src/v2/storage/events/EventStore.ts deleted file mode 100644 index 21c7be0..0000000 --- a/src/v2/storage/events/EventStore.ts +++ /dev/null @@ -1,37 +0,0 @@ -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/src/v2/storage/resources/ResourceService.ts b/src/v2/storage/resources/ResourceService.ts deleted file mode 100644 index 769210c..0000000 --- a/src/v2/storage/resources/ResourceService.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/src/v2/storage/resources/ResourceStore.ts b/src/v2/storage/resources/ResourceStore.ts deleted file mode 100644 index 38e39b6..0000000 --- a/src/v2/storage/resources/ResourceStore.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/src/v2/types/AuditTypes.ts b/src/v2/types/AuditTypes.ts deleted file mode 100644 index 3c0eb9f..0000000 --- a/src/v2/types/AuditTypes.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/src/v2/types/CalendarTypes.ts b/src/v2/types/CalendarTypes.ts deleted file mode 100644 index 8bcbb41..0000000 --- a/src/v2/types/CalendarTypes.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Calendar V2 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/src/v2/utils/PositionUtils.ts b/src/v2/utils/PositionUtils.ts deleted file mode 100644 index 5c99e4b..0000000 --- a/src/v2/utils/PositionUtils.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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/src/v2/workers/DataSeeder.ts b/src/v2/workers/DataSeeder.ts deleted file mode 100644 index b05bf40..0000000 --- a/src/v2/workers/DataSeeder.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { IApiRepository } from '../repositories/IApiRepository'; -import { IEntityService } from '../storage/IEntityService'; -import { ISync } from '../types/CalendarTypes'; - -/** - * DataSeeder - Orchestrates initial data loading from repositories into IndexedDB - * - * ARCHITECTURE: - * - Repository (Mock/Api): Fetches data from source (JSON file or backend API) - * - DataSeeder (this class): Orchestrates fetch + save operations - * - Service (EventService, etc.): Saves data to IndexedDB - * - * POLYMORPHIC DESIGN: - * - Uses arrays of IEntityService[] and IApiRepository[] - * - Matches services with repositories using entityType property - * - Open/Closed Principle: Adding new entity requires no code changes here - */ -export class DataSeeder { - constructor( - private services: IEntityService[], - private repositories: IApiRepository[] - ) {} - - /** - * Seed all entity stores if they are empty - */ - async seedIfEmpty(): Promise { - console.log('[DataSeeder] Checking if database needs seeding...'); - - try { - for (const service of this.services) { - const repository = this.repositories.find(repo => repo.entityType === service.entityType); - - if (!repository) { - console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`); - continue; - } - - await this.seedEntity(service.entityType, service, repository); - } - - console.log('[DataSeeder] Seeding complete'); - } catch (error) { - console.error('[DataSeeder] Seeding failed:', error); - throw error; - } - } - - private async seedEntity( - entityType: string, - service: IEntityService, - repository: IApiRepository - ): Promise { - const existing = await service.getAll(); - - if (existing.length > 0) { - console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`); - return; - } - - console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`); - - const data = await repository.fetchAll(); - - console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`); - - for (const entity of data) { - await service.save(entity, true); // silent = true to skip audit logging - } - - console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`); - } -} diff --git a/src/workers/DataSeeder.ts b/src/workers/DataSeeder.ts index 01795cc..b05bf40 100644 --- a/src/workers/DataSeeder.ts +++ b/src/workers/DataSeeder.ts @@ -1,103 +1,73 @@ -import { IApiRepository } from '../repositories/IApiRepository'; -import { IEntityService } from '../storage/IEntityService'; - -/** - * DataSeeder - Orchestrates initial data loading from repositories into IndexedDB - * - * ARCHITECTURE: - * - Repository (Mock/Api): Fetches data from source (JSON file or backend API) - * - DataSeeder (this class): Orchestrates fetch + save operations - * - Service (EventService, etc.): Saves data to IndexedDB - * - * SEPARATION OF CONCERNS: - * - Repository does NOT know about IndexedDB or storage - * - Service does NOT know about where data comes from - * - DataSeeder connects them together - * - * POLYMORPHIC DESIGN: - * - Uses arrays of IEntityService[] and IApiRepository[] - * - Matches services with repositories using entityType property - * - Open/Closed Principle: Adding new entity requires no code changes here - * - * USAGE: - * Called once during app initialization in index.ts: - * 1. IndexedDBService.initialize() - open database - * 2. dataSeeder.seedIfEmpty() - load initial data if needed - * 3. CalendarManager.initialize() - start calendar - * - * NOTE: This is for INITIAL SEEDING only. Ongoing sync is handled by SyncManager. - */ -export class DataSeeder { - constructor( - // Arrays injected via DI - automatically includes all registered services/repositories - private services: IEntityService[], - private repositories: IApiRepository[] - ) {} - - /** - * Seed all entity stores if they are empty - * Runs on app initialization to load initial data from repositories - * - * Uses polymorphism: loops through all services and matches with repositories by entityType - */ - async seedIfEmpty(): Promise { - console.log('[DataSeeder] Checking if database needs seeding...'); - - try { - // Loop through all entity services (Event, Booking, Customer, Resource, etc.) - for (const service of this.services) { - // Find matching repository for this service based on entityType - const repository = this.repositories.find(repo => repo.entityType === service.entityType); - - if (!repository) { - console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`); - continue; - } - - // Seed this entity type - await this.seedEntity(service.entityType, service, repository); - } - - console.log('[DataSeeder] Seeding complete'); - } catch (error) { - console.error('[DataSeeder] Seeding failed:', error); - throw error; - } - } - - /** - * Generic method to seed a single entity type - * - * @param entityType - Entity type ('Event', 'Booking', 'Customer', 'Resource') - * @param service - Entity service for IndexedDB operations - * @param repository - Repository for fetching data - */ - private async seedEntity( - entityType: string, - service: IEntityService, - repository: IApiRepository - ): Promise { - // Check if store is empty - const existing = await service.getAll(); - - if (existing.length > 0) { - console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`); - return; - } - - console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`); - - // Fetch from repository (Mock JSON or backend API) - const data = await repository.fetchAll(); - - console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`); - - // Save each entity to IndexedDB - // Note: Entities from repository should already have syncStatus='synced' - for (const entity of data) { - await service.save(entity); - } - - console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`); - } -} +import { IApiRepository } from '../repositories/IApiRepository'; +import { IEntityService } from '../storage/IEntityService'; +import { ISync } from '../types/CalendarTypes'; + +/** + * DataSeeder - Orchestrates initial data loading from repositories into IndexedDB + * + * ARCHITECTURE: + * - Repository (Mock/Api): Fetches data from source (JSON file or backend API) + * - DataSeeder (this class): Orchestrates fetch + save operations + * - Service (EventService, etc.): Saves data to IndexedDB + * + * POLYMORPHIC DESIGN: + * - Uses arrays of IEntityService[] and IApiRepository[] + * - Matches services with repositories using entityType property + * - Open/Closed Principle: Adding new entity requires no code changes here + */ +export class DataSeeder { + constructor( + private services: IEntityService[], + private repositories: IApiRepository[] + ) {} + + /** + * Seed all entity stores if they are empty + */ + async seedIfEmpty(): Promise { + console.log('[DataSeeder] Checking if database needs seeding...'); + + try { + for (const service of this.services) { + const repository = this.repositories.find(repo => repo.entityType === service.entityType); + + if (!repository) { + console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`); + continue; + } + + await this.seedEntity(service.entityType, service, repository); + } + + console.log('[DataSeeder] Seeding complete'); + } catch (error) { + console.error('[DataSeeder] Seeding failed:', error); + throw error; + } + } + + private async seedEntity( + entityType: string, + service: IEntityService, + repository: IApiRepository + ): Promise { + const existing = await service.getAll(); + + if (existing.length > 0) { + console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`); + return; + } + + console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`); + + const data = await repository.fetchAll(); + + console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`); + + for (const entity of data) { + await service.save(entity, true); // silent = true to skip audit logging + } + + console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`); + } +} diff --git a/src/workers/SyncManager.ts b/src/workers/SyncManager.ts deleted file mode 100644 index 2ec2b5f..0000000 --- a/src/workers/SyncManager.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { IEventBus } from '../types/CalendarTypes'; -import { CoreEvents } from '../constants/CoreEvents'; -import { IAuditEntry } from '../types/AuditTypes'; -import { AuditService } from '../storage/audit/AuditService'; -import { IApiRepository } from '../repositories/IApiRepository'; - -/** - * SyncManager - Background sync worker - * Syncs audit entries with backend API when online - * - * NEW ARCHITECTURE: - * - Listens to AUDIT_LOGGED events (triggered after AuditService saves) - * - Polls AuditService for pending audit entries - * - Syncs audit entries to backend API - * - Marks audit entries as synced when successful - * - * EVENT CHAIN: - * Entity change → ENTITY_SAVED/DELETED → AuditService → AUDIT_LOGGED → SyncManager - * - * Features: - * - Monitors online/offline status - * - Processes pending audits with FIFO order - * - Exponential backoff retry logic - * - Updates syncStatus in IndexedDB after successful sync - * - Emits sync events for UI feedback - */ -export class SyncManager { - private eventBus: IEventBus; - private auditService: AuditService; - private auditApiRepository: IApiRepository; - - private isOnline: boolean = navigator.onLine; - private isSyncing: boolean = false; - private syncInterval: number = 5000; // 5 seconds - private maxRetries: number = 5; - private intervalId: number | null = null; - - // Track retry counts per audit entry (in memory) - private retryCounts: Map = new Map(); - - constructor( - eventBus: IEventBus, - auditService: AuditService, - auditApiRepository: IApiRepository - ) { - this.eventBus = eventBus; - this.auditService = auditService; - this.auditApiRepository = auditApiRepository; - - this.setupNetworkListeners(); - this.setupAuditListener(); - this.startSync(); - console.log('SyncManager initialized - listening for AUDIT_LOGGED events'); - } - - /** - * Setup listener for AUDIT_LOGGED events - * Triggers immediate sync attempt when new audit entry is saved - */ - private setupAuditListener(): void { - this.eventBus.on(CoreEvents.AUDIT_LOGGED, () => { - // New audit entry saved - try to sync if online - if (this.isOnline && !this.isSyncing) { - this.processPendingAudits(); - } - }); - } - - /** - * Setup online/offline event listeners - */ - private setupNetworkListeners(): void { - window.addEventListener('online', () => { - this.isOnline = true; - this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { - isOnline: true - }); - console.log('SyncManager: Network online - starting sync'); - this.startSync(); - }); - - window.addEventListener('offline', () => { - this.isOnline = false; - this.eventBus.emit(CoreEvents.OFFLINE_MODE_CHANGED, { - isOnline: false - }); - console.log('SyncManager: Network offline - pausing sync'); - this.stopSync(); - }); - } - - /** - * Start background sync worker - */ - public startSync(): void { - if (this.intervalId) { - return; // Already running - } - - console.log('SyncManager: Starting background sync'); - - // Process immediately - this.processPendingAudits(); - - // Then poll every syncInterval - this.intervalId = window.setInterval(() => { - this.processPendingAudits(); - }, this.syncInterval); - } - - /** - * Stop background sync worker - */ - public stopSync(): void { - if (this.intervalId) { - window.clearInterval(this.intervalId); - this.intervalId = null; - console.log('SyncManager: Stopped background sync'); - } - } - - /** - * Process pending audit entries - * Fetches from AuditService and syncs to backend - */ - private async processPendingAudits(): Promise { - // Don't sync if offline - if (!this.isOnline) { - return; - } - - // Don't start new sync if already syncing - if (this.isSyncing) { - return; - } - - this.isSyncing = true; - - try { - const pendingAudits = await this.auditService.getPendingAudits(); - - if (pendingAudits.length === 0) { - this.isSyncing = false; - return; - } - - this.eventBus.emit(CoreEvents.SYNC_STARTED, { - operationCount: pendingAudits.length - }); - - // Process audits one by one (FIFO - oldest first by timestamp) - const sortedAudits = pendingAudits.sort((a, b) => a.timestamp - b.timestamp); - - for (const audit of sortedAudits) { - await this.processAuditEntry(audit); - } - - this.eventBus.emit(CoreEvents.SYNC_COMPLETED, { - operationCount: pendingAudits.length - }); - - } catch (error) { - console.error('SyncManager: Audit processing error:', error); - this.eventBus.emit(CoreEvents.SYNC_FAILED, { - error: error instanceof Error ? error.message : 'Unknown error' - }); - } finally { - this.isSyncing = false; - } - } - - /** - * Process a single audit entry - * Sends to backend API and marks as synced - */ - private async processAuditEntry(audit: IAuditEntry): Promise { - const retryCount = this.retryCounts.get(audit.id) || 0; - - // Check if max retries exceeded - if (retryCount >= this.maxRetries) { - console.error(`SyncManager: Max retries exceeded for audit ${audit.id}`); - await this.auditService.markAsError(audit.id); - this.retryCounts.delete(audit.id); - return; - } - - try { - // Send audit entry to backend - await this.auditApiRepository.sendCreate(audit); - - // Success - mark as synced and clear retry count - await this.auditService.markAsSynced(audit.id); - this.retryCounts.delete(audit.id); - - console.log(`SyncManager: Successfully synced audit ${audit.id} (${audit.entityType}:${audit.operation})`); - - } catch (error) { - console.error(`SyncManager: Failed to sync audit ${audit.id}:`, error); - - // Increment retry count - this.retryCounts.set(audit.id, retryCount + 1); - - // Calculate backoff delay - const backoffDelay = this.calculateBackoff(retryCount + 1); - - this.eventBus.emit(CoreEvents.SYNC_RETRY, { - auditId: audit.id, - retryCount: retryCount + 1, - nextRetryIn: backoffDelay - }); - } - } - - /** - * Calculate exponential backoff delay - * @param retryCount Current retry count - * @returns Delay in milliseconds - */ - private calculateBackoff(retryCount: number): number { - // Exponential backoff: 2^retryCount * 1000ms - // Retry 1: 2s, Retry 2: 4s, Retry 3: 8s, Retry 4: 16s, Retry 5: 32s - const baseDelay = 1000; - const exponentialDelay = Math.pow(2, retryCount) * baseDelay; - const maxDelay = 60000; // Max 1 minute - return Math.min(exponentialDelay, maxDelay); - } - - /** - * Manually trigger sync (for testing or manual sync button) - */ - public async triggerManualSync(): Promise { - console.log('SyncManager: Manual sync triggered'); - await this.processPendingAudits(); - } - - /** - * Get current sync status - */ - public getSyncStatus(): { - isOnline: boolean; - isSyncing: boolean; - isRunning: boolean; - } { - return { - isOnline: this.isOnline, - isSyncing: this.isSyncing, - isRunning: this.intervalId !== null - }; - } - - /** - * Cleanup - stop sync and remove listeners - */ - public destroy(): void { - this.stopSync(); - this.retryCounts.clear(); - // Note: We don't remove window event listeners as they're global - } -} diff --git a/wwwroot/css/calendar-base-css.css b/wwwroot/css/calendar-base-css.css deleted file mode 100644 index 10ecdc9..0000000 --- a/wwwroot/css/calendar-base-css.css +++ /dev/null @@ -1,248 +0,0 @@ -/* styles/base.css */ - -/* CSS Reset and Base */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -/* CSS Variables */ -:root { - /* Grid measurements */ - --hour-height: 60px; - --minute-height: 1px; - --snap-interval: 15; - --day-column-min-width: 250px; - --week-days: 7; - --header-height: 80px; - --all-day-row-height: 0px; /* Default height for all-day events row */ - --all-day-event-height: 26px; /* Height of single all-day event including gaps */ - --single-row-height: 28px; /* Height of single row in all-day container - synced with TypeScript */ - --stack-levels: 1; /* Number of stack levels for all-day events */ - - /* Time boundaries - Default fallback values */ - --day-start-hour: 0; - --day-end-hour: 24; - --work-start-hour: 8; - --work-end-hour: 17; - - /* Colors */ - --color-primary: #2196f3; - --color-secondary: #ff9800; - --color-success: #4caf50; - --color-danger: #f44336; - --color-warning: #ff9800; - - /* Grid colors */ - --color-grid-line: #e0e0e0; - --color-grid-line-light: rgba(0, 0, 0, 0.05); - --color-hour-line: rgba(0, 0, 0, 0.2); - --color-work-hours: rgba(255, 255, 255, 0.9); - --color-current-time: #ff0000; - - /* Named color palette for events */ - --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; - - /* UI colors */ - --color-background: #ffffff; - --color-surface: #f5f5f5; - --color-event-grid: #ffffff; - --color-non-work-hours: #ff980038; - --color-text: #333333; - --color-text-secondary: #666666; - --color-border: #e0e0e0; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1); - --shadow-popup: 0 4px 20px rgba(0, 0, 0, 0.15); - - /* Transitions */ - --transition-fast: 150ms ease; - --transition-normal: 300ms ease; - --transition-slow: 500ms ease; - - /* Z-index layers */ - --z-grid: 1; - --z-event: 10; - --z-event-hover: 20; - --z-drag-ghost: 30; - --z-current-time: 40; - --z-popup: 100; - --z-loading: 200; -} - -/* Base styles */ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - font-size: 14px; - line-height: 1.5; - color: var(--color-text); - background-color: var(--color-surface); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Custom elements default display */ -swp-calendar, -swp-calendar-nav, -swp-calendar-container, -swp-calendar-grid, -swp-header-cell, -swp-time-cell, -swp-day-cell, -swp-events-container, -swp-day-columns swp-event, -swp-loading-overlay, -swp-nav-group, -swp-nav-button, -swp-search-container, -swp-search-icon, -swp-search-clear, -swp-view-selector, -swp-view-button, -swp-week-info, -swp-week-number, -swp-date-range, -swp-day-name, -swp-day-date, -swp-event-time, -swp-day-columns swp-event-title, -swp-spinner { - display: block; -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -::-webkit-scrollbar-track { - background: var(--color-surface); -} - -::-webkit-scrollbar-thumb { - background: #bbb; - border-radius: 5px; -} - -::-webkit-scrollbar-thumb:hover { - background: #999; -} - -/* Selection styling */ -::selection { - background-color: rgba(33, 150, 243, 0.2); - color: inherit; -} - -/* Prevent text selection in calendar UI */ -swp-calendar-container, -swp-calendar-grid, -swp-day-column, -swp-day-columns swp-event, -swp-day-columns swp-event-group, -swp-time-axis, -swp-day-columns swp-event-title, -swp-day-columns swp-event-time { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; -} - -/* Enable text selection for events when double-clicked */ -swp-day-columns swp-event.text-selectable swp-day-columns swp-event-title, -swp-day-columns swp-event.text-selectable swp-day-columns swp-event-time { - user-select: text; - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; -} - -/* Focus styles */ -:focus { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -:focus:not(:focus-visible) { - outline: none; -} - -/* Utility classes */ -.hidden { - display: none !important; -} - -.invisible { - visibility: hidden !important; -} - -.transparent { - opacity: 0 !important; -} - -/* Animations */ -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@keyframes pulse { - 0% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.6; - transform: scale(1.2); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideIn { - from { - transform: translateX(-100%); - } - to { - transform: translateX(0); - } -} \ No newline at end of file diff --git a/wwwroot/css/v2/calendar-v2-base.css b/wwwroot/css/calendar-base.css similarity index 100% rename from wwwroot/css/v2/calendar-v2-base.css rename to wwwroot/css/calendar-base.css diff --git a/wwwroot/css/calendar-components-css.css b/wwwroot/css/calendar-components-css.css deleted file mode 100644 index f9793e1..0000000 --- a/wwwroot/css/calendar-components-css.css +++ /dev/null @@ -1,236 +0,0 @@ -/* styles/components/navigation.css */ - -/* Navigation groups */ -swp-nav-group { - display: flex; - align-items: center; - gap: 4px; -} - -/* Navigation buttons */ -swp-nav-button { - display: flex; - align-items: center; - justify-content: center; - padding: 8px 16px; - border: 1px solid var(--color-border); - background: var(--color-background); - border-radius: 4px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all var(--transition-fast); - min-width: 40px; - height: 36px; - - &:hover { - background: var(--color-surface); - border-color: var(--color-text-secondary); - } - - &:active { - transform: translateY(1px); - } - - /* Icon buttons */ - svg { - width: 20px; - height: 20px; - stroke-width: 2; - } - - /* Today button */ - &[data-action="today"] { - min-width: 70px; - } -} - -/* View selector */ -swp-view-selector { - display: flex; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 4px; - overflow: hidden; -} - -swp-view-button { - padding: 8px 16px; - border: none; - background: transparent; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all var(--transition-fast); - position: relative; - - &:not(:last-child) { - border-right: 1px solid var(--color-border); - } - - &:hover:not([disabled]) { - background: rgba(0, 0, 0, 0.05); - } - - &[data-active="true"] { - background: var(--color-primary); - color: white; - - &:hover { - background: var(--color-primary); - } - } - - &[disabled] { - opacity: 0.5; - cursor: not-allowed; - } -} - -/* Workweek Presets */ -swp-workweek-presets { - display: flex; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 4px; - overflow: hidden; - margin-left: 16px; -} - -swp-preset-button { - padding: 6px 12px; - border: none; - background: transparent; - cursor: pointer; - font-size: 0.75rem; - font-weight: 500; - transition: all var(--transition-fast); - position: relative; - color: var(--color-text-secondary); - - &:not(:last-child) { - border-right: 1px solid var(--color-border); - } - - - &[data-active="true"] { - background: var(--color-primary); - color: white; - font-weight: 600; - } - - &[disabled] { - opacity: 0.5; - cursor: not-allowed; - } -} - - -/* Search container */ -swp-search-container { - margin-left: auto; - display: flex; - align-items: center; - position: relative; - - swp-search-icon { - position: absolute; - left: 12px; - color: var(--color-text-secondary); - - svg { - width: 16px; - height: 16px; - } - } - - input[type="search"] { - padding: 8px 36px 8px 36px; - border: 1px solid var(--color-border); - border-radius: 20px; - background: var(--color-surface); - font-size: 0.875rem; - width: 250px; - transition: all var(--transition-fast); - - &::-webkit-search-cancel-button { - display: none; - } - - &:focus { - outline: none; - border-color: var(--color-primary); - background: var(--color-background); - width: 300px; - } - - &::placeholder { - color: var(--color-text-secondary); - } - } - - swp-search-clear { - position: absolute; - right: 8px; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - border-radius: 50%; - transition: all var(--transition-fast); - - &:hover { - background: rgba(0, 0, 0, 0.1); - } - - svg { - width: 14px; - height: 14px; - stroke: var(--color-text-secondary); - } - - &[hidden] { - display: none; - } - } -} - -/* Visual indication when filter is active */ -swp-search-container.filter-active input { - border-color: var(--color-primary); - background: rgba(33, 150, 243, 0.05); -} - -/* Calendar search active state */ -swp-calendar[data-searching="true"] { - swp-event { - opacity: 0.15; - transition: opacity var(--transition-normal); - - &[data-search-match="true"] { - opacity: 1; - box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3); - } - } -} - -/* Week info display */ -swp-week-info { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -} - -swp-week-number { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text); -} - -swp-date-range { - font-size: 0.875rem; - color: var(--color-text-secondary); -} \ No newline at end of file diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css deleted file mode 100644 index 379f4a2..0000000 --- a/wwwroot/css/calendar-events-css.css +++ /dev/null @@ -1,338 +0,0 @@ -/* styles/components/events.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; - - /* 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; - opacity: 0.8; - left: 2px; - right: 2px; - width: auto; - } - - /* 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 (< 60px) */ -@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; - - /* 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: '◀'; - 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: '▶'; - 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; - - &: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); } diff --git a/wwwroot/css/v2/calendar-v2-events.css b/wwwroot/css/calendar-events.css similarity index 100% rename from wwwroot/css/v2/calendar-v2-events.css rename to wwwroot/css/calendar-events.css diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css deleted file mode 100644 index 415b3d4..0000000 --- a/wwwroot/css/calendar-layout-css.css +++ /dev/null @@ -1 +0,0 @@ -.calendar-wrapper{box-sizing:border-box;display:flex;flex-direction:column;height:100vh;margin:0;overflow:hidden;padding:0;width:100vw}swp-calendar{background:var(--color-background);display:grid;grid-template-rows:auto 1fr;height:100vh;overflow:hidden;position:relative;width:100%}swp-calendar[data-fit-to-width=true] swp-scrollable-content{overflow-x:hidden}swp-calendar-nav{align-items:center;background:var(--color-background);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow-sm);display:grid;gap:20px;grid-template-columns:auto 1fr auto auto;padding:12px 16px}swp-calendar-container{display:grid;grid-template-columns:60px 1fr;grid-template-rows:auto 1fr;height:100%;overflow:hidden;position:relative}swp-calendar-container.week-transition{transition:opacity .3s ease}swp-calendar-container.week-transition:is(-out){opacity:.5}swp-header-spacer{background:var(--color-surface);border-bottom:1px solid var(--color-border);border-right:1px solid var(--color-border);grid-column:1;grid-row:1;height:calc(var(--header-height) + var(--all-day-row-height));position:relative;z-index:5}.allday-chevron{background:none;border:none;border-radius:4px;bottom:2px;color:#666;cursor:pointer;left:50%;padding:4px 8px;position:absolute;transform:translateX(-50%);transition:transform .3s ease,color .2s ease}.allday-chevron:hover{background-color:rgba(0,0,0,.05);color:#000}.allday-chevron.collapsed{transform:translateX(-50%) rotate(0deg)}.allday-chevron.expanded{transform:translateX(-50%) rotate(180deg)}.allday-chevron svg{display:block;height:8px;width:12px}swp-grid-container{display:grid;grid-column:2;grid-row:1/3;grid-template-rows:auto 1fr;transition:transform .4s cubic-bezier(.4,0,.2,1);width:100%}swp-grid-container,swp-time-axis{overflow:hidden;position:relative}swp-time-axis{background:var(--color-surface);border-right:1px solid var(--color-border);grid-column:1;grid-row:2;height:100%;left:0;width:60px;z-index:3}swp-time-axis-content{display:flex;flex-direction:column;position:relative}swp-hour-marker{align-items:flex-start;color:var(--color-text-secondary);display:flex;font-size:.75rem;height:var(--hour-height);padding:0 8px 8px 15px;position:relative}swp-hour-marker:before{background:var(--color-hour-line);content:"";height:1px;left:50px;position:absolute;top:-1px;width:calc(100vw - 60px);z-index:2}swp-calendar-header{background:var(--color-surface);display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));grid-template-rows:var(--header-height) auto;height:calc(var(--header-height) + var(--all-day-row-height));min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));overflow-x:hidden;overflow-y:scroll;position:sticky;top:0;z-index:3}swp-calendar-header::-webkit-scrollbar{background:transparent;width:17px}swp-calendar-header::-webkit-scrollbar-thumb,swp-calendar-header::-webkit-scrollbar-track{background:transparent}swp-calendar-header swp-allday-container{align-items:center;display:grid;gap:2px 0;grid-auto-rows:var(--single-row-height);grid-column:1/-1;grid-row:2;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));overflow:hidden}:is(swp-calendar-header swp-allday-container):has(swp-allday-event){border-bottom:1px solid var(--color-grid-line)}swp-day-header{align-items:center;border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;grid-row:1;justify-content:center;padding-top:3px;text-align:center}swp-day-header:last-child{border-right:none}swp-day-header[data-today=true]{background:rgba(33,150,243,.1)}swp-day-header[data-today=true] swp-day-name{color:var(--color-primary);font-weight:600}swp-day-header[data-today=true] swp-day-date{color:var(--color-primary)}swp-day-name{color:var(--color-text-secondary);display:block;font-size:12px;font-weight:500;letter-spacing:.1em}swp-day-date{display:block;font-size:30px;margin-top:4px}swp-resource-header{align-items:center;background:var(--color-surface);border-bottom:1px solid var(--color-grid-line);border-right:1px solid var(--color-grid-line);display:flex;flex-direction:column;justify-content:center;padding:12px;text-align:center}swp-resource-header:last-child{border-right:none}swp-resource-avatar{background:var(--color-border);border-radius:50%;display:block;height:40px;margin-bottom:8px;overflow:hidden;width:40px}swp-resource-avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}swp-resource-name{color:var(--color-text);display:block;font-size:.875rem;font-weight:500;text-align:center}swp-allday-column{background:transparent;height:100%;opacity:0;position:relative;z-index:1}swp-allday-container swp-allday-event{align-items:center;background:#08f;border-radius:3px;color:#fff;display:flex;font-size:.75rem;height:22px!important;justify-content:flex-start;left:auto!important;margin:1px;overflow:hidden;padding:2px 4px;position:relative!important;right:auto!important;text-overflow:ellipsis;top:auto!important;white-space:nowrap;width:auto!important;z-index:2;--b-text:var(--color-text);background-color:color-mix(in srgb,var(--b-primary) 10%,var(--b-mix));border-left:4px solid var(--b-primary);color:var(--b-text)}.dragging:is(swp-allday-container swp-allday-event){opacity:1}.highlight:is(swp-allday-container swp-allday-event){background-color:color-mix(in srgb,var(--b-primary) 15%,var(--b-mix))!important}.max-event-indicator:is(swp-allday-container swp-allday-event){background:#e0e0e0!important;border:1px dashed #999!important;color:#666!important;cursor:pointer!important;font-style:italic;justify-content:center;opacity:.8;text-align:center!important}.max-event-indicator:is(swp-allday-container swp-allday-event):hover{background:#d0d0d0!important;color:#333!important;opacity:1}.max-event-indicator:is(swp-allday-container swp-allday-event) span{display:block;font-size:11px;font-weight:400;text-align:center;width:100%}.max-event-overflow-show:is(swp-allday-container swp-allday-event){opacity:1;transition:opacity .3s ease-in-out}.max-event-overflow-hide:is(swp-allday-container swp-allday-event){opacity:0;transition:opacity .3s ease-in-out}:is(swp-allday-container swp-allday-event) swp-event-time{display:none}:is(swp-allday-container swp-allday-event) swp-event-title{display:block;font-size:12px;line-height:18px}.transitioning:is(swp-allday-container swp-allday-event){transition:grid-area .2s ease-out,grid-row .2s ease-out,grid-column .2s ease-out}swp-scrollable-content{display:grid;overflow-x:auto;overflow-y:auto;position:relative;scroll-behavior:smooth;top:-1px}swp-scrollable-content::-webkit-scrollbar{height:var(--scrollbar-width,12px);width:var(--scrollbar-width,12px)}swp-scrollable-content::-webkit-scrollbar-track{background:var(--scrollbar-track-color,#f0f0f0)}swp-scrollable-content::-webkit-scrollbar-thumb{background:var(--scrollbar-color,#666);border-radius:var(--scrollbar-border-radius,6px)}:is(swp-scrollable-content::-webkit-scrollbar-thumb):hover{background:var(--scrollbar-hover-color,#333)}swp-scrollable-content{scrollbar-color:var(--scrollbar-color,#666) var(--scrollbar-track-color,#f0f0f0);scrollbar-width:auto}swp-time-grid{height:calc((var(--day-end-hour) - var(--day-start-hour))*var(--hour-height));position:relative}swp-time-grid:before{background:transparent;display:none;height:0}swp-time-grid:after,swp-time-grid:before{content:"";left:0;min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute;right:0;top:0}swp-time-grid:after{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));bottom:0;z-index:1}swp-grid-lines{background-image:repeating-linear-gradient(to bottom,transparent,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));bottom:0;left:0;right:0;top:0;z-index:var(--z-grid)}swp-day-columns,swp-grid-lines{min-width:calc(var(--grid-columns, 7)*var(--day-column-min-width));position:absolute}swp-day-columns{display:grid;grid-template-columns:repeat(var(--grid-columns,7),minmax(var(--day-column-min-width),1fr));inset:0}swp-day-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-day-column:last-child{border-right:none}swp-day-column:after,swp-day-column:before{background:var(--color-non-work-hours);content:"";left:0;opacity:.3;position:absolute;right:0;z-index:2}swp-day-column:before{height:var(--before-work-height,0);top:0}swp-day-column:after{bottom:0;top:var(--after-work-top,100%)}swp-day-column[data-work-hours=off]{background:var(--color-non-work-hours)}swp-day-column[data-work-hours=off]:after,swp-day-column[data-work-hours=off]:before{display:none}swp-resource-column{background:var(--color-event-grid);border-right:1px solid var(--color-grid-line);min-width:var(--day-column-min-width);position:relative}swp-resource-column:last-child{border-right:none}swp-events-layer{display:block;inset:0;position:absolute;z-index:var(--z-event)}swp-current-time-indicator{background:var(--color-current-time);height:2px;left:0;position:absolute;right:0;z-index:var(--z-current-time)}swp-current-time-indicator:before{background:var(--color-current-time);border-radius:3px;color:#fff;content:attr(data-time);font-size:.75rem;left:-55px;padding:2px 6px;position:absolute;top:-10px;white-space:nowrap}swp-current-time-indicator:after{background:var(--color-current-time);border-radius:50%;box-shadow:0 0 0 2px rgba(255,0,0,.3);content:"";height:10px;position:absolute;right:-4px;top:-4px;width:10px} \ No newline at end of file diff --git a/wwwroot/css/v2/calendar-v2-layout.css b/wwwroot/css/calendar-layout.css similarity index 100% rename from wwwroot/css/v2/calendar-v2-layout.css rename to wwwroot/css/calendar-layout.css diff --git a/wwwroot/css/calendar-month-css.css b/wwwroot/css/calendar-month-css.css deleted file mode 100644 index 0067bdd..0000000 --- a/wwwroot/css/calendar-month-css.css +++ /dev/null @@ -1,315 +0,0 @@ -/* Calendar Month View Styles */ - -/* Month view specific container - extends swp-calendar-container for month layout */ -swp-calendar[data-view="month"] swp-calendar-container { - overflow: hidden; - background: var(--color-background); -} - -/* Month grid layout with week numbers column */ -.month-grid { - display: grid; - grid-template-columns: 40px repeat(7, 1fr); /* Week numbers + 7 days */ - grid-template-rows: 40px repeat(6, 1fr); - min-height: 600px; - border: 1px solid var(--color-border); - border-radius: var(--border-radius); - overflow: hidden; -} - -/* Week number header cell */ -.month-week-header { - grid-column: 1; - grid-row: 1; - background: var(--color-surface); - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-secondary); - height: 40px; -} - -/* Month day headers - only day names, right aligned */ -.month-day-header { - background: var(--color-surface); - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - display: flex; - align-items: center; - justify-content: flex-end; - padding: 8px 12px; - font-weight: 600; - color: var(--color-text-secondary); - font-size: 0.875rem; - height: 40px; -} - -.month-day-header:last-child { - border-right: none; -} - -/* Week number cells */ -.month-week-number { - grid-column: 1; - background: var(--color-surface); - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-secondary); -} - -/* Month day cells */ -.month-day-cell { - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); - padding: 8px; - background: var(--color-background); - transition: background-color var(--transition-fast); - position: relative; - min-height: 100px; - cursor: pointer; -} - -.month-day-cell:hover { - background: var(--color-hover); -} - -.month-day-cell:last-child { - border-right: none; -} - -/* Other month dates (previous/next month) */ -.month-day-cell.other-month { - background: var(--color-surface); - color: var(--color-text-secondary); -} - -/* Today highlighting - subtle with left border */ -.month-day-cell.today { - background: #f0f8ff; - border-left: 3px solid var(--color-primary); -} - -/* Weekend styling */ -.month-day-cell.weekend { - background: #fafbfc; -} - -/* Day number in each cell */ -.month-day-number { - font-weight: 600; - margin-bottom: 6px; - font-size: 0.875rem; -} - -.month-day-cell.today .month-day-number { - color: var(--color-primary); - font-weight: 700; -} - -/* Events container within each day */ -.month-events { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 70px; - overflow: hidden; -} - -/* Individual month events - compact version */ -.month-event { - background: #e3f2fd; - color: var(--color-primary); - padding: 1px 4px; - border-radius: 2px; - font-size: 10px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition-fast); - border-left: 2px solid var(--color-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; -} - -.month-event:hover { - transform: translateY(-1px); -} - -/* Event categories using existing color scheme */ -.month-event.category-meeting { - background: #e8f5e8; - color: var(--color-success); - border-left-color: var(--color-success); -} - -.month-event.category-deadline { - background: #ffebee; - color: var(--color-error); - border-left-color: var(--color-error); -} - -.month-event.category-work { - background: #fff8e1; - color: var(--color-secondary); - border-left-color: var(--color-secondary); -} - -.month-event.category-personal { - background: #f3e5f5; - color: #7b1fa2; - border-left-color: #9c27b0; -} - -/* "More events" indicator */ -.month-event-more { - background: var(--color-surface); - color: var(--color-text-secondary); - padding: 1px 4px; - border-radius: 2px; - font-size: 9px; - text-align: center; - cursor: pointer; - border: 1px dashed var(--color-border); - margin-top: 1px; -} - -.month-event-more:hover { - background: var(--color-hover); -} - -/* Expanded month view - duration-based events */ -.month-grid.expanded { - min-height: 800px; -} - -.month-grid.expanded .month-day-cell { - min-height: 120px; - padding: 4px; -} - -.month-grid.expanded .month-events { - max-height: none; - overflow: visible; -} - -.month-grid.expanded .month-event { - padding: 2px 6px; - font-size: 11px; - min-height: 16px; - display: flex; - flex-direction: column; - white-space: normal; - text-overflow: clip; - overflow: visible; -} - -/* Duration-based event sizing (30px per hour) */ -.month-event.duration-30min { min-height: 15px; } -.month-event.duration-1h { min-height: 30px; } -.month-event.duration-1h30 { min-height: 45px; } -.month-event.duration-2h { min-height: 60px; } -.month-event.duration-3h { min-height: 90px; } -.month-event.duration-4h { min-height: 120px; } - -/* Event time display for expanded view */ -.month-event-time { - font-size: 9px; - opacity: 0.8; - font-weight: 400; -} - -.month-event-title { - font-weight: 600; - font-size: 10px; -} - -.month-event-subtitle { - font-size: 9px; - opacity: 0.7; - font-weight: 400; -} - -/* All-day events */ -.month-event.all-day { - background: linear-gradient(90deg, var(--color-primary), rgba(33, 150, 243, 0.7)); - color: white; - border-left-color: var(--color-primary); - font-weight: 600; -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .month-grid { - grid-template-columns: 30px repeat(7, 1fr); - min-height: 400px; - } - - .month-day-cell { - min-height: 60px; - padding: 4px; - } - - .month-day-header { - padding: 8px 4px; - font-size: 0.75rem; - } - - .month-event { - font-size: 9px; - padding: 1px 3px; - } - - .month-events { - max-height: 40px; - } - - .month-week-number { - font-size: 0.6rem; - } -} - -/* Loading state for month view */ -swp-calendar[data-view="month"][data-loading="true"] .month-grid { - opacity: 0.5; -} - -/* Month view navigation animation support */ -.month-grid { - transition: transform var(--transition-normal); -} - -.month-grid.sliding-out-left { - transform: translateX(-100%); -} - -.month-grid.sliding-out-right { - transform: translateX(100%); -} - -.month-grid.sliding-in-left { - transform: translateX(-100%); - animation: slideInFromLeft var(--transition-normal) forwards; -} - -.month-grid.sliding-in-right { - transform: translateX(100%); - animation: slideInFromRight var(--transition-normal) forwards; -} - -@keyframes slideInFromLeft { - to { transform: translateX(0); } -} - -@keyframes slideInFromRight { - to { transform: translateX(0); } -} \ No newline at end of file diff --git a/wwwroot/css/calendar-popup-css.css b/wwwroot/css/calendar-popup-css.css deleted file mode 100644 index f4616ea..0000000 --- a/wwwroot/css/calendar-popup-css.css +++ /dev/null @@ -1,193 +0,0 @@ -/* styles/components/popup.css */ - -/* Event popup */ -swp-event-popup { - position: fixed; - background: #f9f5f0; - border-radius: 8px; - box-shadow: var(--shadow-popup); - padding: 16px; - min-width: 300px; - z-index: var(--z-popup); - animation: fadeIn var(--transition-fast); - - /* Chevron arrow */ - &::before { - content: ''; - position: absolute; - width: 16px; - height: 16px; - background: inherit; - transform: rotate(45deg); - top: 50%; - margin-top: -8px; - } - - /* Right-side popup (arrow on left) */ - &[data-align="right"] { - &::before { - left: -8px; - box-shadow: -2px 2px 4px rgba(0, 0, 0, 0.1); - } - } - - /* Left-side popup (arrow on right) */ - &[data-align="left"] { - &::before { - right: -8px; - box-shadow: 2px -2px 4px rgba(0, 0, 0, 0.1); - } - } - - &[hidden] { - display: none; - } -} - -/* Popup header */ -swp-popup-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 16px; - gap: 16px; -} - -swp-popup-title { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text); - line-height: 1.4; - flex: 1; -} - -/* Popup actions */ -swp-popup-actions { - display: flex; - gap: 4px; -} - -swp-action-button { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - cursor: pointer; - transition: background var(--transition-fast); - color: var(--color-text-secondary); - - &:hover { - background: rgba(0, 0, 0, 0.05); - color: var(--color-text); - } - - &:active { - background: rgba(0, 0, 0, 0.1); - } - - svg { - width: 16px; - height: 16px; - } - - /* Specific button styles */ - &[data-action="delete"]:hover { - color: var(--color-danger); - } - - &[data-action="close"]:hover { - background: rgba(0, 0, 0, 0.1); - } -} - -/* Popup content */ -swp-popup-content { - display: flex; - flex-direction: column; - gap: 8px; -} - -swp-time-info { - display: flex; - align-items: center; - gap: 12px; - color: var(--color-text-secondary); - font-size: 0.875rem; - - swp-icon { - font-size: 1.25rem; - color: var(--color-secondary); - } -} - -/* Loading overlay */ -swp-loading-overlay { - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; -} - -swp-loading-overlay[hidden] { - display: none; -} - -swp-spinner { - width: 40px; - height: 40px; - border: 3px solid #f3f3f3; - border-top: 3px solid var(--color-primary); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Snap indicator */ -swp-snap-indicator { - position: absolute; - left: 0; - right: 0; - height: 2px; - background: var(--color-primary); - opacity: 0; - transition: opacity var(--transition-fast); - z-index: var(--z-drag-ghost); - - &[data-active="true"] { - opacity: 1; - } - - &::before { - content: attr(data-time); - position: absolute; - right: 8px; - top: -24px; - background: var(--color-primary); - color: white; - padding: 2px 8px; - font-size: 0.75rem; - border-radius: 3px; - white-space: nowrap; - } -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - swp-event-popup { - min-width: 250px; - max-width: calc(100vw - 32px); - } - - swp-popup-title { - font-size: 1rem; - } -} \ No newline at end of file diff --git a/wwwroot/css/calendar-sliding-animation.css b/wwwroot/css/calendar-sliding-animation.css deleted file mode 100644 index 8965d9f..0000000 --- a/wwwroot/css/calendar-sliding-animation.css +++ /dev/null @@ -1,24 +0,0 @@ -/* POC-style Calendar Sliding Animation CSS */ - -/* Grid container base styles */ -swp-grid-container { - position: relative; - width: 100%; - transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1); - will-change: transform; - backface-visibility: hidden; - transform: translateZ(0); /* GPU acceleration */ -} - -/* Calendar container for sliding */ -swp-calendar-container { - position: relative; - overflow: hidden; -} - -/* Accessibility: Respect reduced motion preference */ -@media (prefers-reduced-motion: reduce) { - swp-grid-container { - transition: none; - } -} \ No newline at end of file diff --git a/wwwroot/css/calendar-v2.css b/wwwroot/css/calendar-v2.css deleted file mode 100644 index de3e09b..0000000 --- a/wwwroot/css/calendar-v2.css +++ /dev/null @@ -1,321 +0,0 @@ -:root { - --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; - --color-border: #e0e0e0; - --color-surface: #fff; - --color-text-secondary: #666; - --color-primary: #1976d2; - --color-hour-line: rgba(0, 0, 0, 0.2); - --color-grid-line-light: rgba(0, 0, 0, 0.05); -} - -* { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f5f5; -} - -.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: 16px; - padding: 12px 16px; - border-bottom: 1px solid var(--color-border); - align-items: center; -} - -swp-nav-button { - padding: 8px 16px; - border: 1px solid var(--color-border); - border-radius: 4px; - cursor: pointer; - background: var(--color-surface); - - &:hover { background: #f0f0f0; } -} - -swp-week-info { - margin-left: auto; - text-align: right; - - swp-week-number { - font-weight: 600; - display: block; - } - - swp-date-range { - font-size: 12px; - 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; -} - -swp-header-spacer { - border-bottom: 1px solid var(--color-border); -} - -swp-header-drawer { - display: block; - height: 0; - overflow: hidden; - background: #fafafa; - 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-rows: subgrid; - overflow: hidden; -} - -/* Viewport/Track for slide animation */ -swp-header-viewport { - overflow: hidden; -} - -swp-content-viewport { - overflow: hidden; - min-height: 0; /* Tillader at krympe i grid */ -} - -swp-header-track { - display: flex; - - > swp-calendar-header { flex: 0 0 100%; } -} - -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: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr)); - min-width: calc(var(--grid-columns) * var(--day-column-min-width)); - grid-auto-rows: auto; - background: var(--color-surface); - overflow-y: scroll; - overflow-x: hidden; - - &::-webkit-scrollbar { background: transparent; } - &::-webkit-scrollbar-thumb { background: transparent; } - - &[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; } - } -} - -swp-day-header, -swp-resource-header, -swp-team-header { - padding: 8px; - text-align: center; - border-right: 1px solid var(--color-border); - border-bottom: 1px solid var(--color-border); -} - -swp-team-header { - background: #e3f2fd; - color: #1565c0; - font-weight: 500; -} - -swp-resource-header { - background: #fafafa; - 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; - } -} - -/* 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), ikke ved timegrænsen */ -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; -} - -/* Events */ -swp-event { - position: absolute; - background: var(--color-primary); - color: white; - border-radius: 4px; - padding: 4px 6px; - font-size: 12px; - overflow: hidden; - - swp-event-time { - display: block; - font-size: 10px; - opacity: 0.9; - } - - swp-event-title { - display: block; - font-weight: 500; - } -} diff --git a/wwwroot/css/calendar.css b/wwwroot/css/calendar.css new file mode 100644 index 0000000..14d11ed --- /dev/null +++ b/wwwroot/css/calendar.css @@ -0,0 +1,6 @@ +/* Calendar - Entry point */ +/* Modular CSS architecture: one file per feature */ + +@import 'calendar-base.css'; +@import 'calendar-layout.css'; +@import 'calendar-events.css'; diff --git a/wwwroot/css/test-nesting.css b/wwwroot/css/test-nesting.css deleted file mode 100644 index f5513a1..0000000 --- a/wwwroot/css/test-nesting.css +++ /dev/null @@ -1 +0,0 @@ -.test-container{display:flex;padding:20px}.test-container .test-child{color:blue}:is(.test-container .test-child):hover{color:red}.active:is(.test-container .test-child){font-weight:700}.test-container .test-nested{margin:10px}:is(.test-container .test-nested) .deep-nested{font-size:14px} \ No newline at end of file diff --git a/wwwroot/css/v2/calendar-v2.css b/wwwroot/css/v2/calendar-v2.css deleted file mode 100644 index 5a90f07..0000000 --- a/wwwroot/css/v2/calendar-v2.css +++ /dev/null @@ -1,6 +0,0 @@ -/* V2 Calendar - Entry point */ -/* Modular CSS architecture: one file per feature */ - -@import 'calendar-v2-base.css'; -@import 'calendar-v2-layout.css'; -@import 'calendar-v2-events.css'; diff --git a/wwwroot/index.html b/wwwroot/index.html index d1a1629..c507ae5 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -1,76 +1,83 @@ - - - - - - Calendar Plantempus - Week View - - - - - - - - - - -
    - - - - - - - Today - - - - Week 3 - Jan 15 - Jan 21, 2024 - - - - - - - - - - - - - - - Day - Week - Month - - - - Mon-Fri - Mon-Thu - Wed-Fri - Sat-Sun - 7 Days - - - - - - - - - - -
    - - - - - \ No newline at end of file + + + + + + Calendar + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + Demo + + + + Drawer + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/wwwroot/v2.html b/wwwroot/v2.html deleted file mode 100644 index 28e558d..0000000 --- a/wwwroot/v2.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Calendar V2 - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - V2 Demo - - - - Drawer - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - -