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 - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - -