Refactors calendar project structure and build configuration

Consolidates V2 codebase into main project directory
Updates build script to support simplified entry points
Removes redundant files and cleans up project organization

Simplifies module imports and entry points for calendar application
This commit is contained in:
Janus C. H. Knudsen 2025-12-17 23:54:25 +01:00
parent 9f360237cf
commit 863b433eba
200 changed files with 2331 additions and 16193 deletions

View file

@ -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": []

View file

@ -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, pipelinerendering 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.
```
---
```

View file

@ -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);

View file

@ -141,7 +141,7 @@
<div class="summary">
<div class="stat-card">
<div class="stat-label">Total CSS Size</div>
<div class="stat-value">17.00 KB</div>
<div class="stat-value">19.26 KB</div>
</div>
<div class="stat-card">
<div class="stat-label">CSS Files</div>
@ -149,11 +149,11 @@
</div>
<div class="stat-card warning">
<div class="stat-label">Unused CSS Rules</div>
<div class="stat-value">23</div>
<div class="stat-value">43</div>
</div>
<div class="stat-card success">
<div class="stat-label">Potential Removal</div>
<div class="stat-value">0.15%</div>
<div class="stat-value">0.27%</div>
</div>
</div>
@ -195,12 +195,12 @@
<tr>
<td><strong>calendar-v2-layout.css</strong></td>
<td>6.39 KB</td>
<td>308</td>
<td>38</td>
<td>48</td>
<td>153</td>
<td>1</td>
<td>8.65 KB</td>
<td>428</td>
<td>56</td>
<td>71</td>
<td>219</td>
<td>2</td>
</tr>
<tr>
@ -237,17 +237,17 @@
<h3>calendar-v2-layout.css</h3>
<p>
<span class="badge badge-success">
3 unused rules
16 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 6275 | After purge: 6203
Original: 7087 | After purge: 6800
</span>
</p>
<details>
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
<div class="rejected-list">
&:hover<br>&[data-levels="resource date"]<br>&[data-levels="team resource date"]
.view-chip<br>&:hover<br>&.active<br>.workweek-dropdown<br>&:focus<br>fieldset<br>legend<br>.resource-checkboxes<br>label<br>input[type="checkbox"]<br>&.btn-small<br>&[data-levels="date"] > swp-day-header<br>&[data-levels="resource date"]<br>&[data-levels="team resource date"]<br>&[data-levels="department resource date"]<br>&[data-hidden="true"]
</div>
</details>
@ -257,19 +257,19 @@
<div class="file-detail">
<h3>calendar-v2-events.css</h3>
<p>
<span class="badge badge-success">
20 unused rules
<span class="badge badge-warning">
26 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 7298 | After purge: 6810
Original: 7047 | After purge: 6504
</span>
</p>
<details>
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
<div class="rejected-list">
&:hover<br>&[data-continues-before="true"]<br>&[data-continues-after="true"]<br>swp-events-layer[data-filter-active="true"] swp-event<br>swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]<br>swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0'])<br>
swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event<br>.is-pink<br>.is-magenta<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-orange<br>.is-deep-orange
&.drag-ghost<br>&:hover<br>&[data-continues-before="true"]<br>&[data-continues-after="true"]<br>swp-events-layer[data-filter-active="true"] swp-event<br>swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"]<br>swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0'])<br>
swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event<br>.is-red<br>.is-pink<br>.is-magenta<br>.is-purple<br>.is-violet<br>.is-deep-purple<br>.is-indigo<br>.is-blue<br>.is-light-blue<br>.is-cyan<br>.is-teal<br>.is-green<br>.is-light-green<br>.is-lime<br>.is-yellow<br>.is-amber<br>.is-orange<br>.is-deep-orange
</div>
</details>
@ -280,13 +280,21 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev
<h3>calendar-v2-base.css</h3>
<p>
<span class="badge badge-success">
0 unused rules
1 unused rules
</span>
<span style="margin-left: 10px; color: #666;">
Original: 1701 | After purge: 1701
Original: 1574 | After purge: 1570
</span>
</p>
<p style="color: #2e7d32; margin-top: 10px;">✅ No unused CSS found!</p>
<details>
<summary style="cursor: pointer; margin-top: 10px;">Show unused selectors</summary>
<div class="rejected-list">
body
</div>
</details>
</div>
</section>
@ -297,12 +305,12 @@ swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-ev
<li>✅ CSS usage is relatively clean.</li>
<li>📦 Consider consolidating similar styles to reduce duplication.</li>
<li>🎨 Review color palette - found 38 unique colors across all files.</li>
<li>🎨 Review color palette - found 39 unique colors across all files.</li>
<li>🔄 Implement a build process to automatically remove unused CSS in production.</li>
</ul>
</section>
<p class="timestamp">Report generated: 11.12.2025, 00.08.52</p>
<p class="timestamp">Report generated: 17.12.2025, 21.36.53</p>
</div>
</body>
</html>

View file

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

View file

@ -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"
]
}
}
}

View file

@ -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();

View file

@ -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 <swp-nav-group> 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<Element, EventListener> = 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);
}
}

View file

@ -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 <swp-view-selector> 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<Element, EventListener> = 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);
}
}

View file

@ -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 <swp-workweek-presets> 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<Element, EventListener> = 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');
}
});
}
}

View file

@ -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 };

View file

@ -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<Configuration> {
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;
}
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -1,10 +0,0 @@
/**
* Time format configuration settings
*/
export interface ITimeFormatConfig {
timezone: string;
use24HourFormat: boolean;
locale: string;
dateFormat: 'locale' | 'technical';
showSeconds: boolean;
}

View file

@ -1,9 +0,0 @@
/**
* Work week configuration settings
*/
export interface IWorkWeekSettings {
id: string;
workDays: number[];
totalDays: number;
firstWorkDay: number;
}

View file

@ -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',
// 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',
// Sync events (4)
// 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;

View file

@ -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<void> {
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<RenderPayload>).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<WorkweekChangePayload>).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<ViewUpdatePayload>).detail;
this.handleViewUpdate(type, values);
}) as EventListener);
});
}
private async handleRenderCommand(viewId: string): Promise<void> {

View file

@ -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[];
}

View file

@ -1,4 +1,3 @@
// Core EventBus using pure DOM CustomEvents
import { IEventLogEntry, IListenerEntry, IEventBus } from '../types/CalendarTypes';
/**
@ -89,7 +88,7 @@ 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);
@ -98,10 +97,8 @@ export class EventBus implements IEventBus {
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);
}
/**
@ -132,12 +129,12 @@ 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' }
};
@ -175,6 +172,3 @@ export class EventBus implements IEventBus {
this.debug = enabled;
}
}
// Create singleton instance
export const eventBus = new EventBus();

View file

@ -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<IColumnInfo[]> {
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));
}
}

View file

@ -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<IColumnInfo[]> {
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;
}
}

5
src/demo/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { createContainer } from '../CompositionRoot';
import { DemoApp } from './DemoApp';
const container = createContainer();
container.resolveType<DemoApp>().init().catch(console.error);

View file

@ -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 = `
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
<swp-event-title>${this.title}</swp-event-title>
${this.description ? `<swp-event-description>${this.description}</swp-event-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);

6
src/entry.ts Normal file
View file

@ -0,0 +1,6 @@
/**
* Calendar - Standalone Entry Point
*/
// Re-export everything from index
export * from './index';

View file

@ -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';

View file

@ -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';
// 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';
// 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<void> {
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<void> {
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<IEventBus>();
// Register configuration instance
builder.registerInstance(config).as<Configuration>();
// Register storage stores (IStore implementations)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(BookingStore).as<IStore>();
builder.registerType(CustomerStore).as<IStore>();
builder.registerType(ResourceStore).as<IStore>();
builder.registerType(EventStore).as<IStore>();
builder.registerType(AuditStore).as<IStore>();
// Register storage and repository services
builder.registerType(IndexedDBContext).as<IndexedDBContext>();
// Register Mock repositories (development/testing - load from JSON files)
// Each entity type has its own Mock repository implementing IApiRepository<T>
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
builder.registerType(MockAuditRepository).as<IApiRepository<IAuditEntry>>();
let calendarMode = 'resource' ;
// Register DataSource and HeaderRenderer based on mode
if (calendarMode === 'resource') {
builder.registerType(ResourceColumnDataSource).as<IColumnDataSource>();
builder.registerType(ResourceHeaderRenderer).as<IHeaderRenderer>();
} else {
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
}
// Register entity services (sync status management)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(EventService).as<EventService>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(ResourceService).as<IEntityService<IResource>>();
builder.registerType(ResourceService).as<ResourceService>();
builder.registerType(AuditService).as<AuditService>();
// Register workers
builder.registerType(SyncManager).as<SyncManager>();
builder.registerType(DataSeeder).as<DataSeeder>();
// Register renderers
// Note: IHeaderRenderer and IColumnRenderer are registered above based on calendarMode
if (calendarMode === 'resource') {
builder.registerType(ResourceColumnRenderer).as<IColumnRenderer>();
} else {
builder.registerType(DateColumnRenderer).as<IColumnRenderer>();
}
builder.registerType(DateEventRenderer).as<IEventRenderer>();
// Register core services and utilities
builder.registerType(DateService).as<DateService>();
builder.registerType(EventStackManager).as<EventStackManager>();
builder.registerType(EventLayoutCoordinator).as<EventLayoutCoordinator>();
builder.registerType(WorkHoursManager).as<WorkHoursManager>();
builder.registerType(URLManager).as<URLManager>();
builder.registerType(TimeFormatter).as<TimeFormatter>();
builder.registerType(PositionUtils).as<PositionUtils>();
// Note: AllDayLayoutEngine is instantiated per-operation with specific dates, not a singleton
builder.registerType(WeekInfoRenderer).as<WeekInfoRenderer>();
builder.registerType(AllDayEventRenderer).as<AllDayEventRenderer>();
builder.registerType(EventRenderingService).as<EventRenderingService>();
builder.registerType(GridRenderer).as<GridRenderer>();
builder.registerType(GridManager).as<GridManager>();
builder.registerType(ScrollManager).as<ScrollManager>();
builder.registerType(NavigationManager).as<NavigationManager>();
builder.registerType(NavigationButtons).as<NavigationButtons>();
builder.registerType(ViewSelector).as<ViewSelector>();
builder.registerType(DragDropManager).as<DragDropManager>();
builder.registerType(AllDayManager).as<AllDayManager>();
builder.registerType(ResizeHandleManager).as<ResizeHandleManager>();
builder.registerType(EdgeScrollManager).as<EdgeScrollManager>();
builder.registerType(HeaderManager).as<HeaderManager>();
builder.registerType(CalendarManager).as<CalendarManager>();
builder.registerType(WorkweekPresets).as<WorkweekPresets>();
builder.registerType(ConfigManager).as<ConfigManager>();
builder.registerType(EventManager).as<EventManager>();
// Build the container
const app = builder.build();
// Initialize database and seed data BEFORE initializing managers
const indexedDBContext = app.resolveType<IndexedDBContext>();
await indexedDBContext.initialize();
const dataSeeder = app.resolveType<DataSeeder>();
await dataSeeder.seedIfEmpty();
// Get managers from container
const eb = app.resolveType<IEventBus>();
const calendarManager = app.resolveType<CalendarManager>();
const eventManager = app.resolveType<EventManager>();
const resizeHandleManager = app.resolveType<ResizeHandleManager>();
const headerManager = app.resolveType<HeaderManager>();
const dragDropManager = app.resolveType<DragDropManager>();
const viewSelectorManager = app.resolveType<ViewSelector>();
const navigationManager = app.resolveType<NavigationManager>();
const navigationButtonsManager = app.resolveType<NavigationButtons>();
const edgeScrollManager = app.resolveType<EdgeScrollManager>();
const allDayManager = app.resolveType<AllDayManager>();
const urlManager = app.resolveType<URLManager>();
const workweekPresetsManager = app.resolveType<WorkweekPresets>();
const configManager = app.resolveType<ConfigManager>();
// Initialize managers
await calendarManager.initialize?.();
await resizeHandleManager.initialize?.();
// Resolve AuditService (starts listening for entity events)
const auditService = app.resolveType<AuditService>();
// Resolve SyncManager (starts background sync automatically)
const syncManager = app.resolveType<SyncManager>();
// 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);
});
}
// 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';

View file

@ -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<IDragMouseEnterHeaderEventPayload>).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<IDragStartEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let dragEndPayload: IDragEndEventPayload = (event as CustomEvent<IDragEndEventPayload>).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<IHeaderReadyEventPayload>).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<void> {
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<void> {
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 = `
<svg width="12" height="8" viewBox="0 0 12 8">
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`;
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 = `<span>+${overflowCount + 1} more</span>`;
} 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 = `<span>+${overflowCount + 1} more</span>`;
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();
});
}
}

View file

@ -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<void> {
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
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,220 +1,140 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near edges
* Uses time-based scrolling with 2-zone system for variable speed
* 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 { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedClone: HTMLElement | null = null;
private draggedElement: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false; // Track if edge-scroll is active
private isScrolling = false;
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
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.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();
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 {
// Listen to drag events from DragDropManager
this.eventBus.on('drag:start', (event: Event) => {
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedClone = payload.draggedClone;
this.draggedElement = payload.element;
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();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
}
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;
}
this.isScrolling = false;
this.lastTs = 0;
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
this.scrollRAF = requestAnimationFrame(this.scrollTick);
}
}
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', {});
}
this.setScrollingState(false);
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private handleScroll(): void {
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 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;
this.rect ??= this.scrollableContent.getBoundingClientRect();
if (!this.scrollableContent) {
this.stopDrag();
return;
}
const velocity = this.calculateVelocity();
// 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));
}
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 {
// 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();
}
this.setScrollingState(false);
}
}
this.scrollRAF = requestAnimationFrame(this.scrollTick);
};
}

View file

@ -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<string> = new Set();
private isFilterActive: boolean = false;
private frameRequest: number | null = null;
private fuse: Fuse<ICalendarEvent> | 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)
};
}
}

View file

@ -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 (ABC 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;
}
}

View file

@ -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<ICalendarEvent>
) {
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<void> {
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<ICalendarEvent[]> {
const events = await this.eventService.getAll();
return copy ? [...events] : events;
}
/**
* Get event by ID from service
*/
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
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<boolean> {
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<ICalendarEvent[]> {
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<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
// 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<ICalendarEvent>): Promise<ICalendarEvent | null> {
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<boolean> {
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;
}
}
}

View file

@ -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<string, IStackLink> {
const stackLinks = new Map<string, IStackLink>();
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 = '';
}
}

View file

@ -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<void> {
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
});
}
}

View file

@ -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<IDragMouseEnterHeaderEventPayload>).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<IDragMouseLeaveHeaderEventPayload>).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<void> {
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);
}
}

View file

@ -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<INavButtonClickedEventPayload>).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<void> {
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
});
});
}
}

View file

@ -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<SwpEventEl>('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<HTMLElement>('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<HTMLElement>('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);
}
}
}

View file

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

View file

@ -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<string, IDayWorkHours | 'off'> {
const workHoursMap = new Map<string, IDayWorkHours | 'off'>();
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<void> {
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()];
}
}

View file

@ -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();
}
}

View file

@ -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`);
}
}
}
}

View file

@ -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 = `
<swp-day-name>${dayName}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
header.dataset.columnId = columnInfo.identifier;
header.dataset.groupId = columnInfo.groupId;
calendarHeader.appendChild(header);
});
}
}

View file

@ -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[];
}
}

View file

@ -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<IDragStartEventPayload>).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<IDragMoveEventPayload>).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<IDragEndEventPayload>).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<IDragColumnChangeEventPayload>).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<IDragMouseLeaveHeaderEventPayload>).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<IDragMouseEnterColumnEventPayload>).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<IResizeEndEventPayload>).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);
}
}

View file

@ -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:
* =====================
* <swp-calendar-container>
* <swp-header-spacer /> GridRenderer
* <swp-time-axis> GridRenderer
* <swp-hour-marker>00:00</...> GridRenderer (iterates hours)
* </swp-time-axis>
* <swp-grid-container> GridRenderer
* <swp-calendar-header> GridRenderer creates container
* <swp-day-header /> DateHeaderRenderer (iterates dates)
* </swp-calendar-header>
* <swp-scrollable-content> GridRenderer
* <swp-time-grid> GridRenderer
* <swp-grid-lines /> GridRenderer
* <swp-day-columns> GridRenderer creates container
* <swp-day-column /> ColumnRenderer (iterates dates)
* </swp-day-columns>
* </swp-time-grid>
* </swp-scrollable-content>
* </swp-grid-container>
* </swp-calendar-container>
*
* 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;
}
}

View file

@ -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`);
}
}
}

View file

@ -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 = `<img class="swp-resource-avatar" src="${resource.avatarUrl}" alt="${resource.displayName}" />`;
} else {
// Fallback: initials
const initials = this.getInitials(resource.displayName);
const bgColor = resource.color || '#6366f1';
avatarHtml = `<span class="swp-resource-initials" style="background-color: ${bgColor}">${initials}</span>`;
}
header.innerHTML = `
<div class="swp-resource-header">
${avatarHtml}
<span class="swp-resource-name">${resource.displayName}</span>
</div>
`;
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);
}
}

View file

@ -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');
});
}
});
});
}
}

View file

@ -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<IBooking> for generic sync infrastructure.
* Used by SyncManager to send queued booking operations to the server.
*/
export class ApiBookingRepository implements IApiRepository<IBooking> {
readonly entityType: EntityType = 'Booking';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(booking: IBooking): Promise<IBooking> {
// 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<IBooking>): Promise<IBooking> {
// 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<void> {
// 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<IBooking[]> {
// 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');
}
}

View file

@ -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<ICustomer> for generic sync infrastructure.
* Used by SyncManager to send queued customer operations to the server.
*/
export class ApiCustomerRepository implements IApiRepository<ICustomer> {
readonly entityType: EntityType = 'Customer';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(customer: ICustomer): Promise<ICustomer> {
// 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<ICustomer>): Promise<ICustomer> {
// 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<void> {
// 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<ICustomer[]> {
// 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');
}
}

View file

@ -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<ICalendarEvent> 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<ICalendarEvent> {
readonly entityType: EntityType = 'Event';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
// 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<ICalendarEvent>): Promise<ICalendarEvent> {
// 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<void> {
// 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<ICalendarEvent[]> {
// 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<void> {
// 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');
}
}

View file

@ -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<IResource> for generic sync infrastructure.
* Used by SyncManager to send queued resource operations to the server.
*/
export class ApiResourceRepository implements IApiRepository<IResource> {
readonly entityType: EntityType = 'Resource';
private apiEndpoint: string;
constructor(config: Configuration) {
this.apiEndpoint = config.apiEndpoint;
}
/**
* Send create operation to API
*/
async sendCreate(resource: IResource): Promise<IResource> {
// 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<IResource>): Promise<IResource> {
// 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<void> {
// 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<IResource[]> {
// 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');
}
}

View file

@ -3,58 +3,31 @@ import { EntityType } from '../types/CalendarTypes';
/**
* IApiRepository<T> - 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<T>
* - Operations are routed at runtime based on IQueueOperation.dataEntity.typename
* Used by DataSeeder to fetch initial data and by SyncManager for sync operations.
*/
export interface IApiRepository<T> {
/**
* 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<T> - Created entity from server (with server-generated fields)
* @throws Error if API call fails
*/
sendCreate(data: T): Promise<T>;
/**
* Send update operation to backend API
*
* @param id - Entity ID
* @param updates - Partial entity data to update
* @returns Promise<T> - Updated entity from server
* @throws Error if API call fails
*/
sendUpdate(id: string, updates: Partial<T>): Promise<T>;
/**
* Send delete operation to backend API
*
* @param id - Entity ID to delete
* @returns Promise<void>
* @throws Error if API call fails
*/
sendDelete(id: string): Promise<void>;
/**
* Fetch all entities from backend API
* Used for initial sync and full refresh
*
* @returns Promise<T[]> - Array of all entities
* @throws Error if API call fails
*/
fetchAll(): Promise<T[]>;
}

View file

@ -26,7 +26,7 @@ export class MockAuditRepository implements IApiRepository<IAuditEntry> {
return entity;
}
async sendUpdate(_id: string, entity: IAuditEntry): Promise<IAuditEntry> {
async sendUpdate(_id: string, _entity: IAuditEntry): Promise<IAuditEntry> {
// Audit entries are immutable - updates should not happen
throw new Error('Audit entries cannot be updated');
}

View file

@ -1,5 +1,4 @@
import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
import { EntityType } from '../types/CalendarTypes';
import { IBooking, IBookingService, BookingStatus, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawBookingData {
@ -25,22 +24,11 @@ interface RawBookingService {
/**
* 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<IBooking> {
public readonly entityType: EntityType = 'Booking';
private readonly dataUrl = 'data/mock-bookings.json';
/**
* Fetch all bookings from mock JSON file
*/
public async fetchAll(): Promise<IBooking[]> {
try {
const response = await fetch(this.dataUrl);
@ -50,7 +38,6 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
}
const rawData: RawBookingData[] = await response.json();
return this.processBookingData(rawData);
} catch (error) {
console.error('Failed to load booking data:', error);
@ -58,32 +45,28 @@ export class MockBookingRepository implements IApiRepository<IBooking> {
}
}
/**
* NOT SUPPORTED - MockBookingRepository is read-only
*/
public async sendCreate(booking: IBooking): Promise<IBooking> {
public async sendCreate(_booking: IBooking): Promise<IBooking> {
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<IBooking>): Promise<IBooking> {
public async sendUpdate(_id: string, _updates: Partial<IBooking>): Promise<IBooking> {
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<void> {
public async sendDelete(_id: string): Promise<void> {
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),
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
}));
}

View file

@ -1,5 +1,4 @@
import { ICustomer } from '../types/CustomerTypes';
import { EntityType } from '../types/CalendarTypes';
import { ICustomer, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawCustomerData {
@ -7,28 +6,17 @@ interface RawCustomerData {
name: string;
phone: string;
email?: string;
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
[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<ICustomer> {
public readonly entityType: EntityType = 'Customer';
private readonly dataUrl = 'data/mock-customers.json';
/**
* Fetch all customers from mock JSON file
*/
public async fetchAll(): Promise<ICustomer[]> {
try {
const response = await fetch(this.dataUrl);
@ -38,7 +26,6 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
}
const rawData: RawCustomerData[] = await response.json();
return this.processCustomerData(rawData);
} catch (error) {
console.error('Failed to load customer data:', error);
@ -46,30 +33,25 @@ export class MockCustomerRepository implements IApiRepository<ICustomer> {
}
}
/**
* NOT SUPPORTED - MockCustomerRepository is read-only
*/
public async sendCreate(customer: ICustomer): Promise<ICustomer> {
public async sendCreate(_customer: ICustomer): Promise<ICustomer> {
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<ICustomer>): Promise<ICustomer> {
public async sendUpdate(_id: string, _updates: Partial<ICustomer>): Promise<ICustomer> {
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<void> {
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
}
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
return data.map((customer): ICustomer => ({
...customer,
id: customer.id,
name: customer.name,
phone: customer.phone,
email: customer.email,
metadata: customer.metadata,
syncStatus: 'synced' as const
}));
}

View file

@ -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<string, any>; // 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<string, unknown>;
[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<ICalendarEvent> {
public readonly entityType: EntityType = 'Event';
@ -53,7 +38,6 @@ export class MockEventRepository implements IApiRepository<ICalendarEvent> {
}
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<ICalendarEvent> {
}
}
/**
* NOT SUPPORTED - MockEventRepository is read-only
*/
public async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
public async sendCreate(_event: ICalendarEvent): Promise<ICalendarEvent> {
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<ICalendarEvent>): Promise<ICalendarEvent> {
public async sendUpdate(_id: string, _updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
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<void> {
public async sendDelete(_id: string): Promise<void> {
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<ICalendarEvent> {
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
};
});

View file

@ -1,6 +1,6 @@
import { IResource, ResourceType } from '../types/ResourceTypes';
import { EntityType } from '../types/CalendarTypes';
import { IResource, ResourceType, EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
import { IWeekSchedule } from '../types/ScheduleTypes';
interface RawResourceData {
id: string;
@ -10,28 +10,17 @@ interface RawResourceData {
avatarUrl?: string;
color?: string;
isActive?: boolean;
metadata?: Record<string, any>;
[key: string]: unknown;
defaultSchedule?: IWeekSchedule;
metadata?: Record<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<IResource> {
public readonly entityType: EntityType = 'Resource';
private readonly dataUrl = 'data/mock-resources.json';
/**
* Fetch all resources from mock JSON file
*/
public async fetchAll(): Promise<IResource[]> {
try {
const response = await fetch(this.dataUrl);
@ -41,7 +30,6 @@ export class MockResourceRepository implements IApiRepository<IResource> {
}
const rawData: RawResourceData[] = await response.json();
return this.processResourceData(rawData);
} catch (error) {
console.error('Failed to load resource data:', error);
@ -49,31 +37,29 @@ export class MockResourceRepository implements IApiRepository<IResource> {
}
}
/**
* NOT SUPPORTED - MockResourceRepository is read-only
*/
public async sendCreate(resource: IResource): Promise<IResource> {
public async sendCreate(_resource: IResource): Promise<IResource> {
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<IResource>): Promise<IResource> {
public async sendUpdate(_id: string, _updates: Partial<IResource>): Promise<IResource> {
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<void> {
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
}
private processResourceData(data: RawResourceData[]): IResource[] {
return data.map((resource): IResource => ({
...resource,
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
}));
}

Some files were not shown because too many files have changed in this diff Show more