Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
200 changed files with 2331 additions and 16193 deletions
Showing only changes of commit 863b433eba - Show all commits

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