Merge branch 'wip-colors'

This commit is contained in:
Janus C. H. Knudsen 2025-10-08 23:07:12 +02:00
commit b6f2aba398
109 changed files with 18703 additions and 8572 deletions

1
.gitignore vendored
View file

@ -30,3 +30,4 @@ Thumbs.db
*.suo
*.userosscache
*.sln.docstates
js/

BIN
.workbench/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

217
.workbench/review.txt Normal file
View file

@ -0,0 +1,217 @@
De 6 vigtigste fund (med fixes)
Gruppering kan “brygge” mellem to grupper uden at merge dem
groupEventsByStartTime finder første eksisterende gruppe med konflikt og lægger eventet deri. Hvis et nyt event konflikter med flere grupper, bliver grupperne ikke merged → inkonsistente “grid”-klumper. Løs: merge alle matchende grupper eller brug union-find/sweep-line konfliktsæt.
EventStackManager
ContainerType er “GRID” for alle grupper >1 — også ved dybe overlaps
decideContainerType returnerer altid 'GRID' når events.length > 1. Det kan være tilsigtet, men så skal du være tryg ved, at lange overlappende events, der kun næsten starter samtidigt, stadig pakkes i kolonner fremfor “stacking”. Overvej: GRID kun når samtidighed er vigtigere end varighed, ellers fald tilbage til STACKING.
EventStackManager
Stack level-algoritmen kan eskalere niveauer unødigt
createOptimizedStackLinks sætter stackLevel = max(overlappende tidligere) + 1. Det er mere “stak-tårn” end “før-ledig-kolonne” og giver højere niveauer end nødvendigt (ikke minimal farvelægning). Løs: interval partitioning med min-heap (giver laveste ledige level).
EventStackManager
Grid-top beregnes fra ét event, men børn positioneres relativt til containerStart
I koordinatoren bruges earliestEvent til top, og renderer bruger earliestEvent.start som containerStart. Det er ok — men sørg for, at earliestEvent garanteret er det tidligste i gruppen og sortér eksplicit inden brug (robusthed mod fremtidige ændringer).
EventLayoutCoordinator
EventRenderer
Drag bruger rå new Date(...) i stedet for DateService
Kan give TZ/DST-glitches. Brug samme parse/logik som resten.
EventRenderer
Ingen reflow af kolonne efter drop
handleDragEnd normaliserer DOM men recalculerer ikke layout → forkert stacking/margin efter flyt. Kald din kolonne-pipeline igen for den berørte kolonne.
EventRenderer
Bonus: getEventsForColumn matcher kun start-dato === kolonnedato; events der krydser midnat forsvinder. Overvej interval-overlap mod døgnets [00:0023:59:59.999].
EventRenderer
Målrettede patches (små og sikre)
A) Merge grupper når et event rammer flere (EventStackManager)
Erstat den nuværende “find første gruppe”-logik med merge af alle matchende:
// inde i groupEventsByStartTime
const matches: number[] = [];
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const conflict = group.events.some(ge => {
const s2s = Math.abs(event.start.getTime() - ge.start.getTime()) / 60000;
if (s2s <= thresholdMinutes) return true;
const e2s = (ge.end.getTime() - event.start.getTime()) / 60000;
if (e2s > 0 && e2s <= thresholdMinutes) return true;
const rev = (event.end.getTime() - ge.start.getTime()) / 60000;
if (rev > 0 && rev <= thresholdMinutes) return true;
return false;
});
if (conflict) matches.push(gi);
}
if (matches.length === 0) {
groups.push({ events: [event], containerType: 'NONE', startTime: event.start });
} else {
// merge alle matchende grupper + dette event
const base = matches[0];
groups[base].events.push(event);
for (let i = matches.length - 1; i >= 1; i--) {
const idx = matches[i];
groups[base].events.push(...groups[idx].events);
groups.splice(idx, 1);
}
// opdatér startTime til min start
groups[base].startTime = new Date(
Math.min(...groups[base].events.map(e => e.start.getTime()))
);
}
Nu undgår du “brobygning” der splitter reelt sammenhængende grupper.
EventStackManager
B) Minimal stack level med min-heap (EventStackManager)
Udskift level-tildeling med klassisk interval partitioning:
public createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
const res = new Map<string, StackLink>();
if (!events.length) return res;
const sorted = [...events].sort((a,b)=> a.start.getTime() - b.start.getTime());
type Col = { level: number; end: number };
const cols: Col[] = []; // min-heap på end
const push = (c: Col) => { cols.push(c); cols.sort((x,y)=> x.end - y.end); };
for (const ev of sorted) {
const t = ev.start.getTime();
// find første kolonne der er fri
let placed = false;
for (let i = 0; i < cols.length; i++) {
if (cols[i].end <= t) { cols[i].end = ev.end.getTime(); res.set(ev.id, { stackLevel: cols[i].level }); placed = true; break; }
}
if (!placed) { const level = cols.length; push({ level, end: ev.end.getTime() }); res.set(ev.id, { stackLevel: level }); }
}
// evt. byg prev/next separat hvis nødvendigt
return res;
}
Dette giver laveste ledige niveau og undgår “trappetårne”.
EventStackManager
C) Konsolidér margin/zIndex + brug DateService i drag (EventRenderer)
Lad StackManager styre marginLeft konsekvent (og undgå magic numbers):
// renderGridGroup
groupElement.style.top = `${gridGroup.position.top}px`;
this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); // i stedet for *15
this.stackManager.applyStackLinkToElement(groupElement, { stackLevel: gridGroup.stackLevel });
EventRenderer
Brug DateService i drag:
public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone || !payload.columnBounds) return;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(payload.columnBounds.date) ?? new Date(payload.columnBounds.date);
swp.updatePosition(colDate, payload.snappedY);
}
public handleColumnChange(e: DragColumnChangeEventPayload): void {
if (!this.draggedClone) return;
const layer = e.newColumn.element.querySelector('swp-events-layer');
if (layer && this.draggedClone.parentElement !== layer) {
layer.appendChild(this.draggedClone);
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(e.newColumn.date) ?? new Date(e.newColumn.date);
swp.updatePosition(colDate, currentTop);
}
}
EventRenderer
D) Reflow efter drop (EventRenderer)
Genberegn layout for den berørte kolonne:
public handleDragEnd(id: string, original: HTMLElement, clone: HTMLElement, finalColumn: ColumnBounds): void {
if (!clone || !original) { console.warn('Missing clone/original'); return; }
this.fadeOutAndRemove(original);
const cid = clone.dataset.eventId;
if (cid && cid.startsWith('clone-')) clone.dataset.eventId = cid.replace('clone-','');
clone.classList.remove('dragging');
const layer = finalColumn.element.querySelector('swp-events-layer') as HTMLElement | null;
if (layer) {
// 1) Hent kolonnens events fra din model/state (inkl. opdateret event)
const columnEvents: CalendarEvent[] = /* ... */;
// 2) Ryd
layer.querySelectorAll('swp-event, swp-event-group').forEach(el => el.remove());
// 3) Render igen via layout
this.renderColumnEvents(columnEvents, layer);
}
this.draggedClone = null;
this.originalEvent = null;
}
EventRenderer
E) Døgn-overlap i kolonnefilter (EventRenderer)
Hvis ønsket (ellers behold din nuværende):
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const d = column.dataset.date; if (!d) return [];
const start = this.dateService.parseISODate(`${d}T00:00:00`);
const end = this.dateService.parseISODate(`${d}T23:59:59.999`);
return events.filter(ev => ev.start < end && ev.end > start);
}
EventRenderer
F) Eksplicit “earliest” i GRID (Coordinator)
Gør det robust i tilfælde af usorteret input:
const earliestEvent = [...gridCandidates].sort((a,b)=> a.start.getTime()-b.start.getTime())[0];
const pos = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
EventLayoutCoordinator
Mini-noter
allocateColumns er O(n²); det er fint for typiske dagvisninger. Hvis I ser >100 events/kolonne, kan I optimere med sweep-line + min-heap.
EventLayoutCoordinator
Overvej at lade koordinatoren returnere rene layout-maps (id → {level, z, margin}) og holde DOM-påføring 100% i renderer — det gør DnD-”reflow” enklere at teste.
EventLayoutCoordinator
EventRenderer

15
.workbench/scenarie3.html Normal file
View file

@ -0,0 +1,15 @@
<swp-day-column data-date="2025-10-07" style="--before-work-height: 240px; --after-work-top: 880px;"><swp-events-layer><swp-event-group class="cols-2 stack-level-3" data-stack-link="{&quot;stackLevel&quot;:3}" style="top: 321px; margin-left: 45px; z-index: 103;"><div style="position: relative;"><swp-event data-event-id="S3B" data-title="Scenario 3: Event B" data-start="2025-10-07T08:00:00.000Z" data-end="2025-10-07T11:00:00.000Z" data-type="work" data-duration="180" style="position: absolute; top: 0px; height: 237px; left: 0px; right: 0px;">
<swp-event-time data-duration="180">10:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 3: Event B</swp-event-title>
</swp-event></div><div style="position: relative;"><swp-event data-event-id="S3D" data-title="Scenario 3: Event D" data-start="2025-10-07T10:30:00.000Z" data-end="2025-10-07T11:30:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 200px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 3: Event D</swp-event-title>
</swp-event></div></swp-event-group><swp-event data-event-id="S3A" data-title="Scenario 3: Event A" data-start="2025-10-07T07:00:00.000Z" data-end="2025-10-07T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0,&quot;next&quot;:&quot;S3B&quot;}" style="position: absolute; top: 241px; height: 477px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 3: Event A</swp-event-title>
</swp-event><swp-event data-event-id="S3C" data-title="Scenario 3: Event C" data-start="2025-10-07T09:00:00.000Z" data-end="2025-10-07T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2,&quot;prev&quot;:&quot;S3B&quot;}" style="position: absolute; top: 401px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 3: Event C</swp-event-title>
</swp-event><swp-event data-event-id="S4A" data-title="Scenario 4: Event A" data-start="2025-10-07T14:00:00.000Z" data-end="2025-10-07T20:00:00.000Z" data-type="meeting" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0,&quot;next&quot;:&quot;S4B&quot;}" style="position: absolute; top: 801px; height: 477px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">16:00 - 22:00</swp-event-time>
</swp-event></swp-events-layer></swp-day-column>

11
.workbench/scenarie9.html Normal file
View file

@ -0,0 +1,11 @@
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 481px; margin-left: 0px; z-index: 100;"><div style="position: relative;"><swp-event data-event-id="S9A" data-title="Scenario 9: Event A" data-start="2025-10-09T10:00:00.000Z" data-end="2025-10-09T11:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 9: Event A</swp-event-title>
</swp-event></div><div style="position: relative;"><swp-event data-event-id="S9B" data-title="Scenario 9: Event B" data-start="2025-10-09T10:30:00.000Z" data-end="2025-10-09T11:30:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 40px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 9: Event B</swp-event-title>
</swp-event></div></swp-event-group>
<swp-event data-event-id="S9C" data-title="Scenario 9: Event C" data-start="2025-10-09T11:15:00.000Z" data-end="2025-10-09T13:00:00.000Z" data-type="work" data-duration="105" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 581px; height: 137px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="105">13:15 - 15:00</swp-event-time>
<swp-event-title>Scenario 9: Event C</swp-event-title>
</swp-event>

View file

@ -0,0 +1,81 @@
## Testplan Stack link (`data-stack-link`) & z-index
### A. Regler (krav som testes)
- **SL1**: Hvert event har en gyldig `data-stack-link` JSON med felterne `{ prev, stackLevel }`.
- **SL2**: `stackLevel` ≥ 1 og heltal. Nederste event i en stack har `prev = null` og `stackLevel = 1`.
- **SL3**: `prev` refererer til **eksisterende** event-ID i **samme lane** (ingen cross-lane links).
- **SL4**: Kæden er **acyklisk** (ingen loops) og uden “dangling” referencer.
- **SL5**: For en given stack er levels **kontiguøse** (1..N uden huller).
- **SL6**: Ved **flyt/resize/slet** genberegnes stack-links deterministisk (samme input ⇒ samme output).
- **Z1**: z-index er en **strengt voksende funktion** af `stackLevel` (fx `zIndex = base + stackLevel`).
- **Z2**: For overlappende events i **samme lane** gælder: højere `stackLevel` **renderes visuelt ovenpå** lavere level (ingen tekst skjules af et lavere level).
- **Z3**: z-index må **ikke** afhænge af DOM-indsættelsesrækkefølge—kun af `stackLevel` (og evt. lane-offset).
- **Z4** (valgfrit): På tværs af lanes kan systemet enten bruge samme base eller lane-baseret offset (fx `zIndex = lane*100 + stackLevel`). Uanset valg må events i **samme lane** aldrig blive skjult af events i en **anden** lane, når de overlapper visuelt.
### B. Unit tests (logik for stack link)
1. **Basestack**
*Givet* en enkelt event A 10:0011:00, *Når* stack beregnes, *Så* `A.prev=null` og `A.stackLevel=1` (SL2).
2. **Simpel overlap**
*Givet* A 10:0013:00 og B 10:4511:15 i samme lane, *Når* stack beregnes, *Så* `B.prev='A'` og `B.stackLevel=2` (SL1SL3).
3. **Fler-leddet overlap**
*Givet* A 1013, B 10:4511:15, C 11:0011:30, *Når* stack beregnes, *Så* `B.stackLevel=2`, `C.stackLevel≥2`, ingen huller i levels (SL5).
4. **Ingen overlap**
*Givet* A 10:0011:00 og B 11:3012:00 i samme lane, *Når* stack beregnes, *Så* `A.stackLevel=1`, `B.stackLevel=1`, `prev=null` for begge (SL2).
5. **Cross-lane isolation**
*Givet* A(lane1) 1013 og B(lane2) 10:1511:00, *Når* stack beregnes, *Så* `B.prev` **må ikke** pege på A (SL3).
6. **Acyklisk garanti**
*Givet* en vilkårlig mængde overlappende events, *Når* stack beregnes, *Så* kan traversal fra top → `prev` aldrig besøge samme ID to gange (SL4).
7. **Sletning i kæde**
*Givet* A→B→C (`prev`-kæde), *Når* B slettes, *Så* peger C.prev nu på A (eller `null` hvis A ikke findes), og levels reindekseres 1..N (SL5SL6).
8. **Resize der fjerner overlap**
*Givet* A 1013 og B 10:4511:15 (stacked), *Når* B resizes til 13:0013:30, *Så* `B.prev=null`, `B.stackLevel=1` (SL6).
9. **Determinisme**
*Givet* samme inputliste i samme sortering, *Når* stack beregnes to gange, *Så* er output (prev/stackLevel pr. event) identisk (SL6).
### C. Integration/DOM tests (z-index & rendering)
10. **Z-index mapping**
*Givet* mapping `zIndex = base + stackLevel`, *Når* tre overlappende events har levels 1,2,3, *Så* er `zIndex` hhv. stigende og uden lighed (Z1).
11. **Visuel prioritet**
*Givet* to overlappende events i samme lane med levels 1 (A) og 2 (B), *Når* kalenderen renderes, *Så* kan Bs titel læses fuldt ud, og As ikke dækker B (Z2).
12. **DOM-orden er irrelevant**
*Givet* to overlappende events, *Når* DOM-indsættelsesrækkefølgen byttes, *Så* er visuel orden uændret, styret af z-index (Z3).
13. **Lane-isolation**
*Givet* A(lane1, level 2) og B(lane2, level 1), *Når* de geometrisk overlapper (smal viewport), *Så* skjuler lane2 ikke lane1 i strid med reglen—afhængigt af valgt z-index strategi (Z4). Dokumentér valgt strategi.
14. **Tekst-visibility**
*Givet* N overlappende events, *Når* der renderes, *Så* er der ingen CSS-egenskaber (opacity/clip/overflow) der gør højere level mindre synlig end lavere (Z2).
### D. Scenarie-baserede tests (17)
15. **S1 Overlap ovenpå**
Lunch `prev=Excursion`, `stackLevel=2`; `zIndex(Lunch) > zIndex(Excursion)`.
16. **S2 Flere overlappende**
Lunch og Breakfast har `stackLevel≥2`; ingen huller 1..N; z-index følger levels.
17. **S3 Side-by-side**
Overlappende events i samme lane har stigende `stackLevel`; venstre offset stiger med level; z-index følger levels.
18. **S4 Sekvens**
For hvert overlap i sekvens: korrekt `prev` til nærmeste base; contiguøse levels; z-index stigende.
19. **S5 <30 min ⇒ lane 2**
Lunch i lane 2; ingen `prev` der peger cross-lane; levels starter ved 1 i begge lanes; z-index valideres pr. lane.
20. **S6 Stack + lane**
Lane 1: Excursion & Breakfast (levels 1..N). Lane 2: Lunch (level 1). Ingen cross-lane `prev`. Z-index korrekt i lane 1.
21. **S7 Frivillig lane 2**
Events i lane 2 har egne levels startende på 1; z-index følger levels i hver lane.
### E. Edge cases
22. **Samme starttid**
To events med identisk start i samme lane fordeles deterministisk: det først behandlede bliver base (`level=1`), det næste `level=2`. Z-index følger.
23. **Mange levels**
*Givet* 6 overlappende events, *Når* der renderes, *Så* er levels 1..6 uden huller og z-index 6 er visuelt øverst.
24. **Ugyldigt JSON**
*Givet* en defekt `data-stack-link`, *Når* komponenten loader, *Så* logges fejl og stack genberegnes fra start/end (self-healing), hvorefter valid `data-stack-link` skrives (SL1, SL6).
### F. Implementationsnoter (hjælp til test)
- Z-index funktion bør være **enkel og auditérbar**, fx: `zIndex = 100 + stackLevel` (samme lane) eller `zIndex = lane*100 + stackLevel` (multi-lane isolation).
- Test for acykliskhed: lav traversal fra hver node: gentagen ID ⇒ fejl.
- Test for contiguity: hent alle `stackLevel` i en stack, sortér, forvent `[1..N]` uden huller.
- Test for cross-lane: sammenlign `event.dataset.lane` for `id` og dets `prev`—de skal være ens.

View file

@ -40,7 +40,7 @@ eventBus.on('calendar:view-changed', (event) => { /* handle */ });
```
#### Manager Pattern
Each manager is instantiated in `src/index.ts` and handles a specific domain:
Each manager is instantiated via ManagerFactory in `src/index.ts` and handles a specific domain:
- **CalendarManager**: Main coordinator, initializes other managers
- **ViewManager**: Handles day/week/month view switching
- **NavigationManager**: Prev/next/today navigation
@ -48,14 +48,18 @@ Each manager is instantiated in `src/index.ts` and handles a specific domain:
- **EventRenderer**: Visual rendering of events in the grid
- **GridManager**: Creates and maintains the calendar grid structure
- **ScrollManager**: Handles scroll position and time indicators
- **DataManager**: Mock data loading and event data transformation
- **DragDropManager**: Drag & drop functionality for events
### Project Structure
```
src/
├── constants/ # Enums and constants (EventTypes)
├── constants/ # Enums and constants (CoreEvents)
├── core/ # Core functionality (EventBus, CalendarConfig)
├── factories/ # ManagerFactory for dependency injection
├── interfaces/ # TypeScript interfaces
├── managers/ # Manager classes (one per domain)
├── renderers/ # Event rendering services
├── strategies/ # View strategy pattern implementations
├── types/ # TypeScript type definitions
└── utils/ # Utility functions (DateUtils, PositionUtils)
@ -72,18 +76,36 @@ Modular CSS structure without external frameworks:
- `calendar-events-css.css`: Event styling and colors
- `calendar-layout-css.css`: Grid and layout
- `calendar-popup-css.css`: Popup and modal styles
- `calendar-month-css.css`: Month view specific styles
### Event Naming Convention
Events follow the pattern `category:action`:
### Event System
The application uses CoreEvents enum for type-safe event handling. Events follow the pattern `category:action`:
- `calendar:*` - General calendar events
- `grid:*` - Grid-related events
- `event:*` - Event data changes
- `navigation:*` - Navigation actions
- `view:*` - View changes
Core events are centralized in `src/constants/CoreEvents.ts` to maintain consistency across the application.
### Configuration System
CalendarConfig singleton (`src/core/CalendarConfig.ts`) manages:
- Grid settings (hour height, snap intervals, time boundaries)
- View configurations (day/week/month settings)
- Work week presets (standard, compressed, midweek, weekend, fullweek)
- Resource-based calendar mode support
### TypeScript Configuration
- Target: ES2020
- Module: ESNext
- Strict mode enabled
- Source maps enabled
- Output directory: `./js`
- Output directory: `wwwroot/js`
### Build System
Uses esbuild for fast TypeScript compilation:
- Entry point: `src/index.ts`
- Output: `wwwroot/js/calendar.js` (single bundled file)
- Platform: Browser
- Format: ESM
- Source maps: Inline for development

View file

@ -0,0 +1,578 @@
# Cyclomatic Complexity Analysis Report
**Calendar Plantempus Project**
Generated: 2025-10-04
---
## Executive Summary
This report analyzes the cyclomatic complexity of the Calendar Plantempus TypeScript codebase, focusing on identifying methods that exceed recommended complexity thresholds and require refactoring.
### Key Metrics
| Metric | Value |
|--------|-------|
| **Total Files Analyzed** | 6 |
| **Total Methods Analyzed** | 74 |
| **Methods with Complexity >10** | 4 (5.4%) |
| **Methods with Complexity 6-10** | 5 (6.8%) |
| **Methods with Complexity 1-5** | 65 (87.8%) |
### Complexity Distribution
```
■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ Low (1-5): 87.8%
■■■ Medium (6-10): 6.8%
■ High (>10): 5.4%
```
### Overall Assessment
✅ **Strengths:**
- 87.8% of methods have acceptable complexity
- Web Components (SwpEventElement) demonstrate excellent design
- Rendering services show clean separation of concerns
🔴 **Critical Issues:**
- 4 methods exceed complexity threshold of 10
- Stack management logic is overly complex (complexity 18!)
- Drag & drop handlers need refactoring
---
## Detailed File Analysis
### 1. DragDropManager.ts
**File:** `src/managers/DragDropManager.ts`
**Overall Complexity:** HIGH ⚠️
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `init()` | 88-133 | 7 | 🟡 Medium | Event listener setup could be extracted |
| `handleMouseDown()` | 135-168 | 5 | ✅ OK | Acceptable complexity |
| `handleMouseMove()` | 173-260 | **15** | 🔴 **Critical** | **NEEDS IMMEDIATE REFACTORING** |
| `handleMouseUp()` | 265-310 | 4 | ✅ OK | Clean implementation |
| `cleanupAllClones()` | 312-320 | 2 | ✅ OK | Simple utility method |
| `cancelDrag()` | 325-350 | 3 | ✅ OK | Straightforward cleanup |
| `calculateDragPosition()` | 355-364 | 2 | ✅ OK | Simple calculation |
| `calculateSnapPosition()` | 369-377 | 1 | ✅ OK | Base complexity |
| `checkAutoScroll()` | 383-403 | 5 | ✅ OK | Could be simplified slightly |
| `startAutoScroll()` | 408-444 | 6 | 🟡 Medium | Autoscroll logic could be extracted |
| `stopAutoScroll()` | 449-454 | 2 | ✅ OK | Simple cleanup |
| `detectDropTarget()` | 468-483 | 4 | ✅ OK | Clear DOM traversal |
| `handleHeaderMouseEnter()` | 488-516 | 4 | ✅ OK | Clean event handling |
| `handleHeaderMouseLeave()` | 521-544 | 4 | ✅ OK | Clean event handling |
**Decision Points in handleMouseMove():**
1. `if (event.buttons === 1)` - Check if mouse button is pressed
2. `if (!this.isDragStarted && this.draggedElement)` - Check for drag initialization
3. `if (totalMovement >= this.dragThreshold)` - Movement threshold check
4. `if (this.isDragStarted && this.draggedElement && this.draggedClone)` - Drag state validation
5. `if (!this.draggedElement.hasAttribute("data-allday"))` - Event type check
6. `if (deltaY >= this.snapDistancePx)` - Snap interval check
7. Multiple autoscroll conditionals
8. `if (newColumn == null)` - Column validation
9. `if (newColumn?.index !== this.currentColumnBounds?.index)` - Column change detection
**Recommendation for handleMouseMove():**
```typescript
// Current: 88 lines, complexity 15
// Suggested refactoring:
private handleMouseMove(event: MouseEvent): void {
this.updateMousePosition(event);
if (!this.isMouseButtonPressed(event)) return;
if (this.shouldStartDrag()) {
this.initializeDrag();
}
if (this.isDragActive()) {
this.updateDragPosition();
this.handleColumnChange();
}
}
// Extract methods with complexity 2-4 each:
// - initializeDrag()
// - updateDragPosition()
// - handleColumnChange()
```
---
### 2. SwpEventElement.ts
**File:** `src/elements/SwpEventElement.ts`
**Overall Complexity:** LOW ✅
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `connectedCallback()` | 84-89 | 2 | ✅ OK | Simple initialization |
| `attributeChangedCallback()` | 94-98 | 2 | ✅ OK | Clean attribute handling |
| `updatePosition()` | 109-128 | 2 | ✅ OK | Straightforward update logic |
| `createClone()` | 133-152 | 2 | ✅ OK | Simple cloning |
| `render()` | 161-171 | 1 | ✅ OK | Base complexity |
| `updateDisplay()` | 176-194 | 3 | ✅ OK | Clean DOM updates |
| `applyPositioning()` | 199-205 | 1 | ✅ OK | Delegates to PositionUtils |
| `calculateTimesFromPosition()` | 210-230 | 1 | ✅ OK | Simple calculation |
| `fromCalendarEvent()` (static) | 239-252 | 1 | ✅ OK | Factory method |
| `extractCalendarEventFromElement()` (static) | 257-270 | 1 | ✅ OK | Clean extraction |
| `fromAllDayElement()` (static) | 275-311 | 4 | ✅ OK | Acceptable conversion logic |
| `SwpAllDayEventElement.connectedCallback()` | 319-323 | 2 | ✅ OK | Simple setup |
| `SwpAllDayEventElement.createClone()` | 328-335 | 1 | ✅ OK | Base complexity |
| `SwpAllDayEventElement.applyGridPositioning()` | 340-343 | 1 | ✅ OK | Simple positioning |
| `SwpAllDayEventElement.fromCalendarEvent()` (static) | 348-362 | 1 | ✅ OK | Factory method |
**Best Practices Demonstrated:**
- ✅ Clear separation of concerns
- ✅ Factory methods for object creation
- ✅ Delegation to utility classes (PositionUtils, DateService)
- ✅ BaseSwpEventElement abstraction reduces duplication
- ✅ All methods stay within complexity threshold
**This file serves as a model for good design in the codebase.**
---
### 3. SimpleEventOverlapManager.ts
**File:** `src/managers/SimpleEventOverlapManager.ts`
**Overall Complexity:** HIGH ⚠️
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `resolveOverlapType()` | 33-58 | 4 | ✅ OK | Clear overlap detection |
| `groupOverlappingElements()` | 64-84 | 4 | ✅ OK | Acceptable grouping logic |
| `createEventGroup()` | 89-92 | 1 | ✅ OK | Simple factory |
| `addToEventGroup()` | 97-113 | 2 | ✅ OK | Straightforward addition |
| `createStackedEvent()` | 118-165 | 7 | 🟡 Medium | Chain traversal could be extracted |
| `removeStackedStyling()` | 170-284 | **18** | 🔴 **Critical** | **MOST COMPLEX METHOD IN CODEBASE** |
| `updateSubsequentStackLevels()` | 289-313 | 5 | ✅ OK | Could be simplified |
| `isStackedEvent()` | 318-324 | 3 | ✅ OK | Simple boolean check |
| `removeFromEventGroup()` | 329-364 | 6 | 🟡 Medium | Remaining event handling complex |
| `restackEventsInContainer()` | 369-432 | **11** | 🔴 **High** | **NEEDS REFACTORING** |
| `getEventGroup()` | 438-440 | 1 | ✅ OK | Simple utility |
| `isInEventGroup()` | 442-444 | 1 | ✅ OK | Simple utility |
| `getStackLink()` | 449-459 | 3 | ✅ OK | JSON parsing with error handling |
| `setStackLink()` | 461-467 | 2 | ✅ OK | Simple setter |
| `findElementById()` | 469-471 | 1 | ✅ OK | Base complexity |
**Critical Issue: removeStackedStyling() - Complexity 18**
**Decision Points Breakdown:**
1. `if (link)` - Check if element has stack link
2. `if (link.prev && link.next)` - Middle element in chain
3. `if (prevElement && nextElement)` - Both neighbors exist
4. `if (!actuallyOverlap)` - Chain breaking decision (CRITICAL BRANCH)
5. `if (nextLink?.next)` - Subsequent elements exist
6. `while (subsequentId)` - Loop through chain
7. `if (!subsequentElement)` - Element validation
8. `else` - Normal stacking (chain maintenance)
9. `else if (link.prev)` - Last element case
10. `if (prevElement)` - Previous element exists
11. `else if (link.next)` - First element case
12. `if (nextElement)` - Next element exists
13. `if (link.prev && link.next)` - Middle element check (duplicate)
14. `if (nextLink && nextLink.next)` - Chain continuation
15. `else` - Chain was broken
16-18. Additional nested conditions
**Recommendation for removeStackedStyling():**
```typescript
// Current: 115 lines, complexity 18
// Suggested refactoring:
public removeStackedStyling(eventElement: HTMLElement): void {
this.clearVisualStyling(eventElement);
const link = this.getStackLink(eventElement);
if (!link) return;
// Delegate to specialized methods based on position in chain
if (link.prev && link.next) {
this.removeMiddleElementFromChain(eventElement, link);
} else if (link.prev) {
this.removeLastElementFromChain(eventElement, link);
} else if (link.next) {
this.removeFirstElementFromChain(eventElement, link);
}
this.setStackLink(eventElement, null);
}
// Extract to separate methods:
// - clearVisualStyling() - complexity 1
// - removeMiddleElementFromChain() - complexity 5-6
// - removeLastElementFromChain() - complexity 3
// - removeFirstElementFromChain() - complexity 3
// - breakStackChain() - complexity 4
// - maintainStackChain() - complexity 4
```
**Critical Issue: restackEventsInContainer() - Complexity 11**
**Decision Points:**
1. `if (stackedEvents.length === 0)` - Early return
2. `for (const element of stackedEvents)` - Iterate events
3. `if (!eventId || processedEventIds.has(eventId))` - Validation
4. `while (rootLink?.prev)` - Find root of chain
5. `if (!prevElement)` - Break condition
6. `while (currentElement)` - Traverse chain
7. `if (!currentLink?.next)` - End of chain
8. `if (!nextElement)` - Break condition
9. `if (chain.length > 1)` - Only add multi-element chains
10. `forEach` - Restack each chain
11. `if (link)` - Update link data
**Recommendation for restackEventsInContainer():**
```typescript
// Current: 64 lines, complexity 11
// Suggested refactoring:
public restackEventsInContainer(container: HTMLElement): void {
const stackedEvents = this.getStackedEvents(container);
if (stackedEvents.length === 0) return;
const stackChains = this.collectStackChains(stackedEvents);
stackChains.forEach(chain => this.reapplyStackStyling(chain));
}
// Extract to separate methods:
// - getStackedEvents() - complexity 2
// - collectStackChains() - complexity 6
// - findStackRoot() - complexity 3
// - traverseChain() - complexity 3
// - reapplyStackStyling() - complexity 2
```
---
### 4. EventRendererManager.ts
**File:** `src/renderers/EventRendererManager.ts`
**Overall Complexity:** MEDIUM 🟡
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `renderEvents()` | 35-68 | 3 | ✅ OK | Clean rendering logic |
| `setupEventListeners()` | 70-95 | 1 | ✅ OK | Simple delegation |
| `handleGridRendered()` | 101-127 | 5 | ✅ OK | Could reduce conditionals |
| `handleViewChanged()` | 133-138 | 1 | ✅ OK | Simple cleanup |
| `setupDragEventListeners()` | 144-238 | **10** | 🔴 **High** | **NEEDS REFACTORING** |
| `handleConvertToTimeEvent()` | 243-292 | 4 | ✅ OK | Acceptable conversion logic |
| `clearEvents()` | 294-296 | 1 | ✅ OK | Delegates to strategy |
| `refresh()` | 298-300 | 1 | ✅ OK | Simple refresh |
**Issue: setupDragEventListeners() - Complexity 10**
**Decision Points:**
1. `if (hasAttribute('data-allday'))` - Filter all-day events
2. `if (draggedElement && strategy.handleDragStart && columnBounds)` - Validation
3. `if (hasAttribute('data-allday'))` - Filter check
4. `if (strategy.handleDragMove)` - Strategy check
5. `if (strategy.handleDragAutoScroll)` - Strategy check
6. `if (target === 'swp-day-column' && finalColumn)` - Drop target validation
7. `if (draggedElement && draggedClone && strategy.handleDragEnd)` - Validation
8. `if (dayEventClone)` - Cleanup check
9. `if (hasAttribute('data-allday'))` - Filter check
10. `if (strategy.handleColumnChange)` - Strategy check
**Recommendation:**
```typescript
// Current: 95 lines, complexity 10
// Suggested refactoring:
private setupDragEventListeners(): void {
this.setupDragStartListener();
this.setupDragMoveListener();
this.setupDragEndListener();
this.setupDragAutoScrollListener();
this.setupColumnChangeListener();
this.setupConversionListener();
this.setupNavigationListener();
}
// Each listener method: complexity 2-3
```
---
### 5. EventRenderer.ts
**File:** `src/renderers/EventRenderer.ts`
**Overall Complexity:** LOW ✅
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `handleDragStart()` | 50-72 | 2 | ✅ OK | Clean drag initialization |
| `handleDragMove()` | 77-84 | 2 | ✅ OK | Simple position update |
| `handleDragAutoScroll()` | 89-97 | 2 | ✅ OK | Simple scroll handling |
| `handleColumnChange()` | 102-115 | 3 | ✅ OK | Clean column switching |
| `handleDragEnd()` | 120-141 | 3 | ✅ OK | Proper cleanup |
| `handleNavigationCompleted()` | 146-148 | 1 | ✅ OK | Placeholder method |
| `fadeOutAndRemove()` | 153-160 | 1 | ✅ OK | Simple animation |
| `renderEvents()` | 163-182 | 2 | ✅ OK | Straightforward rendering |
| `renderEvent()` | 184-186 | 1 | ✅ OK | Factory delegation |
| `calculateEventPosition()` | 188-191 | 1 | ✅ OK | Delegates to utility |
| `clearEvents()` | 193-200 | 2 | ✅ OK | Simple cleanup |
| `getColumns()` | 202-205 | 1 | ✅ OK | DOM query |
| `getEventsForColumn()` | 207-221 | 2 | ✅ OK | Filter logic |
**Best Practices:**
- ✅ All methods under complexity 4
- ✅ Clear method names
- ✅ Delegation to utilities
- ✅ Single responsibility per method
---
### 6. AllDayEventRenderer.ts
**File:** `src/renderers/AllDayEventRenderer.ts`
**Overall Complexity:** LOW ✅
| Method | Lines | Complexity | Status | Notes |
|--------|-------|------------|--------|-------|
| `getContainer()` | 20-32 | 3 | ✅ OK | Container initialization |
| `getAllDayContainer()` | 35-37 | 1 | ✅ OK | Simple query |
| `handleDragStart()` | 41-65 | 3 | ✅ OK | Clean drag setup |
| `renderAllDayEventWithLayout()` | 72-83 | 2 | ✅ OK | Simple rendering |
| `removeAllDayEvent()` | 89-97 | 3 | ✅ OK | Clean removal |
| `clearCache()` | 102-104 | 1 | ✅ OK | Simple reset |
| `renderAllDayEventsForPeriod()` | 109-116 | 1 | ✅ OK | Delegates to helper |
| `clearAllDayEvents()` | 118-123 | 2 | ✅ OK | Simple cleanup |
| `handleViewChanged()` | 125-127 | 1 | ✅ OK | Simple handler |
**Best Practices:**
- ✅ Consistent low complexity across all methods
- ✅ Clear separation of concerns
- ✅ Focused functionality
---
## Recommendations
### Immediate Action Required (Complexity >10)
#### 1. SimpleEventOverlapManager.removeStackedStyling() - Priority: CRITICAL
**Current Complexity:** 18
**Target Complexity:** 4-6 per method
**Refactoring Steps:**
1. Extract `clearVisualStyling()` - Remove inline styles
2. Extract `removeMiddleElementFromChain()` - Handle middle element removal
3. Extract `removeLastElementFromChain()` - Handle last element removal
4. Extract `removeFirstElementFromChain()` - Handle first element removal
5. Extract `breakStackChain()` - Handle non-overlapping chain breaking
6. Extract `maintainStackChain()` - Handle overlapping chain maintenance
**Expected Impact:**
- Main method: complexity 4
- Helper methods: complexity 3-6 each
- Improved testability
- Easier maintenance
---
#### 2. DragDropManager.handleMouseMove() - Priority: HIGH
**Current Complexity:** 15
**Target Complexity:** 4-5 per method
**Refactoring Steps:**
1. Extract `updateMousePosition()` - Update tracking variables
2. Extract `shouldStartDrag()` - Check movement threshold
3. Extract `initializeDrag()` - Create clone and emit start event
4. Extract `updateDragPosition()` - Handle position and autoscroll
5. Extract `handleColumnChange()` - Detect and handle column transitions
**Expected Impact:**
- Main method: complexity 4
- Helper methods: complexity 3-4 each
- Better separation of drag lifecycle stages
---
#### 3. SimpleEventOverlapManager.restackEventsInContainer() - Priority: HIGH
**Current Complexity:** 11
**Target Complexity:** 3-4 per method
**Refactoring Steps:**
1. Extract `getStackedEvents()` - Filter stacked events
2. Extract `collectStackChains()` - Build stack chains
3. Extract `findStackRoot()` - Find root of chain
4. Extract `traverseChain()` - Collect chain elements
5. Extract `reapplyStackStyling()` - Apply visual styling
**Expected Impact:**
- Main method: complexity 3
- Helper methods: complexity 2-4 each
---
#### 4. EventRendererManager.setupDragEventListeners() - Priority: MEDIUM
**Current Complexity:** 10
**Target Complexity:** 2-3 per method
**Refactoring Steps:**
1. Extract `setupDragStartListener()`
2. Extract `setupDragMoveListener()`
3. Extract `setupDragEndListener()`
4. Extract `setupDragAutoScrollListener()`
5. Extract `setupColumnChangeListener()`
6. Extract `setupConversionListener()`
7. Extract `setupNavigationListener()`
**Expected Impact:**
- Main method: complexity 1 (just calls helpers)
- Helper methods: complexity 2-3 each
- Improved readability
---
### Medium Priority (Complexity 6-10)
#### 5. SimpleEventOverlapManager.createStackedEvent() - Complexity 7
Consider extracting chain traversal logic into `findEndOfChain()`
#### 6. DragDropManager.startAutoScroll() - Complexity 6
Extract scroll calculation into `calculateScrollAmount()`
#### 7. SimpleEventOverlapManager.removeFromEventGroup() - Complexity 6
Extract remaining event handling into `handleRemainingEvents()`
---
## Code Quality Metrics
### Complexity by File
```
DragDropManager.ts: ████████░░ 8/10 (1 critical, 2 medium)
SwpEventElement.ts: ██░░░░░░░░ 2/10 (excellent!)
SimpleEventOverlapManager.ts: ██████████ 10/10 (2 critical, 2 medium)
EventRendererManager.ts: ██████░░░░ 6/10 (1 critical)
EventRenderer.ts: ██░░░░░░░░ 2/10 (excellent!)
AllDayEventRenderer.ts: ██░░░░░░░░ 2/10 (excellent!)
```
### Methods Requiring Attention
| Priority | File | Method | Complexity | Effort |
|----------|------|--------|------------|--------|
| 🔴 Critical | SimpleEventOverlapManager | removeStackedStyling | 18 | High |
| 🔴 Critical | DragDropManager | handleMouseMove | 15 | High |
| 🔴 High | SimpleEventOverlapManager | restackEventsInContainer | 11 | Medium |
| 🔴 High | EventRendererManager | setupDragEventListeners | 10 | Low |
| 🟡 Medium | SimpleEventOverlapManager | createStackedEvent | 7 | Low |
| 🟡 Medium | DragDropManager | startAutoScroll | 6 | Low |
| 🟡 Medium | SimpleEventOverlapManager | removeFromEventGroup | 6 | Low |
---
## Positive Examples
### SwpEventElement.ts - Excellent Design Pattern
This file demonstrates best practices:
```typescript
// ✅ Clear, focused methods with single responsibility
public updatePosition(columnDate: Date, snappedY: number): void {
this.style.top = `${snappedY + 1}px`;
const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY);
const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
let endDate = this.dateService.createDateAtTime(columnDate, endMinutes);
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = this.dateService.addDays(endDate, extraDays);
}
this.start = startDate;
this.end = endDate;
}
// Complexity: 2 (one if statement)
```
**Why this works:**
- Single responsibility (update position)
- Delegates complex calculations to helper methods
- Clear variable names
- Minimal branching
---
## Action Plan
### Phase 1: Critical Refactoring (Week 1-2)
1. ✅ Refactor `SimpleEventOverlapManager.removeStackedStyling()` (18 → 4-6)
2. ✅ Refactor `DragDropManager.handleMouseMove()` (15 → 4-5)
**Expected Impact:**
- Reduce highest complexity from 18 to 4-6
- Improve maintainability significantly
- Enable easier testing
### Phase 2: High Priority (Week 3)
3. ✅ Refactor `SimpleEventOverlapManager.restackEventsInContainer()` (11 → 3-4)
4. ✅ Refactor `EventRendererManager.setupDragEventListeners()` (10 → 2-3)
**Expected Impact:**
- Eliminate all methods with complexity >10
- Improve overall code quality score
### Phase 3: Medium Priority (Week 4)
5. ✅ Review and simplify medium complexity methods (complexity 6-7)
6. ✅ Add unit tests for extracted methods
**Expected Impact:**
- All methods under complexity threshold of 10
- Comprehensive test coverage
### Phase 4: Continuous Improvement
7. ✅ Establish cyclomatic complexity checks in CI/CD
8. ✅ Set max complexity threshold to 10
9. ✅ Regular code reviews focusing on complexity
---
## Tools & Resources
### Recommended Tools for Ongoing Monitoring:
- **TypeScript ESLint** with `complexity` rule
- **SonarQube** for continuous code quality monitoring
- **CodeClimate** for maintainability scoring
### Suggested ESLint Configuration:
```json
{
"rules": {
"complexity": ["error", 10],
"max-lines-per-function": ["warn", 50],
"max-depth": ["error", 4]
}
}
```
---
## Conclusion
The Calendar Plantempus codebase shows **mixed code quality**:
**Strengths:**
- 87.8% of methods have acceptable complexity
- Web Components demonstrate excellent design patterns
- Clear separation of concerns in rendering services
**Areas for Improvement:**
- Stack management logic is overly complex
- Some drag & drop handlers need refactoring
- File naming could better reflect complexity (e.g., "Simple"EventOverlapManager has complexity 18!)
**Overall Grade: B-**
With the recommended refactoring, the codebase can easily achieve an **A grade** by reducing the 4 critical methods to acceptable complexity levels.
---
**Generated by:** Claude Code Cyclomatic Complexity Analyzer
**Date:** 2025-10-04
**Analyzer Version:** 1.0

772
STACKING_CONCEPT.md Normal file
View file

@ -0,0 +1,772 @@
# Event Stacking Concept
**Calendar Plantempus - Visual Event Overlap Management**
---
## Overview
**Event Stacking** is a visual technique for displaying overlapping calendar events by offsetting them horizontally with a cascading effect. This creates a clear visual hierarchy showing which events overlap in time.
---
## Visual Concept
### Basic Stacking
When multiple events overlap in time, they are "stacked" with increasing left margin:
```
Timeline:
08:00 ─────────────────────────────────
09:00 │ Event A starts
│ ┌─────────────────────┐
│ │ Meeting A │
10:00 │ │ │
│ │ Event B starts │
│ │ ┌─────────────────────┐
11:00 │ │ │ Meeting B │
│ └──│─────────────────────┘
│ │ │
12:00 │ │ Event C starts │
│ │ ┌─────────────────────┐
│ └──│─────────────────────┘
13:00 │ │ Meeting C │
│ └─────────────────────┘
14:00 ─────────────────────────────────
Visual Result (stacked view):
┌─────────────────────┐
│ Meeting A │
│ ┌─────────────────────┐
│ │ Meeting B │
└─│─────────────────────┘
│ ┌─────────────────────┐
│ │ Meeting C │
└─│─────────────────────┘
└─────────────────────┘
```
Each subsequent event is offset by **15px** to the right.
---
## Stack Link Data Structure
Stack links create a **doubly-linked list** stored directly in DOM elements as data attributes.
### Interface Definition
```typescript
interface StackLink {
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.)
}
```
### Storage in DOM
Stack links are stored as JSON in the `data-stack-link` attribute:
```html
<swp-event
data-event-id="event-1"
data-stack-link='{"stackLevel":0,"next":"event-2"}'>
</swp-event>
<swp-event
data-event-id="event-2"
data-stack-link='{"stackLevel":1,"prev":"event-1","next":"event-3"}'>
</swp-event>
<swp-event
data-event-id="event-3"
data-stack-link='{"stackLevel":2,"prev":"event-2"}'>
</swp-event>
```
### Benefits of DOM Storage
**State follows the element** - No external state management needed
**Survives drag & drop** - Links persist through DOM manipulations
**Easy to query** - Can traverse chain using DOM queries
**Self-contained** - Each element knows its position in the stack
---
## Overlap Detection
Events overlap when their time ranges intersect.
### Time-Based Overlap Algorithm
```typescript
function doEventsOverlap(eventA: CalendarEvent, eventB: CalendarEvent): boolean {
// Two events overlap if:
// - Event A starts before Event B ends AND
// - Event A ends after Event B starts
return eventA.start < eventB.end && eventA.end > eventB.start;
}
```
### Example Cases
**Case 1: Events Overlap**
```
Event A: 09:00 ──────── 11:00
Event B: 10:00 ──────── 12:00
Result: OVERLAP (10:00 to 11:00)
```
**Case 2: No Overlap**
```
Event A: 09:00 ──── 10:00
Event B: 11:00 ──── 12:00
Result: NO OVERLAP
```
**Case 3: Complete Containment**
```
Event A: 09:00 ──────────────── 13:00
Event B: 10:00 ─── 11:00
Result: OVERLAP (Event B fully inside Event A)
```
---
## Visual Styling
### CSS Calculations
```typescript
const STACK_OFFSET_PX = 15;
// For each event in stack:
marginLeft = stackLevel * STACK_OFFSET_PX;
zIndex = 100 + stackLevel;
```
### Example with 3 Stacked Events
```typescript
Event A (stackLevel: 0):
marginLeft = 0 * 15 = 0px
zIndex = 100 + 0 = 100
Event B (stackLevel: 1):
marginLeft = 1 * 15 = 15px
zIndex = 100 + 1 = 101
Event C (stackLevel: 2):
marginLeft = 2 * 15 = 30px
zIndex = 100 + 2 = 102
```
Result: Event C appears on top, Event A at the base.
---
## Optimized Stacking (Smart Stacking)
### The Problem: Naive Stacking vs Optimized Stacking
**Naive Approach:** Simply stack all overlapping events sequentially.
```
Event A: 09:00 ════════════════════════════ 14:00
Event B: 10:00 ═════ 12:00
Event C: 12:30 ═══ 13:00
Naive Result:
Event A: stackLevel 0
Event B: stackLevel 1
Event C: stackLevel 2 ← INEFFICIENT! C doesn't overlap B
```
**Optimized Approach:** Events that don't overlap each other can share the same stack level.
```
Event A: 09:00 ════════════════════════════ 14:00
Event B: 10:00 ═════ 12:00
Event C: 12:30 ═══ 13:00
Optimized Result:
Event A: stackLevel 0
Event B: stackLevel 1 ← Both at level 1
Event C: stackLevel 1 ← because they don't overlap!
```
### Visual Comparison: The Key Insight
**Example Timeline:**
```
Timeline:
09:00 ─────────────────────────────────
│ Event A starts
│ ┌─────────────────────────────┐
10:00 │ │ Event A │
│ │ │
│ │ Event B starts │
│ │ ╔═══════════════╗ │
11:00 │ │ ║ Event B ║ │
│ │ ║ ║ │
12:00 │ │ ╚═══════════════╝ │
│ │ │
│ │ Event C starts │
│ │ ╔═══════════╗ │
13:00 │ │ ║ Event C ║ │
│ └───────╚═══════════╝─────────┘
14:00 ─────────────────────────────────
Key Observation:
• Event B (10:00-12:00) and Event C (12:30-13:00) do NOT overlap!
• They are separated by 30 minutes (12:00 to 12:30)
• Both overlap with Event A, but not with each other
```
**Naive Stacking (Wasteful):**
```
Visual Result (Naive - Inefficient):
┌─────────────────────────────────────────────────┐
│ Event A │
│ ┌─────────────────────┐ │
│ │ Event B │ │
│ │ ┌─────────────────────┐ │
│ └─│─────────────────────┘ │
│ │ Event C │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────┘
0px 15px 30px
└──┴────┘
Wasted space!
Stack Levels:
• Event A: stackLevel 0 (marginLeft: 0px)
• Event B: stackLevel 1 (marginLeft: 15px)
• Event C: stackLevel 2 (marginLeft: 30px) ← UNNECESSARY!
Problem: Event C is pushed 30px to the right even though
it doesn't conflict with Event B!
```
**Optimized Stacking (Efficient):**
```
Visual Result (Optimized - Efficient):
┌─────────────────────────────────────────────────┐
│ Event A │
│ ┌─────────────────────┐ ┌─────────────────────┐│
│ │ Event B │ │ Event C ││
│ └─────────────────────┘ └─────────────────────┘│
└─────────────────────────────────────────────────┘
0px 15px 15px
└────────────────────┘
Same offset for both!
Stack Levels:
• Event A: stackLevel 0 (marginLeft: 0px)
• Event B: stackLevel 1 (marginLeft: 15px)
• Event C: stackLevel 1 (marginLeft: 15px) ← OPTIMIZED!
Benefit: Event C reuses stackLevel 1 because Event B
has already ended when Event C starts.
No visual conflict, saves 15px of horizontal space!
```
**Side-by-Side Comparison:**
```
Naive (3 levels): Optimized (2 levels):
A A
├─ B ├─ B
│ └─ C └─ C
Uses 45px width Uses 30px width
(0 + 15 + 30) (0 + 15 + 15)
33% space savings! →
```
### Algorithm: Greedy Stack Level Assignment
The optimized stacking algorithm assigns the lowest available stack level to each event:
```typescript
function createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
// Step 1: Sort events by start time
const sorted = events.sort((a, b) => a.start - b.start)
// Step 2: Track which stack levels are occupied at each time point
const stackLinks = new Map<string, StackLink>()
for (const event of sorted) {
// Find the lowest available stack level for this event
let stackLevel = 0
// Check which levels are occupied by overlapping events
const overlapping = sorted.filter(other =>
other !== event && doEventsOverlap(event, other)
)
// Try each level starting from 0
while (true) {
const levelOccupied = overlapping.some(other =>
stackLinks.get(other.id)?.stackLevel === stackLevel
)
if (!levelOccupied) {
break // Found available level
}
stackLevel++ // Try next level
}
// Assign the lowest available level
stackLinks.set(event.id, { stackLevel })
}
return stackLinks
}
```
### Example Scenarios
#### Scenario 1: Three Events, Two Parallel Tracks
```
Input:
Event A: 09:00-14:00 (long event)
Event B: 10:00-12:00
Event C: 12:30-13:00
Analysis:
A overlaps with: B, C
B overlaps with: A (not C)
C overlaps with: A (not B)
Result:
Event A: stackLevel 0 (base)
Event B: stackLevel 1 (first available)
Event C: stackLevel 1 (level 1 is free, B doesn't conflict)
```
#### Scenario 2: Four Events, Three at Same Level
```
Input:
Event A: 09:00-15:00 (very long event)
Event B: 10:00-11:00
Event C: 11:30-12:30
Event D: 13:00-14:00
Analysis:
A overlaps with: B, C, D
B, C, D don't overlap with each other
Result:
Event A: stackLevel 0
Event B: stackLevel 1
Event C: stackLevel 1 (B is done, level 1 free)
Event D: stackLevel 1 (B and C are done, level 1 free)
```
#### Scenario 3: Nested Events with Optimization
```
Input:
Event A: 09:00-15:00
Event B: 10:00-13:00
Event C: 11:00-12:00
Event D: 12:30-13:30
Analysis:
A overlaps with: B, C, D
B overlaps with: A, C (not D)
C overlaps with: A, B (not D)
D overlaps with: A (not B, not C)
Result:
Event A: stackLevel 0 (base)
Event B: stackLevel 1 (overlaps with A)
Event C: stackLevel 2 (overlaps with A and B)
Event D: stackLevel 2 (overlaps with A only, level 2 is free)
```
### Stack Links with Optimization
**Important:** With optimized stacking, events at the same stack level are NOT linked via prev/next!
```typescript
// Traditional chain (naive):
Event A: { stackLevel: 0, next: "event-b" }
Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
Event C: { stackLevel: 2, prev: "event-b" }
// Optimized (B and C at same level, no link between them):
Event A: { stackLevel: 0 }
Event B: { stackLevel: 1 } // No prev/next
Event C: { stackLevel: 1 } // No prev/next
```
### Benefits of Optimized Stacking
**Space Efficiency:** Reduces horizontal space usage by up to 50%
**Better Readability:** Events are visually closer, easier to see relationships
**Scalability:** Works well with many events in a day
**Performance:** Same O(n²) complexity as naive approach
### Trade-offs
⚠️ **No Single Chain:** Events at the same level aren't linked, making traversal more complex
⚠️ **More Complex Logic:** Requires checking all overlaps, not just sequential ordering
⚠️ **Visual Ambiguity:** Users might wonder why some events are at the same level
## Stack Chain Operations
### Building a Stack Chain (Naive Approach)
When events overlap, they form a chain sorted by start time:
```typescript
// Input: Events with overlapping times
Event A: 09:00-11:00
Event B: 10:00-12:00
Event C: 11:30-13:00
// Step 1: Sort by start time (earliest first)
Sorted: [Event A, Event B, Event C]
// Step 2: Create links
Event A: { stackLevel: 0, next: "event-b" }
Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
Event C: { stackLevel: 2, prev: "event-b" }
```
### Traversing Forward
```typescript
// Start at any event
currentEvent = Event B;
// Get stack link
stackLink = currentEvent.dataset.stackLink; // { prev: "event-a", next: "event-c" }
// Move to next event
nextEventId = stackLink.next; // "event-c"
nextEvent = document.querySelector(`[data-event-id="${nextEventId}"]`);
```
### Traversing Backward
```typescript
// Start at any event
currentEvent = Event B;
// Get stack link
stackLink = currentEvent.dataset.stackLink; // { prev: "event-a", next: "event-c" }
// Move to previous event
prevEventId = stackLink.prev; // "event-a"
prevEvent = document.querySelector(`[data-event-id="${prevEventId}"]`);
```
### Finding Stack Root
```typescript
function findStackRoot(event: HTMLElement): HTMLElement {
let current = event;
let stackLink = getStackLink(current);
// Traverse backward until we find an event with no prev link
while (stackLink?.prev) {
const prevEvent = document.querySelector(
`[data-event-id="${stackLink.prev}"]`
);
if (!prevEvent) break;
current = prevEvent;
stackLink = getStackLink(current);
}
return current; // This is the root (stackLevel 0)
}
```
---
## Use Cases
### 1. Adding a New Event to Existing Stack
```
Existing Stack:
Event A (09:00-11:00) - stackLevel 0
Event B (10:00-12:00) - stackLevel 1
New Event:
Event C (10:30-11:30)
Steps:
1. Detect overlap with Event A and Event B
2. Sort all three by start time: [A, B, C]
3. Rebuild stack links:
- Event A: { stackLevel: 0, next: "event-b" }
- Event B: { stackLevel: 1, prev: "event-a", next: "event-c" }
- Event C: { stackLevel: 2, prev: "event-b" }
4. Apply visual styling
```
### 2. Removing Event from Middle of Stack
```
Before:
Event A (stackLevel 0) ─→ Event B (stackLevel 1) ─→ Event C (stackLevel 2)
Remove Event B:
After:
Event A (stackLevel 0) ─→ Event C (stackLevel 1)
Steps:
1. Get Event B's stack link: { prev: "event-a", next: "event-c" }
2. Update Event A's next: "event-c"
3. Update Event C's prev: "event-a"
4. Update Event C's stackLevel: 1 (was 2)
5. Recalculate Event C's marginLeft: 15px (was 30px)
6. Remove Event B's stack link
```
### 3. Moving Event to Different Time
```
Before (events overlap):
Event A (09:00-11:00) - stackLevel 0
Event B (10:00-12:00) - stackLevel 1
Move Event B to 14:00-16:00 (no longer overlaps):
After:
Event A (09:00-11:00) - NO STACK LINK (standalone)
Event B (14:00-16:00) - NO STACK LINK (standalone)
Steps:
1. Detect that Event B no longer overlaps Event A
2. Remove Event B from stack chain
3. Clear Event A's next link
4. Clear Event B's stack link entirely
5. Reset both events' marginLeft to 0px
```
---
## Edge Cases
### Case 1: Single Event (No Overlap)
```
Event A: 09:00-10:00 (alone in time slot)
Stack Link: NONE (no data-stack-link attribute)
Visual: marginLeft = 0px, zIndex = default
```
### Case 2: Two Events, Same Start Time
```
Event A: 10:00-11:00
Event B: 10:00-12:00 (same start, different end)
Sort by: start time first, then by end time (shortest first)
Result: Event A (stackLevel 0), Event B (stackLevel 1)
```
### Case 3: Multiple Separate Chains in Same Column
```
Chain 1:
Event A (09:00-10:00) - stackLevel 0
Event B (09:30-10:30) - stackLevel 1
Chain 2:
Event C (14:00-15:00) - stackLevel 0
Event D (14:30-15:30) - stackLevel 1
Note: Two independent chains, each with their own root at stackLevel 0
```
### Case 4: Complete Containment
```
Event A: 09:00-13:00 (large event)
Event B: 10:00-11:00 (inside A)
Event C: 11:30-12:30 (inside A)
All three overlap, so they form one chain:
Event A - stackLevel 0
Event B - stackLevel 1
Event C - stackLevel 2
```
---
## Algorithm Pseudocode
### Creating Stack for New Event
```
function createStackForNewEvent(newEvent, columnEvents):
// Step 1: Find overlapping events
overlapping = columnEvents.filter(event =>
doEventsOverlap(newEvent, event)
)
if overlapping is empty:
// No stack needed
return null
// Step 2: Combine and sort by start time
allEvents = [...overlapping, newEvent]
allEvents.sort((a, b) => a.start - b.start)
// Step 3: Create stack links
stackLinks = new Map()
for (i = 0; i < allEvents.length; i++):
link = {
stackLevel: i,
prev: i > 0 ? allEvents[i-1].id : undefined,
next: i < allEvents.length-1 ? allEvents[i+1].id : undefined
}
stackLinks.set(allEvents[i].id, link)
// Step 4: Apply to DOM
for each event in allEvents:
element = findElementById(event.id)
element.dataset.stackLink = JSON.stringify(stackLinks.get(event.id))
element.style.marginLeft = stackLinks.get(event.id).stackLevel * 15 + 'px'
element.style.zIndex = 100 + stackLinks.get(event.id).stackLevel
return stackLinks
```
### Removing Event from Stack
```
function removeEventFromStack(eventId):
element = findElementById(eventId)
stackLink = JSON.parse(element.dataset.stackLink)
if not stackLink:
return // Not in a stack
// Update previous element
if stackLink.prev:
prevElement = findElementById(stackLink.prev)
prevLink = JSON.parse(prevElement.dataset.stackLink)
prevLink.next = stackLink.next
prevElement.dataset.stackLink = JSON.stringify(prevLink)
// Update next element
if stackLink.next:
nextElement = findElementById(stackLink.next)
nextLink = JSON.parse(nextElement.dataset.stackLink)
nextLink.prev = stackLink.prev
// Shift down stack level
nextLink.stackLevel = nextLink.stackLevel - 1
nextElement.dataset.stackLink = JSON.stringify(nextLink)
// Update visual styling
nextElement.style.marginLeft = nextLink.stackLevel * 15 + 'px'
nextElement.style.zIndex = 100 + nextLink.stackLevel
// Cascade update to all subsequent events
updateSubsequentStackLevels(nextElement, -1)
// Clear removed element's stack link
delete element.dataset.stackLink
element.style.marginLeft = '0px'
```
---
## Performance Considerations
### Time Complexity
- **Overlap Detection:** O(n) where n = number of events in column
- **Stack Creation:** O(n log n) due to sorting
- **Chain Traversal:** O(n) worst case (entire chain)
- **Stack Removal:** O(n) worst case (update all subsequent)
### Space Complexity
- **Stack Links:** O(1) per event (stored in DOM attribute)
- **No Global State:** All state is in DOM
### Optimization Tips
1. **Batch Updates:** When adding multiple events, batch DOM updates
2. **Lazy Evaluation:** Only recalculate stacks when events change
3. **Event Delegation:** Use event delegation instead of per-element listeners
4. **Virtual Scrolling:** For large calendars, only render visible events
---
## Implementation Guidelines
### Separation of Concerns
**Pure Logic (No DOM):**
- Overlap detection algorithms
- Stack link calculation
- Sorting logic
**DOM Manipulation:**
- Applying stack links to elements
- Updating visual styles
- Chain traversal
**Event Handling:**
- Detecting event changes
- Triggering stack recalculation
- Cleanup on event removal
### Testing Strategy
1. **Unit Tests:** Test overlap detection in isolation
2. **Integration Tests:** Test stack creation with DOM
3. **Visual Tests:** Test CSS styling calculations
4. **Edge Cases:** Test boundary conditions
---
## Future Enhancements
### Potential Improvements
1. **Smart Stacking:** Detect non-overlapping sub-groups and stack independently
2. **Column Sharing:** For events with similar start times, use flexbox columns
3. **Compact Mode:** Reduce stack offset for dense calendars
4. **Color Coding:** Visual indication of stack depth
5. **Stack Preview:** Hover to highlight entire stack chain
---
## Glossary
- **Stack:** Group of overlapping events displayed with horizontal offset
- **Stack Link:** Data structure connecting events in a stack (doubly-linked list)
- **Stack Level:** Position in stack (0 = base, 1+ = offset)
- **Stack Root:** First event in stack (stackLevel 0, no prev link)
- **Stack Chain:** Complete sequence of linked events
- **Overlap:** Two events with intersecting time ranges
- **Offset:** Horizontal margin applied to stacked events (15px per level)
---
**Document Version:** 1.0
**Last Updated:** 2025-10-04
**Status:** Conceptual Documentation - Ready for TDD Implementation

View file

@ -1,155 +0,0 @@
# Critical Code Review - Calendar Plantempus (UPDATED)
**Date:** January 2025
**Reviewer:** Code Analysis Assistant
**Scope:** Full TypeScript/JavaScript codebase
**Update:** Post-refactoring status
## Executive Summary
This code review identified 14+ critical issues. After immediate refactoring, 7 critical issues have been resolved, significantly improving code quality and maintainability.
## ✅ RESOLVED ISSUES (January 2025)
### 1. ~~Inconsistent File Structure~~ ✅ FIXED
**Resolution:**
- ✅ Deleted `src/utils/PositionUtils.js` (legacy JavaScript)
- ✅ Fixed `tsconfig.json` output directory to `./wwwroot/js`
- ✅ Build pipeline now consistent
### 2. ~~Event System Overcomplexity~~ ✅ PARTIALLY FIXED
**Resolution:**
- ✅ Deleted unused `CalendarState.ts` (170 lines of dead code)
- ✅ Created `CoreEvents.ts` with only 20 essential events
- ✅ Added migration map for gradual transition
- ⚠️ Still need to migrate all code to use CoreEvents
### 3. ~~Missing Error Handling~~ ✅ PARTIALLY FIXED
**Resolution:**
- ✅ Added `validateDate()` method to DateCalculator
- ✅ All date methods now validate inputs
- ⚠️ Still need error boundaries in UI components
### 4. ~~Memory Leak Potential~~ ✅ PARTIALLY FIXED
**Resolution:**
- ✅ ViewManager now tracks all listeners
- ✅ Proper `destroy()` method implementation
- ⚠️ Other managers still need cleanup methods
### 7. ~~Type Safety Issues~~ ✅ FIXED
**Resolution:**
- ✅ Replaced `any[]` with `AllDayEvent[]` type
- ✅ Created proper event type definitions
- ✅ No more type casting in fixed files
---
## 🚨 REMAINING CRITICAL ISSUES
### 5. Single Responsibility Violations
**Severity:** High
**Impact:** Unmaintainable code, difficult to test
**Still Present:**
- GridManager: 311 lines handling multiple responsibilities
- CalendarConfig: Config + state management mixed
**Recommendation:** Implement strategy pattern for different views
---
### 6. Dependency Injection Missing
**Severity:** Medium
**Impact:** Untestable code, tight coupling
**Still Present:**
- Singleton imports in 15+ files
- Circular dependencies through EventBus
**Recommendation:** Use constructor injection pattern
---
### 8. Performance Problems
**Severity:** Medium
**Impact:** Sluggish UI with many events
**Still Present:**
- DOM queries not cached
- Full re-renders on every change
**Recommendation:** Implement virtual scrolling and caching
---
## 📊 IMPROVEMENT METRICS
### Before Refactoring
- **Event Types:** 102 + StateEvents
- **Dead Code:** ~200 lines (CalendarState.ts)
- **Type Safety:** Multiple `any` types
- **Error Handling:** None
- **Memory Leaks:** All managers
### After Refactoring
- **Event Types:** 20 core events (80% reduction!)
- **Dead Code:** 0 lines removed
- **Type Safety:** Proper types defined
- **Error Handling:** Date validation added
- **Memory Leaks:** ViewManager fixed
### Code Quality Scores (Updated)
- **Maintainability:** ~~3/10~~**5/10** ⬆️
- **Testability:** ~~2/10~~**4/10** ⬆️
- **Performance:** 5/10 (unchanged)
- **Type Safety:** ~~4/10~~**7/10** ⬆️
- **Architecture:** ~~3/10~~**4/10** ⬆️
---
## 🎯 NEXT STEPS
### Phase 1: Architecture (Priority)
1. Implement ViewStrategy pattern for month view
2. Split GridManager using strategy pattern
3. Add dependency injection
### Phase 2: Performance
4. Cache DOM queries
5. Implement selective rendering
6. Add virtual scrolling for large datasets
### Phase 3: Testing
7. Add unit tests for DateCalculator
8. Add integration tests for event system
9. Add E2E tests for critical user flows
---
## Files Modified
### Deleted Files
- `src/utils/PositionUtils.js` - Legacy JavaScript removed
- `src/types/CalendarState.ts` - Unused state management
### Created Files
- `src/constants/CoreEvents.ts` - Consolidated event system
- `src/types/EventTypes.ts` - Proper type definitions
### Modified Files
- `tsconfig.json` - Fixed output directory
- `src/utils/DateCalculator.ts` - Added validation
- `src/managers/ViewManager.ts` - Added cleanup
- `src/managers/GridManager.ts` - Fixed types
- `src/renderers/GridRenderer.ts` - Fixed types
- 4 files - Removed StateEvents imports
---
## Conclusion
The immediate refactoring has addressed 50% of critical issues with minimal effort (~1 hour of work). The codebase is now:
- **Cleaner:** 200+ lines of dead code removed
- **Safer:** Type safety and validation improved
- **Simpler:** Event system reduced by 80%
- **More maintainable:** Clear separation emerging
The remaining issues require architectural changes but the foundation is now stronger for implementing month view and other features.

View file

@ -1,282 +0,0 @@
# Critical Code Review - Calendar Plantempus
**Date:** January 2025
**Reviewer:** Code Analysis Assistant
**Scope:** Full TypeScript/JavaScript codebase
## Executive Summary
This code review identifies 14+ critical issues that impact maintainability, performance, and the ability to add new features (especially month view). The codebase shows signs of rapid development without architectural planning, resulting in significant technical debt.
---
## 🚨 CRITICAL ISSUES
### 1. Inconsistent File Structure
**Severity:** High
**Impact:** Development confusion, build issues
**Problems:**
- Duplicate TypeScript/JavaScript files exist (`src/utils/PositionUtils.js` and `.ts`)
- Mixed compiled and source code in `wwwroot/js/`
- Legacy files in root directory (`calendar-*.js`)
**Evidence:**
```
src/utils/PositionUtils.js (JavaScript)
src/utils/PositionUtils.ts (TypeScript)
calendar-grid-manager.js (Root legacy file)
```
**Recommendation:** Delete all `.js` files in `src/`, remove legacy root files, keep only TypeScript sources.
---
### 2. Event System Overcomplexity
**Severity:** Critical
**Impact:** Impossible to maintain, performance degradation
**Problems:**
- Two overlapping event systems (`EventTypes.ts` with 102 events + `CalendarState.ts`)
- Unclear separation of concerns
- Legacy events marked as "removed" but still present
**Evidence:**
```typescript
// EventTypes.ts - 102 constants!
export const EventTypes = {
CONFIG_UPDATE: 'calendar:configupdate',
CALENDAR_TYPE_CHANGED: 'calendar:calendartypechanged',
// ... 100 more events
}
// CalendarState.ts - Another event system
export const StateEvents = {
CALENDAR_STATE_CHANGED: 'calendar:state:changed',
// ... more events
}
```
**Recommendation:** Consolidate to ~20 core events with clear ownership.
---
### 3. Missing Error Handling
**Severity:** High
**Impact:** Silent failures, poor user experience
**Problems:**
- No try-catch blocks in critical paths
- No error boundaries for component failures
- DateCalculator assumes all inputs are valid
**Evidence:**
```typescript
// DateCalculator.ts - No validation
getISOWeekStart(date: Date): Date {
const monday = new Date(date); // What if date is invalid?
const currentDay = monday.getDay();
// ... continues without checks
}
```
**Recommendation:** Add comprehensive error handling and validation.
---
### 4. Memory Leak Potential
**Severity:** Critical
**Impact:** Browser performance degradation over time
**Problems:**
- Event listeners never cleaned up
- DOM references held indefinitely
- Multiple DateCalculator instances created
**Evidence:**
```typescript
// ViewManager.ts - No cleanup
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
this.setupEventListeners(); // Never removed!
}
// No destroy() method exists
```
**Recommendation:** Implement proper cleanup in all managers.
---
### 5. Single Responsibility Violations
**Severity:** High
**Impact:** Unmaintainable code, difficult to test
**Problems:**
- `GridManager`: Handles rendering, events, styling, positioning (311 lines!)
- `CalendarConfig`: Config, state management, and factory logic mixed
- `NavigationRenderer`: DOM manipulation and event rendering
**Evidence:**
```typescript
// GridManager doing everything:
- subscribeToEvents()
- render()
- setupGridInteractions()
- getClickPosition()
- scrollToHour()
- minutesToTime()
```
**Recommendation:** Split into focused, single-purpose classes.
---
### 6. Dependency Injection Missing
**Severity:** Medium
**Impact:** Untestable code, tight coupling
**Problems:**
- Hard-coded singleton imports everywhere
- `calendarConfig` imported directly in 15+ files
- Circular dependencies through EventBus
**Evidence:**
```typescript
import { calendarConfig } from '../core/CalendarConfig'; // Singleton
import { eventBus } from '../core/EventBus'; // Another singleton
```
**Recommendation:** Use constructor injection pattern.
---
### 7. Performance Problems
**Severity:** Medium
**Impact:** Sluggish UI, especially with many events
**Problems:**
- `document.querySelector` called repeatedly
- No caching of DOM elements
- Full re-renders on every change
**Evidence:**
```typescript
// Called multiple times per render:
const scrollableContent = document.querySelector('swp-scrollable-content');
```
**Recommendation:** Cache DOM queries, implement selective rendering.
---
### 8. Type Safety Issues
**Severity:** High
**Impact:** Runtime errors, hidden bugs
**Problems:**
- `any` types used extensively
- Type casting to bypass checks
- Missing interfaces for data structures
**Evidence:**
```typescript
private allDayEvents: any[] = []; // No type safety
(header as any).dataset.today = 'true'; // Bypassing TypeScript
```
**Recommendation:** Define proper TypeScript interfaces for all data.
---
## 🔧 TECHNICAL DEBT
### 9. Redundant Code
- Duplicate date logic in DateCalculator and PositionUtils
- Headers rendered in multiple places
- Similar event handling patterns copy-pasted
### 10. Testability Issues
- No dependency injection makes mocking impossible
- Direct DOM manipulation prevents unit testing
- Global state makes tests brittle
### 11. Documentation Problems
- Mixed Danish/English comments
- Missing JSDoc for public APIs
- Outdated comments that don't match code
---
## ⚡ ARCHITECTURE ISSUES
### 12. Massive Interfaces
- `CalendarTypes.ts`: Too many interfaces in one file
- `EventTypes`: 102 constants is unmanageable
- Manager interfaces too broad
### 13. Coupling Problems
- High coupling between managers
- Everything communicates via events (performance hit)
- All components depend on global config
### 14. Naming Inconsistency
- Mixed language conventions
- Unclear event names (`REFRESH_REQUESTED` vs `CALENDAR_REFRESH_REQUESTED`)
- `swp-` prefix unexplained
---
## 📊 METRICS
### Code Quality Scores
- **Maintainability:** 3/10
- **Testability:** 2/10
- **Performance:** 5/10
- **Type Safety:** 4/10
- **Architecture:** 3/10
### File Statistics
- **Total TypeScript files:** 24
- **Total JavaScript files:** 8 (should be 0)
- **Average file size:** ~200 lines (acceptable)
- **Largest file:** GridManager.ts (311 lines)
- **Event types defined:** 102 (should be ~20)
---
## 🎯 RECOMMENDATIONS
### Immediate Actions (Week 1)
1. **Remove duplicate files** - Clean up `.js` duplicates
2. **Add error boundaries** - Prevent cascade failures
3. **Fix memory leaks** - Add cleanup methods
### Short Term (Month 1)
4. **Consolidate events** - Reduce to core 20 events
5. **Implement DI** - Remove singleton dependencies
6. **Split mega-classes** - Apply Single Responsibility
### Long Term (Quarter 1)
7. **Add comprehensive tests** - Aim for 80% coverage
8. **Performance optimization** - Virtual scrolling, caching
9. **Complete documentation** - JSDoc all public APIs
---
## Impact on Month View Implementation
**Without refactoring:**
- 🔴 ~2000 lines of new code
- 🔴 3-4 weeks implementation
- 🔴 High bug risk
**With minimal refactoring:**
- ✅ ~500 lines of new code
- ✅ 1 week implementation
- ✅ Reusable components
---
## Conclusion
The codebase requires significant refactoring to support new features efficiently. The identified issues, particularly the lack of strategy pattern and hardcoded week/day assumptions, make adding month view unnecessarily complex.
**Priority:** Focus on minimal refactoring that enables month view (Strategy pattern, config split, event consolidation) before attempting to add new features.

View file

@ -1,270 +0,0 @@
# Month View Implementation Plan (POST-REFACTORING)
**Updated:** January 2025
**Status:** Ready to implement - Foundation cleaned up
**Timeline:** 2 days (reduced from 3)
## Pre-Work Completed ✅
The following critical issues have been resolved, making month view implementation much easier:
### ✅ Foundation Improvements Done
- **Event system simplified**: 102 → 20 events with CoreEvents.ts
- **Dead code removed**: CalendarState.ts (170 lines) deleted
- **Type safety improved**: Proper event interfaces defined
- **Error handling added**: Date validation in DateCalculator
- **Build fixed**: tsconfig.json output directory corrected
### ✅ Impact on Month View
- **Clearer event system**: Know exactly which events to use
- **No confusing StateEvents**: Removed competing event system
- **Better types**: AllDayEvent interface ready for month events
- **Reliable dates**: DateCalculator won't crash on bad input
---
## Revised Implementation Plan
### Phase 1: Strategy Pattern (4 hours → 2 hours)
*Time saved: Dead code removed, events clarified*
#### 1.1 Create ViewStrategy Interface ✨
**New file:** `src/strategies/ViewStrategy.ts`
```typescript
import { CoreEvents } from '../constants/CoreEvents'; // Use new events!
export interface ViewStrategy {
renderGrid(container: HTMLElement, context: ViewContext): void;
renderEvents(events: AllDayEvent[], container: HTMLElement): void; // Use proper types!
getLayoutConfig(): ViewLayoutConfig;
handleNavigation(date: Date): Date; // Now validated!
}
```
#### 1.2 Extract WeekViewStrategy ✨
**New file:** `src/strategies/WeekViewStrategy.ts`
- Move existing logic from GridManager
- Use CoreEvents instead of EventTypes
- Leverage improved type safety
#### 1.3 Create MonthViewStrategy
**New file:** `src/strategies/MonthViewStrategy.ts`
```typescript
export class MonthViewStrategy implements ViewStrategy {
renderGrid(container: HTMLElement, context: ViewContext): void {
// 7x6 month grid - no time axis needed
this.createMonthGrid(container, context.currentDate);
}
renderEvents(events: AllDayEvent[], container: HTMLElement): void {
// Use proper AllDayEvent types (now defined!)
// Simple day cell rendering
}
}
```
#### 1.4 Update GridManager
**Modify:** `src/managers/GridManager.ts`
```typescript
export class GridManager {
private strategy: ViewStrategy;
setViewStrategy(strategy: ViewStrategy): void {
this.strategy = strategy;
// No memory leaks - cleanup is now handled!
}
render(): void {
// Emit CoreEvents.GRID_RENDERED instead of old events
this.eventBus.emit(CoreEvents.GRID_RENDERED, {...});
}
}
```
---
### Phase 2: Month Components (2 hours → 1.5 hours)
*Time saved: Better types, no conflicting events*
#### 2.1 MonthGridRenderer
**New file:** `src/renderers/MonthGridRenderer.ts`
```typescript
import { AllDayEvent } from '../types/EventTypes'; // Proper types!
import { CoreEvents } from '../constants/CoreEvents';
export class MonthGridRenderer {
renderMonth(container: HTMLElement, date: Date): void {
// DateCalculator.validateDate() prevents crashes
this.dateCalculator.validateDate(date, 'renderMonth');
// Create 7x6 grid with proper types
}
}
```
#### 2.2 MonthEventRenderer
**New file:** `src/renderers/MonthEventRenderer.ts`
```typescript
export class MonthEventRenderer {
render(events: AllDayEvent[], container: HTMLElement): void {
// Use AllDayEvent interface - no more any!
// Clean event filtering using proper types
}
}
```
---
### Phase 3: Integration (2 hours → 1 hour)
*Time saved: Clear events, no StateEvents confusion*
#### 3.1 Wire ViewManager
**Modify:** `src/managers/ViewManager.ts`
```typescript
private changeView(newView: CalendarView): void {
let strategy: ViewStrategy;
switch(newView) {
case 'month':
strategy = new MonthViewStrategy();
break;
// ... other views
}
this.gridManager.setViewStrategy(strategy);
// Use CoreEvents - no confusion about which events!
this.eventBus.emit(CoreEvents.VIEW_CHANGED, { view: newView });
}
```
#### 3.2 Update HTML & CSS
**Modify:** `wwwroot/index.html`
```html
<!-- Enable month view - no conflicting events to worry about -->
<swp-view-button data-view="month">Month</swp-view-button>
```
**New:** `wwwroot/css/calendar-month.css`
```css
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: auto repeat(6, 1fr);
}
.month-day-cell {
border: 1px solid var(--border-color);
min-height: 120px;
padding: 4px;
}
.month-event {
font-size: 0.75rem;
padding: 1px 4px;
margin: 1px 0;
border-radius: 2px;
background: var(--event-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
```
---
## Updated Timeline
### Day 1 (Reduced from full day)
**Morning (2 hours)**
- ✅ Foundation already clean
- Implement ViewStrategy interface
- Extract WeekViewStrategy
- Create MonthViewStrategy skeleton
**Afternoon (1 hour)**
- Wire strategies in GridManager
- Test view switching works
### Day 2
**Morning (1.5 hours)**
- Implement MonthGridRenderer
- Implement MonthEventRenderer
- Create month CSS
**Afternoon (1 hour)**
- Final integration
- Enable month button
- Test and polish
**Total: 5.5 hours instead of 16+ hours!**
---
## Benefits from Pre-Refactoring
### 🚀 **Development Speed**
- **No conflicting events**: Clear which events to use
- **No dead code confusion**: CalendarState removed
- **Proper types**: AllDayEvent interface ready
- **Reliable foundation**: DateCalculator validation prevents crashes
### 🎯 **Quality**
- **Consistent patterns**: Following established CoreEvents
- **Type safety**: No more `any` types to debug
- **Memory management**: Cleanup patterns established
- **Error handling**: Built-in date validation
### 🔧 **Maintainability**
- **Single event system**: No EventTypes vs StateEvents confusion
- **Clean codebase**: 200+ lines of cruft removed
- **Clear interfaces**: AllDayEvent, ViewStrategy defined
- **Proper separation**: Strategy pattern foundation laid
---
## Success Metrics (Updated)
### ✅ **Foundation Quality**
- [x] Event system consolidated (20 events)
- [x] Dead code removed
- [x] Types properly defined
- [x] Date validation added
- [x] Build configuration fixed
### 🎯 **Month View Goals**
- [ ] Month grid displays 6 weeks correctly
- [ ] Events show in day cells (max 3 + "more")
- [ ] Navigation works (prev/next month)
- [ ] View switching between week/month
- [ ] No regressions in existing views
- [ ] Under 400 lines of new code (down from 750!)
### 📊 **Expected Results**
- **Implementation time**: 5.5 hours (67% reduction)
- **Code quality**: Higher (proper types, clear events)
- **Maintainability**: Much improved (clean foundation)
- **Bug risk**: Lower (validation, proper cleanup)
---
## Risk Assessment (Much Improved)
### ✅ **Risks Eliminated**
- ~~Event system conflicts~~ → Single CoreEvents system
- ~~Type errors~~ → Proper AllDayEvent interface
- ~~Date crashes~~ → DateCalculator validation
- ~~Memory leaks~~ → Cleanup patterns established
- ~~Dead code confusion~~ → CalendarState removed
### ⚠️ **Remaining Risks (Low)**
1. **CSS conflicts**: Mitigated with namespaced `.month-view` classes
2. **Performance with many events**: Can implement virtualization later
3. **Browser compatibility**: CSS Grid widely supported
---
## Conclusion
The pre-refactoring work has transformed this from a difficult, error-prone implementation into a straightforward feature addition. The month view can now be implemented cleanly in ~5.5 hours with high confidence and low risk.
**Ready to proceed!** 🚀

View file

@ -1,456 +0,0 @@
# Month View Refactoring Plan
**Purpose:** Enable month view with minimal refactoring
**Timeline:** 3 days (6 hours of focused work)
**Priority:** High - Blocks new feature development
## Overview
This plan addresses only the critical architectural issues that prevent month view implementation. By focusing on the minimal necessary changes, we can add month view in ~500 lines instead of ~2000 lines.
---
## Current Blockers for Month View
### 🚫 Why Month View Can't Be Added Now
1. **GridManager is hardcoded for time-based views**
- Assumes everything is hours and columns
- Time axis doesn't make sense for months
- Hour-based scrolling irrelevant
2. **No strategy pattern for different view types**
- Would need entirely new managers
- Massive code duplication
- Inconsistent behavior
3. **Config assumes time-based views**
```typescript
hourHeight: 60,
dayStartHour: 0,
snapInterval: 15
// These are meaningless for month view!
```
4. **Event rendering tied to time positions**
- Events positioned by minutes
- No concept of day cells
- Can't handle multi-day spans properly
---
## Phase 1: View Strategy Pattern (2 hours)
### 1.1 Create ViewStrategy Interface
**New file:** `src/strategies/ViewStrategy.ts`
```typescript
export interface ViewStrategy {
// Core rendering methods
renderGrid(container: HTMLElement, context: ViewContext): void;
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
// Configuration
getLayoutConfig(): ViewLayoutConfig;
getRequiredConfig(): string[]; // Which config keys this view needs
// Navigation
getNextPeriod(currentDate: Date): Date;
getPreviousPeriod(currentDate: Date): Date;
getPeriodLabel(date: Date): string;
}
export interface ViewContext {
currentDate: Date;
config: CalendarConfig;
events: CalendarEvent[];
container: HTMLElement;
}
```
### 1.2 Extract WeekViewStrategy
**New file:** `src/strategies/WeekViewStrategy.ts`
- Move existing logic from GridRenderer
- Keep all time-based rendering
- Minimal changes to existing code
```typescript
export class WeekViewStrategy implements ViewStrategy {
renderGrid(container: HTMLElement, context: ViewContext): void {
// Move existing GridRenderer.renderGrid() here
this.createTimeAxis(container);
this.createDayColumns(container, context);
this.createTimeSlots(container);
}
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
// Move existing EventRenderer logic
// Position by time as before
}
}
```
### 1.3 Create MonthViewStrategy
**New file:** `src/strategies/MonthViewStrategy.ts`
```typescript
export class MonthViewStrategy implements ViewStrategy {
renderGrid(container: HTMLElement, context: ViewContext): void {
// Create 7x6 grid
this.createMonthHeader(container); // Mon-Sun
this.createWeekRows(container, context);
}
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
// Render as small blocks in day cells
// Handle multi-day spanning
}
}
```
### 1.4 Update GridManager
**Modify:** `src/managers/GridManager.ts`
```typescript
export class GridManager {
private strategy: ViewStrategy;
setViewStrategy(strategy: ViewStrategy): void {
this.strategy = strategy;
}
render(): void {
// Delegate to strategy
this.strategy.renderGrid(this.grid, {
currentDate: this.currentWeek,
config: this.config,
events: this.events,
container: this.grid
});
}
}
```
---
## Phase 2: Configuration Split (1 hour)
### 2.1 View-Specific Configs
**New file:** `src/core/ViewConfigs.ts`
```typescript
// Shared by all views
export interface BaseViewConfig {
locale: string;
firstDayOfWeek: number;
dateFormat: string;
eventColors: Record<string, string>;
}
// Week/Day views only
export interface TimeViewConfig extends BaseViewConfig {
hourHeight: number;
dayStartHour: number;
dayEndHour: number;
snapInterval: number;
showCurrentTime: boolean;
}
// Month view only
export interface MonthViewConfig extends BaseViewConfig {
weeksToShow: number; // Usually 6
showWeekNumbers: boolean;
compactMode: boolean;
eventLimit: number; // Max events shown per day
showMoreText: string; // "+2 more"
}
```
### 2.2 Update CalendarConfig
**Modify:** `src/core/CalendarConfig.ts`
```typescript
export class CalendarConfig {
private viewConfigs: Map<string, BaseViewConfig> = new Map();
constructor() {
// Set defaults for each view
this.viewConfigs.set('week', defaultWeekConfig);
this.viewConfigs.set('month', defaultMonthConfig);
}
getViewConfig<T extends BaseViewConfig>(view: string): T {
return this.viewConfigs.get(view) as T;
}
}
```
---
## Phase 3: Event Consolidation (1 hour)
### 3.1 Core Events Only
**New file:** `src/constants/CoreEvents.ts`
```typescript
export const CoreEvents = {
// View lifecycle (5 events)
VIEW_CHANGED: 'view:changed',
VIEW_RENDERED: 'view:rendered',
// Navigation (3 events)
DATE_CHANGED: 'date:changed',
PERIOD_CHANGED: 'period:changed',
// Data (4 events)
EVENTS_LOADING: 'events:loading',
EVENTS_LOADED: 'events:loaded',
EVENT_CLICKED: 'event:clicked',
EVENT_UPDATED: 'event:updated',
// UI State (3 events)
LOADING_START: 'ui:loading:start',
LOADING_END: 'ui:loading:end',
ERROR: 'ui:error',
// Grid (3 events)
GRID_RENDERED: 'grid:rendered',
GRID_CLICKED: 'grid:clicked',
CELL_CLICKED: 'cell:clicked'
};
// Total: ~18 events instead of 102!
```
### 3.2 Migration Map
**Modify:** `src/constants/EventTypes.ts`
```typescript
// Keep old events but map to new ones
export const EventTypes = {
VIEW_CHANGED: CoreEvents.VIEW_CHANGED, // Direct mapping
WEEK_CHANGED: CoreEvents.PERIOD_CHANGED, // Renamed
// ... etc
} as const;
```
---
## Phase 4: Month-Specific Renderers (2 hours)
### 4.1 MonthGridRenderer
**New file:** `src/renderers/MonthGridRenderer.ts`
```typescript
export class MonthGridRenderer {
render(container: HTMLElement, date: Date): void {
const grid = this.createGrid();
// Add day headers
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(day => {
grid.appendChild(this.createDayHeader(day));
});
// Add 6 weeks of days
const dates = this.getMonthDates(date);
dates.forEach(weekDates => {
weekDates.forEach(date => {
grid.appendChild(this.createDayCell(date));
});
});
container.appendChild(grid);
}
private createGrid(): HTMLElement {
const grid = document.createElement('div');
grid.className = 'month-grid';
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(7, 1fr)';
return grid;
}
}
```
### 4.2 MonthEventRenderer
**New file:** `src/renderers/MonthEventRenderer.ts`
```typescript
export class MonthEventRenderer {
render(events: CalendarEvent[], container: HTMLElement): void {
const dayMap = this.groupEventsByDay(events);
dayMap.forEach((dayEvents, dateStr) => {
const dayCell = container.querySelector(`[data-date="${dateStr}"]`);
if (!dayCell) return;
const limited = dayEvents.slice(0, 3); // Show max 3
limited.forEach(event => {
dayCell.appendChild(this.createEventBlock(event));
});
if (dayEvents.length > 3) {
dayCell.appendChild(this.createMoreIndicator(dayEvents.length - 3));
}
});
}
}
```
---
## Phase 5: Integration (1 hour)
### 5.1 Wire ViewManager
**Modify:** `src/managers/ViewManager.ts`
```typescript
private changeView(newView: CalendarView): void {
// Create appropriate strategy
let strategy: ViewStrategy;
switch(newView) {
case 'week':
case 'day':
strategy = new WeekViewStrategy();
break;
case 'month':
strategy = new MonthViewStrategy();
break;
}
// Update GridManager
this.gridManager.setViewStrategy(strategy);
// Trigger re-render
this.eventBus.emit(CoreEvents.VIEW_CHANGED, { view: newView });
}
```
### 5.2 Enable Month Button
**Modify:** `wwwroot/index.html`
```html
<!-- Remove disabled attribute -->
<swp-view-button data-view="month">Month</swp-view-button>
```
### 5.3 Add Month Styles
**New file:** `wwwroot/css/calendar-month-css.css`
```css
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--color-border);
}
.month-day-cell {
background: white;
min-height: 100px;
padding: 4px;
position: relative;
}
.month-day-number {
font-weight: bold;
margin-bottom: 4px;
}
.month-event {
font-size: 0.75rem;
padding: 2px 4px;
margin: 1px 0;
border-radius: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.month-more-indicator {
font-size: 0.7rem;
color: var(--color-text-secondary);
cursor: pointer;
}
```
---
## Implementation Timeline
### Day 1 (Monday)
**Morning (2 hours)**
- [ ] Implement ViewStrategy interface
- [ ] Extract WeekViewStrategy
- [ ] Create MonthViewStrategy skeleton
**Afternoon (1 hour)**
- [ ] Split configuration
- [ ] Update CalendarConfig
### Day 2 (Tuesday)
**Morning (2 hours)**
- [ ] Consolidate events to CoreEvents
- [ ] Create migration mappings
- [ ] Update critical event listeners
**Afternoon (2 hours)**
- [ ] Implement MonthGridRenderer
- [ ] Implement MonthEventRenderer
### Day 3 (Wednesday)
**Morning (2 hours)**
- [ ] Wire everything in ViewManager
- [ ] Update HTML and CSS
- [ ] Test month view
- [ ] Fix edge cases
---
## Success Metrics
### ✅ Definition of Done
- [ ] Month view displays 6 weeks correctly
- [ ] Events show in day cells (max 3 + "more")
- [ ] Navigation works (prev/next month)
- [ ] Switching between week/month works
- [ ] No regression in week view
- [ ] Under 750 lines of new code
### 📊 Expected Impact
- **New code:** ~500-750 lines (vs 2000 without refactoring)
- **Reusability:** 80% of components shared
- **Future views:** Day view = 100 lines, Year view = 200 lines
- **Test coverage:** Easy to test strategies independently
- **Performance:** No impact on existing views
---
## Risk Mitigation
### Potential Issues & Solutions
1. **CSS conflicts between views**
- Solution: Namespace all month CSS with `.month-view`
2. **Event overlap in month cells**
- Solution: Implement "more" indicator after 3 events
3. **Performance with many events**
- Solution: Only render visible month
4. **Browser compatibility**
- Solution: Use CSS Grid with flexbox fallback
---
## Next Steps After Month View
Once this refactoring is complete, adding new views becomes trivial:
- **Day View:** ~100 lines (reuse WeekViewStrategy with 1 column)
- **Year View:** ~200 lines (12 small month grids)
- **Agenda View:** ~150 lines (list layout)
- **Timeline View:** ~300 lines (horizontal time axis)
The strategy pattern makes the calendar truly extensible!

View file

@ -1,460 +0,0 @@
# Complete Calendar Component Specification
## 1. Project Overview
### Purpose
Build a professional calendar component with week, day, and month views, featuring drag-and-drop functionality, event management, and real-time synchronization.
### Technology Stack
- **Frontend**: Vanilla JavaScript (ES Modules), ready for TypeScript conversion
- **Styling**: CSS with nested selectors, CSS Grid/Flexbox
- **Backend** (planned): .NET Core with SignalR
- **Architecture**: Modular manager-based system with event-driven communication
### Design Principles
1. **Modularity**: Each manager handles one specific concern
2. **Loose Coupling**: Communication via custom events on document
3. **No External Dependencies**: Pure JavaScript implementation
4. **Custom HTML Tags**: Semantic markup without Web Components registration
5. **CSS-based Positioning**: Events positioned using CSS calc() and variables
## 2. What Has Been Implemented
### 2.1 Core Infrastructure
#### EventBus.js ✅
- Central event dispatcher for all calendar events
- Publish/subscribe pattern implementation
- Debug logging capabilities
- Event history tracking
- Priority-based listeners
#### CalendarConfig.js ✅
- Centralized configuration management
- Default values for all settings
- DOM data-attribute reading
- Computed value calculations (minuteHeight, totalSlots, etc.)
- Configuration change events
#### EventTypes.js ✅
- All event type constants defined
- Organized by category (view, CRUD, interaction, UI, data, state)
- Consistent naming convention
### 2.2 Managers
#### GridManager.js ✅
- Renders time axis with configurable hours
- Creates week headers with day names and dates
- Generates day columns for events
- Sets up grid interactions (click, dblclick)
- Updates CSS variables for dynamic styling
- Handles grid click position calculations with snap
#### DataManager.js ✅
- Mock data generation for testing
- API request preparation (ready for backend)
- Cache management
- Event CRUD operations
- Loading state management
- Sync status handling
### 2.3 Utilities
#### DateUtils.js ✅
- Week start/end calculations
- Date/time formatting (12/24 hour)
- Duration calculations
- Time-to-minutes conversions
- Week number calculation (ISO standard)
- Snap-to-interval logic
### 2.4 Styles
#### base.css ✅
- CSS reset and variables
- Color scheme definition
- Grid measurements
- Animation keyframes
- Utility classes
#### layout.css ✅
- Main calendar container structure
- CSS Grid layout for calendar
- Time axis styling
- Week headers with sticky positioning
- Scrollable content area
- Work hours background indication
#### navigation.css ✅
- Top navigation bar layout
- Button styling (prev/next/today)
- View selector (day/week/month)
- Search box with icons
- Week info display
#### events.css ✅
- Event card styling by type
- Hover and active states
- Resize handles design
- Multi-day event styling
- Sync status indicators
- CSS-based positioning system
#### popup.css ✅
- Event popup styling
- Chevron arrow positioning
- Action buttons
- Loading overlay
- Snap indicators
### 2.5 HTML Structure ✅
- Semantic custom HTML tags
- Modular component structure
- No inline styles or JavaScript
- Data attributes for configuration
## 3. Implementation Details
### 3.1 Event Positioning System
```css
swp-event {
/* Position via CSS variables */
top: calc(var(--start-minutes) * var(--minute-height));
height: calc(var(--duration-minutes) * var(--minute-height));
}
```
### 3.2 Custom Event Flow
```javascript
// Example event flow for drag operation
1. User mousedown on event
2. DragManager → emit('calendar:dragstart')
3. ResizeManager → disable()
4. GridManager → show snap lines
5. User mousemove
6. DragManager → emit('calendar:dragmove')
7. EventRenderer → update ghost position
8. User mouseup
9. DragManager → emit('calendar:dragend')
10. EventManager → update event data
11. DataManager → sync to backend
```
### 3.3 Configuration Options
```javascript
{
view: 'week', // 'day' | 'week' | 'month'
weekDays: 7, // 4-7 days for week view
dayStartHour: 7, // Calendar start time
dayEndHour: 19, // Calendar end time
workStartHour: 8, // Work hours highlighting
workEndHour: 17,
snapInterval: 15, // Minutes: 5, 10, 15, 30, 60
hourHeight: 60, // Pixels per hour
showCurrentTime: true,
allowDrag: true,
allowResize: true,
allowCreate: true
}
```
## 4. What Needs to Be Implemented
### 4.1 Missing Managers
#### CalendarManager.js 🔲
**Purpose**: Main coordinator for all managers
```javascript
class CalendarManager {
- Initialize all managers in correct order
- Handle app lifecycle (start, destroy)
- Coordinate cross-manager operations
- Global error handling
- State persistence
}
```
#### ViewManager.js 🔲
**Purpose**: Handle view mode changes
```javascript
class ViewManager {
- Switch between day/week/month views
- Calculate visible date range
- Update grid structure for view
- Emit view change events
- Handle view-specific settings
}
```
#### NavigationManager.js 🔲
**Purpose**: Handle navigation controls
```javascript
class NavigationManager {
- Previous/Next period navigation
- Today button functionality
- Update week info display
- Coordinate with animations
- Handle navigation limits
}
```
#### EventManager.js 🔲
**Purpose**: Manage event lifecycle
```javascript
class EventManager {
- Store events in memory
- Handle event CRUD operations
- Manage event selection
- Calculate event overlaps
- Validate event constraints
}
```
#### EventRenderer.js 🔲
**Purpose**: Render events in DOM
```javascript
class EventRenderer {
- Create event DOM elements
- Calculate pixel positions
- Handle collision layouts
- Render multi-day events
- Update event appearance
}
```
#### DragManager.js 🔲
**Purpose**: Handle drag operations
```javascript
class DragManager {
- Track drag state
- Create ghost element
- Calculate snap positions
- Validate drop targets
- Handle multi-select drag
}
```
#### ResizeManager.js 🔲
**Purpose**: Handle resize operations
```javascript
class ResizeManager {
- Add/remove resize handles
- Track resize direction
- Calculate new duration
- Enforce min/max limits
- Snap to intervals
}
```
#### PopupManager.js 🔲
**Purpose**: Show event details popup
```javascript
class PopupManager {
- Show/hide popup
- Smart positioning (left/right)
- Update popup content
- Handle action buttons
- Click-outside detection
}
```
#### SearchManager.js 🔲
**Purpose**: Search functionality
```javascript
class SearchManager {
- Real-time search
- Highlight matching events
- Update transparency
- Clear search
- Search history
}
```
#### TimeManager.js 🔲
**Purpose**: Current time indicator
```javascript
class TimeManager {
- Show red line at current time
- Update position every minute
- Auto-scroll to current time
- Show/hide based on view
}
```
#### LoadingManager.js 🔲
**Purpose**: Loading states
```javascript
class LoadingManager {
- Show/hide spinner
- Block interactions
- Show error states
- Progress indication
}
```
### 4.2 Missing Utilities
#### PositionUtils.js 🔲
```javascript
- pixelsToMinutes(y, config)
- minutesToPixels(minutes, config)
- getEventBounds(element)
- detectCollisions(events)
- calculateOverlapGroups(events)
```
#### SnapUtils.js 🔲
```javascript
- snapToInterval(value, interval)
- getNearestSlot(position, interval)
- calculateSnapPoints(config)
- isValidSnapPosition(position)
```
#### DOMUtils.js 🔲
```javascript
- createElement(tag, attributes, children)
- toggleClass(element, className, force)
- findParent(element, selector)
- batchUpdate(updates)
```
### 4.3 Missing Features
#### Animation System 🔲
- Week-to-week slide transition (as shown in POC)
- Smooth state transitions
- Drag preview animations
- Loading animations
#### Collision Detection System 🔲
```javascript
// Two strategies needed:
1. Side-by-side: Events share column width
2. Overlay: Events stack with z-index
```
#### Multi-day Event Support 🔲
- Events spanning multiple days
- Visual continuation indicators
- Proper positioning in week header area
#### Touch Support 🔲
- Touch drag/drop
- Pinch to zoom
- Swipe navigation
- Long press for context menu
#### Keyboard Navigation 🔲
- Tab through events
- Arrow keys for selection
- Enter to edit
- Delete key support
#### Context Menu 🔲
- Right-click on events
- Right-click on empty slots
- Quick actions menu
#### Event Creation 🔲
- Double-click empty slot
- Drag to create
- Default duration
- Inline editing
#### Advanced Features 🔲
- Undo/redo stack
- Copy/paste events
- Bulk operations
- Print view
- Export (iCal, PDF)
- Recurring events UI
- Event templates
- Color customization
- Resource scheduling
- Timezone support
## 5. Integration Points
### 5.1 Backend API Endpoints
```
GET /api/events?start={date}&end={date}&view={view}
POST /api/events
PATCH /api/events/{id}
DELETE /api/events/{id}
GET /api/events/search?q={query}
```
### 5.2 SignalR Events
```
- EventCreated
- EventUpdated
- EventDeleted
- EventsReloaded
```
### 5.3 Data Models
```typescript
interface CalendarEvent {
id: string;
title: string;
start: string; // ISO 8601
end: string; // ISO 8601
type: 'meeting' | 'meal' | 'work' | 'milestone';
allDay: boolean;
syncStatus: 'synced' | 'pending' | 'error';
recurringId?: string;
resources?: string[];
metadata?: Record<string, any>;
}
```
## 6. Performance Considerations
1. **Virtual Scrolling**: For large date ranges
2. **Event Pooling**: Reuse DOM elements
3. **Throttled Updates**: During drag/resize
4. **Batch Operations**: For multiple changes
5. **Lazy Loading**: Load events as needed
6. **Web Workers**: For heavy calculations
## 7. Testing Strategy
1. **Unit Tests**: Each manager/utility
2. **Integration Tests**: Manager interactions
3. **E2E Tests**: User workflows
4. **Performance Tests**: Large datasets
5. **Accessibility Tests**: Keyboard/screen reader
## 8. Deployment Considerations
1. **Build Process**: Bundle modules
2. **Minification**: Reduce file size
3. **Code Splitting**: Load on demand
4. **CDN**: Static assets
5. **Monitoring**: Error tracking
6. **Analytics**: Usage patterns
## 9. Future Enhancements
1. **AI Integration**: Smart scheduling
2. **Mobile Apps**: Native wrappers
3. **Offline Support**: Service workers
4. **Collaboration**: Real-time cursors
5. **Advanced Analytics**: Usage insights
6. **Third-party Integrations**: Google Calendar, Outlook
## 10. Migration Path
### From POC to Production:
1. Extract animation logic from POC
2. Implement missing managers
3. Add error boundaries
4. Implement loading states
5. Add accessibility
6. Performance optimization
7. Security hardening
8. Documentation
9. Testing suite
10. Deployment pipeline

View file

@ -1,393 +0,0 @@
# Calendar Plantempus - Comprehensive TypeScript Code Review
## Executive Summary
This is a well-architected calendar application built with vanilla TypeScript and DOM APIs, implementing sophisticated event-driven communication patterns and drag-and-drop functionality. The codebase demonstrates advanced TypeScript usage, clean separation of concerns, and performance-optimized DOM manipulation.
## Architecture Overview
### Core Design Patterns
**Event-Driven Architecture**: The application uses a centralized EventBus system with DOM CustomEvents for all inter-component communication. This eliminates tight coupling and provides excellent separation of concerns.
**Manager Pattern**: Each domain responsibility is encapsulated in dedicated managers, creating a modular architecture that's easy to maintain and extend.
**Strategy Pattern**: View rendering uses strategy pattern with `DateEventRenderer` and `ResourceEventRenderer` implementations.
**Factory Pattern**: Used for creating managers and calendar types, promoting loose coupling.
### Key Architectural Strengths
1. **Pure DOM/TypeScript Implementation**: No external frameworks reduces bundle size and complexity
2. **Centralized Configuration**: Singleton pattern for configuration management
3. **Type Safety**: Comprehensive TypeScript types with proper union types and interfaces
4. **Performance Optimizations**: Extensive use of caching, batching, and optimized DOM queries
---
## Core System Analysis
### 1. EventBus System (`src/core/EventBus.ts`) ⭐⭐⭐⭐⭐
**Strengths:**
- Pure DOM CustomEvents implementation - elegant and leverages browser event system
- Comprehensive logging with categorization and filtering
- Proper memory management with listener tracking
- Singleton pattern with clean API
- Built-in debug mode with visual categorization
**Code Quality:**
```typescript
// Excellent event emission with proper validation
emit(eventType: string, detail: any = {}): boolean {
if (!eventType || typeof eventType !== 'string') {
return false;
}
const event = new CustomEvent(eventType, {
detail,
bubbles: true,
cancelable: true
});
return !document.dispatchEvent(event);
}
```
**Minor Improvements:**
- `logEventWithGrouping` method is incomplete (line 105)
- Could benefit from TypeScript generics for type-safe detail objects
### 2. Type System (`src/types/*.ts`) ⭐⭐⭐⭐⭐
**Exceptional Type Safety:**
```typescript
export interface CalendarEvent {
id: string;
title: string;
start: string; // ISO 8601
end: string; // ISO 8601
type: string;
allDay: boolean;
syncStatus: SyncStatus;
resource?: Resource;
recurringId?: string;
metadata?: Record<string, any>;
}
```
**Highlights:**
- Union types for view management (`ViewPeriod`, `CalendarMode`)
- Discriminated unions with `DateModeContext` and `ResourceModeContext`
- Proper interface segregation
- Consistent ISO 8601 date handling
---
## Drag and Drop System Deep Dive ⭐⭐⭐⭐⭐
### DragDropManager (`src/managers/DragDropManager.ts`)
This is the crown jewel of the codebase - a sophisticated, performance-optimized drag-and-drop system.
#### Technical Excellence:
**1. Performance Optimizations:**
```typescript
// Consolidated position calculations to reduce DOM queries
private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } {
const column = this.detectColumn(mousePosition.x, mousePosition.y);
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
return { column, snappedY };
}
```
**2. Intelligent Caching:**
```typescript
private cachedElements: CachedElements = {
scrollContainer: null,
currentColumn: null,
lastColumnDate: null
};
```
**3. Smooth Auto-Scroll:**
```typescript
private startAutoScroll(direction: 'up' | 'down'): void {
const scroll = () => {
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.cachedElements.scrollContainer!.scrollTop += scrollAmount;
this.autoScrollAnimationId = requestAnimationFrame(scroll);
};
this.autoScrollAnimationId = requestAnimationFrame(scroll);
}
```
**4. Advanced Features:**
- **Grid Snapping**: Intelligent snapping to 15-minute intervals
- **Column Detection**: Efficient column switching with caching
- **Auto-scroll**: Smooth scrolling when dragging near edges
- **All-day Conversion**: Seamless conversion from timed to all-day events
- **Mouse Offset Preservation**: Maintains grab point during drag
#### Event Flow Architecture:
```
MouseDown → DragStart → DragMove → (Auto-scroll) → DragEnd
↓ ↓ ↓ ↓ ↓
EventBus → EventRenderer → Visual Update → Position → Finalize
```
**Minor Issues:**
- Some hardcoded values (40px for stacking threshold at line 379)
- Mixed Danish and English comments
---
## Event Rendering System ⭐⭐⭐⭐
### EventRenderer (`src/renderers/EventRenderer.ts`)
**Sophisticated Overlap Management:**
```typescript
// Intelligent overlap detection with pixel-perfect precision
private detectPixelOverlap(element1: HTMLElement, element2: HTMLElement): OverlapType {
const top1 = parseFloat(element1.style.top) || 0;
const height1 = parseFloat(element1.style.height) || 0;
const bottom1 = top1 + height1;
const top2 = parseFloat(element2.style.top) || 0;
const height2 = parseFloat(element2.style.height) || 0;
const bottom2 = top2 + height2;
if (bottom1 <= top2 || bottom2 <= top1) {
return OverlapType.NONE;
}
const startDifference = Math.abs(top1 - top2);
return startDifference > 40 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
}
```
**Advanced Drag Integration:**
- Real-time timestamp updates during drag
- Seamless event cloning with proper cleanup
- Intelligent overlap re-calculation after drops
**Architectural Strengths:**
- Strategy pattern with `DateEventRenderer` and `ResourceEventRenderer`
- Proper separation of rendering logic from positioning
- Clean drag state management
### EventOverlapManager (`src/managers/EventOverlapManager.ts`)
**Brilliant Overlap Algorithm:**
```typescript
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
if (!this.eventsOverlapInTime(event1, event2)) {
return OverlapType.NONE;
}
const start1 = new Date(event1.start).getTime();
const start2 = new Date(event2.start).getTime();
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
// Over 30 min start difference = stacking, within 30 min = column sharing
return timeDiffMinutes > 30 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
}
```
**Visual Layout Strategies:**
- **Column Sharing**: Flexbox layout for concurrent events
- **Stacking**: Margin-left offsets with z-index management
- **Dynamic Grouping**: Real-time group creation and cleanup
---
## Manager System Analysis
### CalendarManager (`src/managers/CalendarManager.ts`) ⭐⭐⭐⭐
**Excellent Orchestration:**
- Clean initialization sequence with proper error handling
- Intelligent view and date management
- WorkWeek change handling with full grid rebuilds
**Smart Period Calculations:**
```typescript
private calculateCurrentPeriod(): { start: string; end: string } {
switch (this.currentView) {
case 'week':
const weekStart = new Date(current);
const dayOfWeek = weekStart.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
weekStart.setDate(weekStart.getDate() - daysToMonday);
// ... proper ISO week calculation
}
}
```
### EventManager (`src/managers/EventManager.ts`) ⭐⭐⭐⭐
**Performance Optimizations:**
```typescript
// Intelligent caching for period queries
public getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[] {
const cacheKey = `${DateCalculator.formatISODate(startDate)}_${DateCalculator.formatISODate(endDate)}`;
if (this.lastCacheKey === cacheKey && this.eventCache.has(cacheKey)) {
return this.eventCache.get(cacheKey)!;
}
// ... filter and cache logic
}
```
**Strengths:**
- Resource and date calendar support
- Proper cache invalidation
- Event navigation with error handling
- Mock data loading with proper async patterns
### ViewManager (`src/managers/ViewManager.ts`) ⭐⭐⭐⭐
**Clean State Management:**
```typescript
// Generic button group setup eliminates duplicate code
private setupButtonGroup(selector: string, attribute: string, handler: (value: string) => void): void {
const buttons = document.querySelectorAll(selector);
buttons.forEach(button => {
const clickHandler = (event: Event) => {
event.preventDefault();
const value = button.getAttribute(attribute);
if (value) handler(value);
};
button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, clickHandler);
});
}
```
**Performance Features:**
- Button caching with cache invalidation (5-second TTL)
- Consolidated button update logic
- Proper event listener cleanup
---
## Utility System Excellence
### DateCalculator (`src/utils/DateCalculator.ts`) ⭐⭐⭐⭐⭐
**Exceptional Date Handling:**
```typescript
// Proper ISO 8601 week calculation
static getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1)/7);
}
```
**Features:**
- Static class pattern for performance
- Comprehensive date validation
- ISO week handling (Monday start)
- Internationalization support with `Intl.DateTimeFormat`
- Proper timezone handling
### PositionUtils (`src/utils/PositionUtils.ts`) ⭐⭐⭐⭐
**Pixel-Perfect Calculations:**
```typescript
public static snapToGrid(pixels: number): number {
const gridSettings = calendarConfig.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const snapPixels = PositionUtils.minutesToPixels(snapInterval);
return Math.round(pixels / snapPixels) * snapPixels;
}
```
**Strengths:**
- Delegate date operations to DateCalculator (proper separation)
- Comprehensive position/time conversions
- Grid snapping with configurable intervals
- Work hours validation
---
## Performance Analysis
### Optimizations Implemented:
1. **DOM Query Caching**: Cached elements with TTL-based invalidation
2. **Event Batching**: Consolidated position calculations in drag system
3. **Efficient Event Filtering**: Map-based caching for period queries
4. **Lazy Loading**: Components only query DOM when needed
5. **Memory Management**: Proper cleanup of event listeners and cached references
### Performance Metrics:
- Drag operations: ~60fps through requestAnimationFrame
- Event rendering: O(n log n) complexity with overlap grouping
- View switching: Cached button states prevent unnecessary DOM queries
---
## Code Quality Assessment
### Strengths:
- **Type Safety**: Comprehensive TypeScript with no `any` types
- **Error Handling**: Proper validation and graceful degradation
- **Memory Management**: Cleanup methods in all managers
- **Documentation**: Good inline documentation and method signatures
- **Consistency**: Uniform coding patterns throughout
### Technical Debt:
1. **Mixed Languages**: Danish and English comments/variables
2. **Hardcoded Values**: Some magic numbers (40px threshold, 5s cache TTL)
3. **Configuration**: Some values should be configurable
4. **Testing**: No visible test suite
### Security Considerations:
- No eval() usage
- Proper DOM sanitization in event rendering
- No direct innerHTML with user data
---
## Architecture Recommendations
### Immediate Improvements:
1. **Internationalization**: Standardize to English or implement proper i18n
2. **Configuration**: Move hardcoded values to configuration
3. **Testing**: Add unit tests for critical drag-and-drop logic
4. **Documentation**: Add architectural decision records (ADRs)
### Future Enhancements:
1. **Web Workers**: Move heavy calculations off main thread
2. **Virtual Scrolling**: For large event sets
3. **Touch Support**: Enhanced mobile drag-and-drop
4. **Accessibility**: ARIA labels and keyboard navigation
---
## Conclusion
This is an exceptionally well-crafted calendar application that demonstrates:
- **Advanced TypeScript Usage**: Proper types, interfaces, and modern patterns
- **Performance Excellence**: Sophisticated caching, batching, and optimization
- **Clean Architecture**: Event-driven design with proper separation of concerns
- **Production Ready**: Comprehensive error handling and memory management
**Overall Rating: ⭐⭐⭐⭐⭐ (Exceptional)**
The drag-and-drop system, in particular, is a masterclass in performance optimization and user experience design. The EventBus architecture provides a solid foundation for future enhancements.
**Key Technical Achievements:**
- Zero-framework implementation with modern browser APIs
- Sophisticated event overlap detection and rendering
- Performance-optimized drag operations with smooth auto-scroll
- Comprehensive date/time handling with internationalization support
- Clean, maintainable codebase with excellent type safety
This codebase serves as an excellent example of how to build complex DOM applications with vanilla TypeScript while maintaining high performance and code quality standards.

0
complexity-output.json Normal file
View file

View file

@ -1,175 +0,0 @@
# EventOverlapManager Complexity Comparison
## Original vs Simplified Implementation
### **Lines of Code Comparison**
| Aspect | Original | Simplified | Reduction |
|--------|----------|------------|-----------|
| Total Lines | ~453 lines | ~220 lines | **51% reduction** |
| Stack Management | ~150 lines | ~20 lines | **87% reduction** |
| State Tracking | Complex Map + linked list | Simple DOM queries | **100% elimination** |
---
## **Key Simplifications**
### 1. **Eliminated Complex State Tracking**
**Before:**
```typescript
// Complex linked list tracking
private stackChains = new Map<string, {
next?: string,
prev?: string,
stackLevel: number
}>();
private removeFromStackChain(eventId: string): string[] {
// 25+ lines of linked list manipulation
const chainInfo = this.stackChains.get(eventId);
// Complex prev/next linking logic...
}
```
**After:**
```typescript
// Simple DOM-based approach
public restackEventsInContainer(container: HTMLElement): void {
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => this.isStackedEvent(el as HTMLElement));
stackedEvents.forEach((element, index) => {
element.style.marginLeft = `${(index + 1) * 15}px`;
});
}
```
### 2. **Simplified Event Detection**
**Before:**
```typescript
public isStackedEvent(element: HTMLElement): boolean {
const eventId = element.dataset.eventId;
const hasMarginLeft = element.style.marginLeft !== '';
const isInStackChain = eventId ? this.stackChains.has(eventId) : false;
// Two different ways to track the same thing
return hasMarginLeft || isInStackChain;
}
```
**After:**
```typescript
public isStackedEvent(element: HTMLElement): boolean {
const marginLeft = element.style.marginLeft;
return marginLeft !== '' && marginLeft !== '0px';
}
```
### 3. **Cleaner Group Management**
**Before:**
```typescript
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
// 50+ lines including:
// - Stack chain checking
// - Complex position calculations
// - Multiple cleanup scenarios
// - Affected event re-stacking
}
```
**After:**
```typescript
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
// 20 lines of clean, focused logic:
// - Remove element
// - Handle remaining events
// - Simple container cleanup
}
```
---
## **Benefits of Simplified Approach**
### ✅ **Maintainability**
- **No complex state synchronization**
- **Single source of truth (DOM)**
- **Easier to debug and understand**
### ✅ **Performance**
- **No Map lookups or linked list traversal**
- **Direct DOM queries when needed**
- **Simpler memory management**
### ✅ **Reliability**
- **No state desynchronization bugs**
- **Fewer edge cases**
- **More predictable behavior**
### ✅ **Code Quality**
- **51% fewer lines of code**
- **Simpler mental model**
- **Better separation of concerns**
---
## **What Was Eliminated**
### 🗑️ **Removed Complexity**
1. **Linked List Management**: Complex `next`/`prev` chain tracking
2. **State Synchronization**: Keeping DOM and Map in sync
3. **Chain Reconstruction**: Complex re-linking after removals
4. **Dual Tracking**: Both style-based and Map-based state
5. **Edge Case Handling**: Complex scenarios from state mismatches
### 🎯 **Retained Functionality**
1. **Column Sharing**: Flexbox groups work exactly the same
2. **Event Stacking**: Visual stacking with margin-left offsets
3. **Overlap Detection**: Same time-based algorithm
4. **Drag and Drop**: Full drag support maintained
5. **Visual Appearance**: Identical user experience
---
## **Risk Assessment**
### ⚠️ **Potential Concerns**
1. **DOM Query Performance**: More DOM queries vs Map lookups
- **Mitigation**: Queries are scoped to specific containers
- **Reality**: Minimal impact for typical calendar usage
2. **State Reconstruction**: Re-calculating vs cached state
- **Mitigation**: DOM is the single source of truth
- **Reality**: Eliminates sync bugs completely
### ✅ **Benefits Outweigh Risks**
- **Dramatically simpler codebase**
- **Eliminated entire class of state sync bugs**
- **Much easier to debug and maintain**
- **Better separation of concerns**
---
## **Migration Strategy**
1. ✅ **Created SimpleEventOverlapManager**
2. ✅ **Updated EventRenderer imports**
3. ✅ **Simplified drag handling methods**
4. ✅ **Maintained API compatibility**
5. 🔄 **Testing phase** (current)
6. 🔄 **Remove old EventOverlapManager** (after validation)
---
## **Conclusion**
The simplified approach provides **identical functionality** with:
- **51% less code**
- **87% simpler stack management**
- **Zero state synchronization bugs**
- **Much easier maintenance**
This is a perfect example of how **complexity often accumulates unnecessarily** and how a **DOM-first approach** can be both simpler and more reliable than complex state management.

View file

@ -1,221 +0,0 @@
# Data-Attribute Stack Tracking Solution
## Implementation Summary
Vi har nu implementeret stack tracking via data attributes i stedet for komplekse Map-baserede linked lists.
### 🎯 **How it works:**
#### **Stack Links via Data Attributes**
```html
<!-- Base event -->
<swp-event
data-event-id="event_123"
data-stack-link='{"stackLevel":0,"next":"event_456"}'
style="margin-left: 0px;">
</swp-event>
<!-- Stacked event -->
<swp-event
data-event-id="event_456"
data-stack-link='{"prev":"event_123","stackLevel":1,"next":"event_789"}'
style="margin-left: 15px;">
</swp-event>
<!-- Top stacked event -->
<swp-event
data-event-id="event_789"
data-stack-link='{"prev":"event_456","stackLevel":2}'
style="margin-left: 30px;">
</swp-event>
```
### 🔧 **Key Methods:**
#### **createStackedEvent()**
```typescript
// Links new event to end of chain
let lastElement = underlyingElement;
while (lastLink?.next) {
lastElement = this.findElementById(lastLink.next);
lastLink = this.getStackLink(lastElement);
}
// Create bidirectional link
this.setStackLink(lastElement, { ...lastLink, next: eventId });
this.setStackLink(eventElement, { prev: lastElementId, stackLevel });
```
#### **removeStackedStyling()**
```typescript
// Re-link prev and next
if (link.prev && link.next) {
this.setStackLink(prevElement, { ...prevLink, next: link.next });
this.setStackLink(nextElement, { ...nextLink, prev: link.prev });
}
// Update subsequent stack levels
this.updateSubsequentStackLevels(link.next, -1);
```
#### **restackEventsInContainer()**
```typescript
// Group by stack chains (not all stacked events together!)
for (const element of stackedEvents) {
// Find root of chain
while (rootLink?.prev) {
rootElement = this.findElementById(rootLink.prev);
}
// Collect entire chain
// Re-stack each chain separately
}
```
---
## 🏆 **Advantages vs Map Solution:**
### ✅ **Simplified State Management**
| Aspect | Map + Linked List | Data Attributes |
|--------|------------------|-----------------|
| **State Location** | Separate Map object | In DOM elements |
| **Synchronization** | Manual sync required | Automatic with DOM |
| **Memory Cleanup** | Manual Map cleanup | Automatic with element removal |
| **Debugging** | Console logs only | DevTools inspection |
| **State Consistency** | Possible sync bugs | Always consistent |
### ✅ **Code Complexity Reduction**
```typescript
// OLD: Complex Map management
private stackChains = new Map<string, { next?: string, prev?: string, stackLevel: number }>();
// Find last event in chain - complex iteration
let lastEventId = underlyingId;
while (this.stackChains.has(lastEventId) && this.stackChains.get(lastEventId)?.next) {
lastEventId = this.stackChains.get(lastEventId)!.next!;
}
// Link events - error prone
this.stackChains.get(lastEventId)!.next = eventId;
this.stackChains.set(eventId, { prev: lastEventId, stackLevel });
// NEW: Simple data attribute management
let lastElement = underlyingElement;
while (lastLink?.next) {
lastElement = this.findElementById(lastLink.next);
}
this.setStackLink(lastElement, { ...lastLink, next: eventId });
this.setStackLink(eventElement, { prev: lastElementId, stackLevel });
```
### ✅ **Better Error Handling**
```typescript
// DOM elements can't get out of sync with their own attributes
// When element is removed, its state automatically disappears
// No orphaned Map entries
```
---
## 🧪 **Test Scenarios:**
### **Scenario 1: Multiple Separate Stacks**
```
Column has:
Stack A: Event1 → Event2 → Event3 (times: 09:00-10:00, 09:15-10:15, 09:30-10:30)
Stack B: Event4 → Event5 (times: 14:00-15:00, 14:10-15:10)
Remove Event2 (middle of Stack A):
✅ Expected: Event1 → Event3 (Event3 moves to 15px margin)
✅ Expected: Stack B unchanged (Event4→Event5 still at 0px→15px)
❌ Old naive approach: Would group all events together
```
### **Scenario 2: Remove Base Event**
```
Stack: EventA(base) → EventB → EventC
Remove EventA:
✅ Expected: EventB becomes base (0px), EventC moves to 15px
✅ Data-attribute solution: EventB.stackLevel = 0, EventC.stackLevel = 1
```
### **Scenario 3: Drag and Drop**
```
Drag Event2 from Stack A to new position:
✅ removeStackedStyling() handles re-linking
✅ Other stack events maintain their relationships
✅ No Map synchronization issues
```
---
## 🔍 **Debugging Benefits:**
### **Browser DevTools Inspection:**
```html
<!-- Easy to see stack relationships directly in HTML -->
<swp-event data-stack-link='{"prev":"123","next":"789","stackLevel":1}'>
<!-- Event content -->
</swp-event>
```
### **Console Debugging:**
```javascript
// Easy to inspect stack chains
const element = document.querySelector('[data-event-id="456"]');
const link = JSON.parse(element.dataset.stackLink);
console.log('Stack chain:', link);
```
---
## 📊 **Performance Comparison:**
| Operation | Map Solution | Data-Attribute Solution |
|-----------|--------------|-------------------------|
| **Create Stack** | Map.set() + element.style | JSON.stringify() + element.style |
| **Remove Stack** | Map manipulation + DOM queries | JSON.parse/stringify + DOM queries |
| **Find Chain** | Map iteration | DOM traversal |
| **Memory Usage** | Map + DOM | DOM only |
| **Sync Overhead** | High (keep Map in sync) | None (DOM is source) |
### **Performance Notes:**
- **JSON.parse/stringify**: Very fast for small objects (~10 properties max)
- **DOM traversal**: Limited by chain length (typically 2-5 events)
- **Memory**: Significant reduction (no separate Map)
- **Garbage collection**: Better (automatic cleanup)
---
## ✅ **Solution Status:**
### **Completed:**
- [x] StackLink interface definition
- [x] Helper methods (getStackLink, setStackLink, findElementById)
- [x] createStackedEvent with data-attribute linking
- [x] removeStackedStyling with proper re-linking
- [x] restackEventsInContainer respects separate chains
- [x] isStackedEvent checks both style and data-attributes
- [x] Compilation successful
### **Ready for Testing:**
- [ ] Manual UI testing of stack behavior
- [ ] Drag and drop stacked events
- [ ] Multiple stacks in same column
- [ ] Edge cases (remove first/middle/last)
---
## 🎉 **Conclusion:**
This data-attribute solution provides:
1. **Same functionality** as the Map-based approach
2. **Simpler implementation** (DOM as single source of truth)
3. **Better debugging experience** (DevTools visibility)
4. **Automatic memory management** (no manual cleanup)
5. **No synchronization bugs** (state follows element)
The solution maintains all the precision of the original complex system while dramatically simplifying the implementation and eliminating entire classes of potential bugs.

View file

@ -1,133 +0,0 @@
# Calendar Initialization Sequence Diagram
Dette diagram viser den aktuelle initialization sekvens baseret på koden.
```mermaid
sequenceDiagram
participant Browser
participant Index as index.ts
participant MF as ManagerFactory
participant CTF as CalendarTypeFactory
participant EB as EventBus
participant CM as CalendarManager
participant EM as EventManager
participant ERS as EventRenderingService
participant GM as GridManager
participant GSM as GridStyleManager
participant GR as GridRenderer
participant SM as ScrollManager
participant NM as NavigationManager
participant NR as NavigationRenderer
participant VM as ViewManager
Browser->>Index: DOMContentLoaded
Index->>Index: initializeCalendar()
Index->>CTF: initialize()
CTF-->>Index: Factory ready
Index->>MF: getInstance()
MF-->>Index: Factory instance
Index->>MF: createManagers(eventBus, config)
MF->>EM: new EventManager(eventBus)
EM->>EB: setupEventListeners()
EM-->>MF: EventManager ready
MF->>ERS: new EventRenderingService(eventBus, eventManager)
ERS->>EB: setupEventListeners()
ERS-->>MF: EventRenderingService ready
MF->>GM: new GridManager()
GM->>GSM: new GridStyleManager(config)
GM->>GR: new GridRenderer(config)
GM->>EB: subscribeToEvents()
GM-->>MF: GridManager ready
MF->>SM: new ScrollManager()
SM->>EB: subscribeToEvents()
SM-->>MF: ScrollManager ready
MF->>NM: new NavigationManager(eventBus)
NM->>NR: new NavigationRenderer(eventBus)
NR->>EB: setupEventListeners()
NM->>EB: setupEventListeners()
NM-->>MF: NavigationManager ready
MF->>VM: new ViewManager(eventBus)
VM->>EB: setupEventListeners()
VM-->>MF: ViewManager ready
MF->>CM: new CalendarManager(eventBus, config, deps...)
CM->>EB: setupEventListeners()
CM-->>MF: CalendarManager ready
MF-->>Index: All managers created
Index->>EB: setDebug(true)
Index->>MF: initializeManagers(managers)
MF->>CM: initialize()
CM->>EM: loadData()
EM->>EM: loadMockData()
EM->>EM: processCalendarData()
EM-->>CM: Data loaded
CM->>GM: setResourceData(resourceData)
GM-->>CM: Resource data set
CM->>GM: render()
GM->>GSM: updateGridStyles(resourceData)
GM->>GR: renderGrid(grid, currentWeek, resourceData, allDayEvents)
GR-->>GM: Grid rendered
GM->>EB: emit(GRID_RENDERED, context)
EB-->>ERS: GRID_RENDERED event
ERS->>EM: getEventsForPeriod(startDate, endDate)
EM-->>ERS: Filtered events
ERS->>ERS: strategy.renderEvents()
CM->>SM: initialize()
SM->>SM: setupScrolling()
CM->>CM: setView(currentView)
CM->>EB: emit(VIEW_CHANGED, viewData)
CM->>CM: setCurrentDate(currentDate)
CM->>EB: emit(DATE_CHANGED, dateData)
CM->>EB: emit(CALENDAR_INITIALIZED, initData)
EB-->>NM: CALENDAR_INITIALIZED
NM->>NM: updateWeekInfo()
NM->>EB: emit(WEEK_INFO_UPDATED, weekInfo)
EB-->>NR: WEEK_INFO_UPDATED
NR->>NR: updateWeekInfoInDOM()
EB-->>VM: CALENDAR_INITIALIZED
VM->>VM: initializeView()
VM->>EB: emit(VIEW_RENDERED, viewData)
CM-->>MF: Initialization complete
MF-->>Index: All managers initialized
Index->>Browser: Calendar ready
```
## Aktuel Arkitektur Status
### Factory Pattern
- ManagerFactory håndterer manager instantiering
- Proper dependency injection via constructor
### Event-Driven Communication
- EventBus koordinerer kommunikation mellem managers
- NavigationRenderer lytter til WEEK_INFO_UPDATED events
- EventRenderingService reagerer på GRID_RENDERED events
### Separation of Concerns
- Managers håndterer business logic
- Renderers håndterer DOM manipulation
- EventBus håndterer kommunikation

View file

@ -0,0 +1,329 @@
# Code Analysis Report
## Calendar Plantempus TypeScript Codebase
**Generated:** 2025-10-06
**Tool:** ts-unused-exports
**Total Files Analyzed:** 40+ TypeScript files
---
## Executive Summary
### Unused Exports Found
- **Total Modules with Unused Exports:** 14
- **Total Unused Exports:** 40+
### Impact Assessment
- 🟢 **Low Risk:** Type definitions and interfaces (can be kept for future use)
- 🟡 **Medium Risk:** Unused classes and managers (should be reviewed)
- 🔴 **High Risk:** Duplicate implementations (should be removed)
---
## Detailed Findings
### 1. Constants & Core (`src/constants/`)
#### `CoreEvents.ts`
**Unused Exports:**
- `CoreEventType` - Type definition
- `EVENT_MIGRATION_MAP` - Migration mapping
**Analysis:**
- `CoreEventType` er en type definition - kan være nyttig for fremtidig type-sikkerhed
- `EVENT_MIGRATION_MAP` ser ud til at være til migration fra gamle events - kan muligvis fjernes hvis migration er færdig
**Recommendation:** ⚠️ Review - Tjek om migration er færdig
---
### 2. Factories (`src/factories/`)
#### `CalendarTypeFactory.ts`
**Unused Exports:**
- `RendererConfig` - Interface
**Analysis:**
- Interface der ikke bruges eksternt
- Kan være intern implementation detail
**Recommendation:** ✅ Keep - Internal interface, no harm
---
### 3. Interfaces (`src/interfaces/`)
#### `IManager.ts`
**Unused Exports:**
- `IEventManager`
- `IRenderingManager`
- `INavigationManager`
- `IScrollManager`
**Analysis:**
- Disse interfaces definerer kontrakter men bruges ikke
- Kan være planlagt til fremtidig dependency injection
**Recommendation:** 🔴 **CRITICAL** - Enten brug interfaces eller fjern dem. Interfaces uden implementation er dead code.
---
### 4. Managers (`src/managers/`)
#### `EventLayoutCoordinator.ts`
**Unused Exports:**
- `ColumnLayout` - Interface
**Analysis:**
- Return type interface der ikke bruges eksternt
**Recommendation:** ✅ Keep - Part of public API
#### `SimpleEventOverlapManager.ts`
**Unused Exports:**
- `OverlapType`
- `OverlapGroup`
- `StackLink`
- `SimpleEventOverlapManager` - **ENTIRE CLASS**
**Analysis:**
- 🔴 **CRITICAL FINDING:** Hele klassen er ubrugt!
- Dette ser ud til at være en gammel implementation der er blevet erstattet af `EventStackManager`
**Recommendation:** 🔴 **DELETE** - Remove entire file if not used
#### `WorkHoursManager.ts`
**Unused Exports:**
- `DayWorkHours`
- `WorkScheduleConfig`
**Analysis:**
- Interfaces for work hours functionality
- Kan være planlagt feature
**Recommendation:** ⚠️ Review - Check if feature is planned or abandoned
---
### 5. Strategies (`src/strategies/`)
#### `MonthViewStrategy.ts`
**Unused Exports:**
- `MonthViewStrategy` - **ENTIRE CLASS**
**Analysis:**
- 🔴 **CRITICAL:** Hele strategy-klassen er ubrugt
- Kan være planlagt feature eller gammel implementation
**Recommendation:** 🔴 **DELETE or IMPLEMENT** - Either use it or remove it
#### `WeekViewStrategy.ts`
**Unused Exports:**
- `WeekViewStrategy` - **ENTIRE CLASS**
**Analysis:**
- 🔴 **CRITICAL:** Hele strategy-klassen er ubrugt
- Samme som MonthViewStrategy
**Recommendation:** 🔴 **DELETE or IMPLEMENT** - Either use it or remove it
---
### 6. Types (`src/types/`)
#### `CalendarTypes.ts`
**Unused Exports:**
- `SyncStatus`
- `Resource`
- `GridPosition`
- `Period`
- `EventData`
- `DateModeContext`
- `ResourceModeContext`
- `CalendarModeContext`
**Analysis:**
- Mange type definitions der ikke bruges
- Nogle kan være planlagt features (Resource, ResourceModeContext)
- Andre kan være legacy (SyncStatus, EventData)
**Recommendation:** ⚠️ Review each type individually
#### `DragDropTypes.ts`
**Unused Exports:**
- `DragState`
- `DragEndPosition`
- `StackLinkData`
- `DragEventHandlers`
**Analysis:**
- Drag & drop types der ikke bruges eksternt
- Kan være internal types
**Recommendation:** ✅ Keep - Part of drag-drop system
#### `EventPayloadMap.ts`
**Unused Exports:**
- `CalendarEventPayloadMap`
- `EventPayload`
- `hasPayload`
**Analysis:**
- Event payload system der ikke bruges
- Kan være planlagt type-safe event system
**Recommendation:** ⚠️ Review - Check if this is planned feature
#### `EventTypes.ts`
**Unused Exports:**
- `AllDayEvent`
- `TimeEvent`
- `CalendarEventData`
- `MousePosition`
**Analysis:**
- Type definitions for events
- `MousePosition` bruges sandsynligvis internt
**Recommendation:** ✅ Keep - Core types
#### `ManagerTypes.ts`
**Unused Exports:**
- `EventManager`
- `EventRenderingService`
- `GridManager`
- `ScrollManager`
- `NavigationManager`
- `ViewManager`
- `CalendarManager`
- `DragDropManager`
- `AllDayManager`
- `Resource`
- `ResourceAssignment`
**Analysis:**
- 🔴 **CRITICAL:** Mange manager types der ikke bruges
- Dette tyder på at type system ikke er implementeret korrekt
**Recommendation:** 🔴 **REFACTOR** - Either use these types or remove them
---
### 7. Utils (`src/utils/`)
#### `OverlapDetector.ts`
**Unused Exports:**
- `EventId`
- `OverlapResult`
- `StackLink`
- `OverlapDetector` - **ENTIRE CLASS**
**Analysis:**
- 🔴 **CRITICAL:** Hele utility-klassen er ubrugt
- Sandsynligvis erstattet af anden implementation
**Recommendation:** 🔴 **DELETE** - Remove if not used
---
## Summary Statistics
### By Category
| Category | Total Unused | Critical (Classes) | Medium (Interfaces) | Low (Types) |
|----------|--------------|-------------------|---------------------|-------------|
| Constants | 2 | 0 | 0 | 2 |
| Factories | 1 | 0 | 1 | 0 |
| Interfaces | 4 | 0 | 4 | 0 |
| Managers | 8 | 1 | 3 | 4 |
| Strategies | 2 | 2 | 0 | 0 |
| Types | 23 | 0 | 0 | 23 |
| Utils | 4 | 1 | 0 | 3 |
| **TOTAL** | **44** | **4** | **8** | **32** |
---
## Critical Issues (Requires Immediate Action)
### 🔴 Unused Classes (Dead Code)
1. **`SimpleEventOverlapManager`** - Entire class unused
2. **`MonthViewStrategy`** - Entire class unused
3. **`WeekViewStrategy`** - Entire class unused
4. **`OverlapDetector`** - Entire class unused
### 🔴 Unused Interfaces (Architecture Issue)
1. **`IManager.ts`** - All 4 interfaces unused
- Suggests dependency injection pattern not implemented
- Either implement or remove
---
## Recommendations
### Immediate Actions (High Priority)
1. **Delete Dead Code:**
```bash
# Remove these files if confirmed unused:
rm src/managers/SimpleEventOverlapManager.ts
rm src/strategies/MonthViewStrategy.ts
rm src/strategies/WeekViewStrategy.ts
rm src/utils/OverlapDetector.ts
```
2. **Review Interfaces:**
- Decide if `IManager.ts` interfaces should be implemented
- If not, remove them
3. **Clean Up Types:**
- Review `ManagerTypes.ts` - many unused types
- Consider if these are planned features or legacy code
### Medium Priority
4. **Review Planned Features:**
- `Resource` and `ResourceModeContext` - Are these planned?
- `WorkHoursManager` types - Is this feature coming?
- `EventPayloadMap` - Is type-safe event system planned?
5. **Document Decisions:**
- Add comments explaining why certain exports exist
- Mark planned features clearly
### Low Priority
6. **Type Definitions:**
- Most type definitions can stay (low cost)
- But consider if they add confusion
---
## Estimated Impact
### Code Reduction Potential
- **Files that can be deleted:** 4 (SimpleEventOverlapManager, MonthViewStrategy, WeekViewStrategy, OverlapDetector)
- **Lines of code reduction:** ~500-800 lines
- **Maintenance burden reduction:** Significant
### Risk Assessment
- **Low Risk:** Removing unused classes (they're not imported anywhere)
- **Medium Risk:** Removing interfaces (might break future plans)
- **High Risk:** None (all findings are confirmed unused)
---
## Next Steps
1. ✅ **Review this report with team**
2. ⚠️ **Decide on each critical issue**
3. 🔴 **Create cleanup tasks**
4. ✅ **Run tests after cleanup**
5. ✅ **Update documentation**
---
## Tools Used
- **ts-unused-exports** v11.0.1
- Analysis date: 2025-10-06
- Project: Calendar Plantempus

View file

@ -0,0 +1,50 @@
# Cyclomatic Complexity Analysis Report
## Calendar Plantempus TypeScript Codebase
**Generated:** 2025-10-06
**Analyzer:** Roo AI Assistant
**Total Files Analyzed:** 40+
---
## Executive Summary
### Complexity Distribution
| Complexity Range | Risk Level | Count | Percentage |
|-----------------|------------|-------|------------|
| 1-10 | ✅ Low | TBD | TBD% |
| 11-20 | ⚠️ Medium | TBD | TBD% |
| 21-50 | 🔴 High | TBD | TBD% |
| 50+ | 💀 Critical | TBD | TBD% |
### Key Metrics
- **Total Functions:** TBD
- **Average Complexity:** TBD
- **Highest Complexity:** TBD
- **Files Needing Refactoring:** TBD
---
## Methodology
### Cyclomatic Complexity Calculation
For each function, we count:
- **+1** for the function itself
- **+1** for each `if`, `else if`, `while`, `for`, `case`
- **+1** for each `&&`, `||` in conditions
- **+1** for each `catch` block
- **+1** for each ternary operator `? :`
- **+1** for each `return` statement (except the last one)
### Risk Assessment
- **1-10:** Simple, easy to test and maintain
- **11-20:** Moderate complexity, acceptable
- **21-50:** Complex, should be refactored
- **50+:** Very complex, high maintenance risk
---
## Analysis in Progress...
This report is being generated. Please wait for the complete analysis.

View file

@ -1,237 +0,0 @@
# Calendar Plantempus - Date Mode Initialization Sequence
## Overview
This document shows the complete initialization sequence and event flow for Date Mode in Calendar Plantempus, including when data is loaded and ready for rendering.
## Sequence Diagram
```mermaid
sequenceDiagram
participant Browser as Browser
participant Index as index.ts
participant Config as CalendarConfig
participant Factory as CalendarTypeFactory
participant CM as CalendarManager
participant EM as EventManager
participant GM as GridManager
participant NM as NavigationManager
participant VM as ViewManager
participant ER as EventRenderer
participant SM as ScrollManager
participant EB as EventBus
participant DOM as DOM
Note over Browser: Page loads calendar application
Browser->>Index: Load application
Note over Index: PHASE 0: Pre-initialization Setup
Index->>Config: new CalendarConfig()
Config->>Config: loadCalendarType() - Read URL ?type=date
Config->>Config: loadFromDOM() - Read data attributes
Config->>Config: Set mode='date', period='week'
Index->>Factory: CalendarTypeFactory.initialize()
Factory->>Factory: Create DateHeaderRenderer
Factory->>Factory: Create DateColumnRenderer
Factory->>Factory: Create DateEventRenderer
Note over Factory: Strategy Pattern renderers ready
Note over Index: PHASE 1: Core Managers Construction
Index->>CM: new CalendarManager(eventBus, config)
CM->>EB: Subscribe to VIEW_CHANGE_REQUESTED
CM->>EB: Subscribe to NAV_PREV, NAV_NEXT
Index->>NM: new NavigationManager(eventBus)
NM->>EB: Subscribe to CALENDAR_INITIALIZED
Note over NM: Will wait to call updateWeekInfo()
Index->>VM: new ViewManager(eventBus)
VM->>EB: Subscribe to CALENDAR_INITIALIZED
Note over Index: PHASE 2: Data & Rendering Managers
Index->>EM: new EventManager(eventBus)
EM->>EB: Subscribe to CALENDAR_INITIALIZED
Note over EM: Will wait to load data
Index->>ER: new EventRenderer(eventBus)
ER->>EB: Subscribe to EVENTS_LOADED
ER->>EB: Subscribe to GRID_RENDERED
Note over ER: Needs BOTH events before rendering
Note over Index: PHASE 3: Layout Managers (Order Critical!)
Index->>SM: new ScrollManager()
SM->>EB: Subscribe to GRID_RENDERED
Note over SM: Must subscribe BEFORE GridManager renders
Index->>GM: new GridManager()
GM->>EB: Subscribe to CALENDAR_INITIALIZED
GM->>EB: Subscribe to CALENDAR_DATA_LOADED
GM->>GM: Set currentWeek = getWeekStart(new Date())
Note over GM: Ready to render, but waiting
Note over Index: PHASE 4: Coordinated Initialization
Index->>CM: initialize()
CM->>EB: emit(CALENDAR_INITIALIZING)
CM->>CM: setView('week'), setCurrentDate()
CM->>EB: emit(CALENDAR_INITIALIZED) ⭐
Note over EB: 🚀 CALENDAR_INITIALIZED triggers all managers
par EventManager Data Loading
EB->>EM: CALENDAR_INITIALIZED
EM->>EM: loadMockData() for date mode
EM->>EM: fetch('/src/data/mock-events.json')
Note over EM: Loading date-specific mock data
EM->>EM: Process events for current week
EM->>EB: emit(CALENDAR_DATA_LOADED, {calendarType: 'date', data})
EM->>EB: emit(EVENTS_LOADED, {events: [...])
and GridManager Initial Rendering
EB->>GM: CALENDAR_INITIALIZED
GM->>GM: render()
GM->>GM: updateGridStyles() - Set --grid-columns: 7
GM->>GM: createHeaderSpacer()
GM->>GM: createTimeAxis(dayStartHour, dayEndHour)
GM->>GM: createGridContainer()
Note over GM: Strategy Pattern - Date Mode Rendering
GM->>Factory: getHeaderRenderer('date') → DateHeaderRenderer
GM->>GM: renderCalendarHeader() - Create day headers
GM->>DOM: Create 7 swp-day-column elements
GM->>Factory: getColumnRenderer('date') → DateColumnRenderer
GM->>GM: renderColumnContainer() - Date columns
GM->>EB: emit(GRID_RENDERED) ⭐
and NavigationManager UI
EB->>NM: CALENDAR_INITIALIZED
NM->>NM: updateWeekInfo()
NM->>DOM: Update week display in navigation
NM->>EB: emit(WEEK_INFO_UPDATED)
and ViewManager Setup
EB->>VM: CALENDAR_INITIALIZED
VM->>VM: initializeView()
VM->>EB: emit(VIEW_RENDERED)
end
Note over GM: GridManager receives its own data event
EB->>GM: CALENDAR_DATA_LOADED
GM->>GM: updateGridStyles() - Recalculate columns if needed
Note over GM: Grid already rendered, just update styles
Note over ER: 🎯 Critical Synchronization Point
EB->>ER: EVENTS_LOADED
ER->>ER: pendingEvents = events (store, don't render yet)
Note over ER: Waiting for grid to be ready...
EB->>ER: GRID_RENDERED
ER->>DOM: querySelectorAll('swp-day-column') - Check if ready
DOM-->>ER: Return 7 day columns (ready!)
Note over ER: Both events loaded AND grid ready → Render!
ER->>Factory: getEventRenderer('date') → DateEventRenderer
ER->>ER: renderEvents(pendingEvents) using DateEventRenderer
ER->>DOM: Position events in day columns
ER->>ER: Clear pendingEvents
ER->>EB: emit(EVENT_RENDERED)
Note over SM: ScrollManager sets up after grid is complete
EB->>SM: GRID_RENDERED
SM->>DOM: querySelector('swp-scrollable-content')
SM->>SM: setupScrolling()
SM->>SM: applyScrollbarStyling()
SM->>SM: setupScrollSynchronization()
Note over Index: 🎊 Date Mode Initialization Complete!
Note over Index: Ready for user interaction
```
## Key Initialization Phases
### Phase 0: Pre-initialization Setup
- **CalendarConfig**: Loads URL parameters (`?type=date`) and DOM attributes
- **CalendarTypeFactory**: Creates strategy pattern renderers for date mode
### Phase 1: Core Managers Construction
- **CalendarManager**: Central coordinator
- **NavigationManager**: Week navigation controls
- **ViewManager**: View state management
### Phase 2: Data & Rendering Managers
- **EventManager**: Handles data loading
- **EventRenderer**: Manages event display with synchronization
### Phase 3: Layout Managers (Order Critical!)
- **ScrollManager**: Must subscribe before GridManager renders
- **GridManager**: Main grid rendering
### Phase 4: Coordinated Initialization
- **CalendarManager.initialize()**: Triggers `CALENDAR_INITIALIZED` event
- All managers respond simultaneously but safely
## Critical Synchronization Points
### 1. Event-Grid Synchronization
```typescript
// EventRenderer waits for BOTH events
if (this.pendingEvents.length > 0) {
const columns = document.querySelectorAll('swp-day-column'); // DATE MODE
if (columns.length > 0) { // Grid must exist first
this.renderEvents(this.pendingEvents);
}
}
```
### 2. Scroll-Grid Dependency
```typescript
// ScrollManager only sets up after grid is rendered
eventBus.on(EventTypes.GRID_RENDERED, () => {
this.setupScrolling(); // Safe to access DOM now
});
```
### 3. Manager Construction Order
```typescript
// Critical order: ScrollManager subscribes BEFORE GridManager renders
const scrollManager = new ScrollManager();
const gridManager = new GridManager();
```
## Date Mode Specifics
### Data Loading
- Uses `/src/data/mock-events.json`
- Processes events for current week
- Emits `CALENDAR_DATA_LOADED` with `calendarType: 'date'`
### Grid Rendering
- Creates 7 `swp-day-column` elements (weekDays: 7)
- Uses `DateHeaderRenderer` strategy
- Uses `DateColumnRenderer` strategy
- Sets `--grid-columns: 7` CSS variable
### Event Rendering
- Uses `DateEventRenderer` strategy
- Positions events in day columns based on start/end time
- Calculates pixel positions using `PositionUtils`
## Race Condition Prevention
1. **Subscription Before Action**: All managers subscribe during construction, act on `CALENDAR_INITIALIZED`
2. **DOM Existence Checks**: Managers verify DOM elements exist before manipulation
3. **Event Ordering**: `GRID_RENDERED` always fires before event rendering attempts
4. **Pending States**: EventRenderer stores pending events until grid is ready
5. **Coordinated Start**: Single `CALENDAR_INITIALIZED` event starts all processes
## Debugging Points
Key events to monitor during initialization:
- `CALENDAR_INITIALIZED` - Start of coordinated setup
- `CALENDAR_DATA_LOADED` - Date data ready
- `GRID_RENDERED` - Grid structure complete
- `EVENTS_LOADED` - Event data ready
- `EVENT_RENDERED` - Events positioned in grid
This sequence ensures deterministic, race-condition-free initialization with comprehensive logging for debugging.

View file

@ -1,270 +0,0 @@
# Improved Calendar Initialization Strategy
## Current Problems
1. **Race Conditions**: Managers try DOM operations before DOM is ready
2. **Sequential Blocking**: All initialization happens sequentially
3. **Poor Error Handling**: No timeouts or retry mechanisms
4. **Late Data Loading**: Data only loads after all managers are created
## Recommended New Architecture
### Phase 1: Early Parallel Startup
```typescript
// index.ts - Improved initialization
export class CalendarInitializer {
async initialize(): Promise<void> {
console.log('📋 Starting Calendar initialization...');
// PHASE 1: Early parallel setup
const setupPromises = [
this.initializeConfig(), // Load URL params, DOM attrs
this.initializeFactory(), // Setup strategy patterns
this.preloadCalendarData(), // Start data loading early
this.waitForDOMReady() // Ensure basic DOM exists
];
await Promise.all(setupPromises);
console.log('✅ Phase 1 complete: Config, Factory, Data preloading started');
// PHASE 2: Manager creation with dependencies
await this.createManagersWithDependencies();
// PHASE 3: Coordinated activation
await this.activateAllManagers();
console.log('🎊 Calendar fully initialized!');
}
}
```
### Phase 2: Dependency-Aware Manager Creation
```typescript
private async createManagersWithDependencies(): Promise<void> {
const managers = new Map<string, any>();
// Core managers (no DOM dependencies)
managers.set('config', calendarConfig);
managers.set('eventBus', eventBus);
managers.set('calendarManager', new CalendarManager(eventBus, calendarConfig));
// DOM-dependent managers (wait for DOM readiness)
await this.waitForRequiredDOM(['swp-calendar', 'swp-calendar-nav']);
managers.set('navigationManager', new NavigationManager(eventBus));
managers.set('viewManager', new ViewManager(eventBus));
// Data managers (can work with preloaded data)
managers.set('eventManager', new EventManager(eventBus));
managers.set('dataManager', new DataManager());
// Layout managers (need DOM structure + other managers)
await this.waitForRequiredDOM(['swp-calendar-container']);
// CRITICAL ORDER: ScrollManager subscribes before GridManager renders
managers.set('scrollManager', new ScrollManager());
managers.set('gridManager', new GridManager());
// Rendering managers (need grid structure)
managers.set('eventRenderer', new EventRenderer(eventBus));
this.managers = managers;
}
```
### Phase 3: Coordinated Activation
```typescript
private async activateAllManagers(): Promise<void> {
// All managers created and subscribed, now activate in coordinated fashion
const calendarManager = this.managers.get('calendarManager');
// This triggers CALENDAR_INITIALIZED, but now all managers are ready
await calendarManager.initialize();
// Wait for critical initialization events
await Promise.all([
this.waitForEvent('CALENDAR_DATA_LOADED', 10000),
this.waitForEvent('GRID_RENDERED', 5000),
this.waitForEvent('EVENTS_LOADED', 10000)
]);
// Ensure event rendering completes
await this.waitForEvent('EVENT_RENDERED', 3000);
}
```
## Specific Timing Improvements
### 1. Early Data Preloading
```typescript
private async preloadCalendarData(): Promise<void> {
const currentDate = new Date();
const mode = calendarConfig.getCalendarMode();
// Start loading data for current period immediately
const dataManager = new DataManager();
const currentPeriod = this.getCurrentPeriod(currentDate, mode);
// Don't await - let this run in background
const dataPromise = dataManager.fetchEventsForPeriod(currentPeriod);
// Also preload adjacent periods
const prevPeriod = this.getPreviousPeriod(currentDate, mode);
const nextPeriod = this.getNextPeriod(currentDate, mode);
// Store promises for later use
this.preloadPromises = {
current: dataPromise,
previous: dataManager.fetchEventsForPeriod(prevPeriod),
next: dataManager.fetchEventsForPeriod(nextPeriod)
};
console.log('📊 Data preloading started for current, previous, and next periods');
}
```
### 2. DOM Readiness Verification
```typescript
private async waitForRequiredDOM(selectors: string[]): Promise<void> {
const maxWait = 5000; // 5 seconds max
const checkInterval = 100; // Check every 100ms
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const missing = selectors.filter(selector => !document.querySelector(selector));
if (missing.length === 0) {
console.log(`✅ Required DOM elements found: ${selectors.join(', ')}`);
return;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
}
throw new Error(`❌ Timeout waiting for DOM elements: ${selectors.join(', ')}`);
}
```
### 3. Manager Base Class with Proper Lifecycle
```typescript
export abstract class BaseManager {
protected isInitialized = false;
protected requiredDOMSelectors: string[] = [];
constructor() {
// Don't call init() immediately in constructor!
console.log(`${this.constructor.name}: Created but not initialized`);
}
async initialize(): Promise<void> {
if (this.isInitialized) {
console.log(`${this.constructor.name}: Already initialized, skipping`);
return;
}
// Wait for required DOM elements
if (this.requiredDOMSelectors.length > 0) {
await this.waitForDOM(this.requiredDOMSelectors);
}
// Perform manager-specific initialization
await this.performInitialization();
this.isInitialized = true;
console.log(`${this.constructor.name}: Initialization complete`);
}
protected abstract performInitialization(): Promise<void>;
private async waitForDOM(selectors: string[]): Promise<void> {
// Same DOM waiting logic as above
}
}
```
### 4. Enhanced GridManager
```typescript
export class GridManager extends BaseManager {
protected requiredDOMSelectors = ['swp-calendar-container'];
constructor() {
super(); // Don't call this.init()!
this.currentWeek = this.getWeekStart(new Date());
}
protected async performInitialization(): Promise<void> {
// Now safe to find elements - DOM guaranteed to exist
this.findElements();
this.subscribeToEvents();
// Wait for CALENDAR_INITIALIZED before rendering
await this.waitForEvent('CALENDAR_INITIALIZED');
console.log('GridManager: Starting initial render');
this.render();
}
}
```
### 5. Enhanced EventRenderer with Better Synchronization
```typescript
export class EventRenderer extends BaseManager {
private dataReady = false;
private gridReady = false;
private pendingEvents: CalendarEvent[] = [];
protected async performInitialization(): Promise<void> {
this.subscribeToEvents();
// Wait for both data and grid in parallel
const [eventsData] = await Promise.all([
this.waitForEvent('EVENTS_LOADED'),
this.waitForEvent('GRID_RENDERED')
]);
console.log('EventRenderer: Both events and grid ready, rendering now');
this.renderEvents(eventsData.events);
}
private subscribeToEvents(): void {
this.eventBus.on(EventTypes.EVENTS_LOADED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.pendingEvents = detail.events;
this.dataReady = true;
this.tryRender();
});
this.eventBus.on(EventTypes.GRID_RENDERED, () => {
this.gridReady = true;
this.tryRender();
});
}
private tryRender(): void {
if (this.dataReady && this.gridReady && this.pendingEvents.length > 0) {
this.renderEvents(this.pendingEvents);
this.pendingEvents = [];
}
}
}
```
## Benefits of New Architecture
1. **🚀 Parallel Operations**: Data loading starts immediately while managers are being created
2. **🛡️ Race Condition Prevention**: DOM readiness verified before operations
3. **⚡ Better Performance**: Critical path optimized, non-critical operations parallelized
4. **🔧 Better Error Handling**: Timeouts and retry mechanisms
5. **📊 Predictable Timing**: Clear phases with guaranteed completion order
6. **🐛 Easier Debugging**: Clear lifecycle events and logging
## Implementation Strategy
1. **Phase 1**: Create BaseManager class and update existing managers
2. **Phase 2**: Implement CalendarInitializer with parallel setup
3. **Phase 3**: Add DOM readiness verification throughout
4. **Phase 4**: Implement data preloading strategy
5. **Phase 5**: Add comprehensive error handling and timeouts
This architecture ensures reliable, fast, and maintainable calendar initialization.

View file

@ -0,0 +1,204 @@
# Stack Binding System - Calendar Plantempus
## Oversigt
Dette dokument beskriver hvordan overlappende events er bundet sammen i Calendar Plantempus systemet, specifikt hvordan 2 eller flere events der ligger oven i hinanden (stacked) er forbundet.
## Stack Binding Mekanisme
### SimpleEventOverlapManager
Systemet bruger `SimpleEventOverlapManager` til at håndtere event overlap og stacking. Denne implementation bruger **data-attributes** på DOM elementerne til at holde styr på stack chains.
### Hvordan Stacked Events er Bundet Sammen
Når 2 eller flere events ligger oven i hinanden, oprettes en **linked list struktur** via `data-stack-link` attributter:
#### Eksempel med 2 Events:
```typescript
// Event A (base event):
<swp-event data-stack-link='{"stackLevel":0,"next":"event-B"}'>
// Event B (stacked event):
<swp-event data-stack-link='{"prev":"event-A","stackLevel":1}'>
```
#### Eksempel med 3 Events:
```typescript
// Event A (base event):
<swp-event data-stack-link='{"stackLevel":0,"next":"event-B"}'>
// Event B (middle event):
<swp-event data-stack-link='{"prev":"event-A","next":"event-C","stackLevel":1}'>
// Event C (top event):
<swp-event data-stack-link='{"prev":"event-B","stackLevel":2}'>
```
### StackLink Interface
```typescript
interface StackLink {
prev?: string; // Event ID af forrige event i stacken
next?: string; // Event ID af næste event i stacken
stackLevel: number; // 0 = base event, 1 = første stacked, etc
}
```
### Visual Styling
Hvert stacked event får automatisk styling baseret på deres `stackLevel`:
- **Event A (base)**: `margin-left: 0px`, `z-index: 100`
- **Event B (middle)**: `margin-left: 15px`, `z-index: 101`
- **Event C (top)**: `margin-left: 30px`, `z-index: 102`
**Formel:**
- `margin-left = stackLevel * 15px`
- `z-index = 100 + stackLevel`
### Stack Chain Navigation
Systemet kan traversere stack chains i begge retninger:
```typescript
// Find næste event i stacken
const link = getStackLink(currentElement);
if (link?.next) {
const nextElement = document.querySelector(`swp-event[data-event-id="${link.next}"]`);
}
// Find forrige event i stacken
if (link?.prev) {
const prevElement = document.querySelector(`swp-event[data-event-id="${link.prev}"]`);
}
```
### Automatisk Chain Opdatering
Når events fjernes fra en stack, opdateres chain automatisk:
1. **Middle element fjernet**: Prev og next events linkes direkte sammen
2. **Chain breaking**: Hvis events ikke længere overlapper, brydes chain
3. **Stack level opdatering**: Alle efterfølgende events får opdateret stackLevel
### Overlap Detection
Events klassificeres som **stacking** hvis:
- De overlapper i tid OG
- Start tid forskel > 30 minutter
```typescript
const timeDiffMinutes = Math.abs(
new Date(event1.start).getTime() - new Date(event2.start).getTime()
) / (1000 * 60);
return timeDiffMinutes > 30 ? OverlapType.STACKING : OverlapType.COLUMN_SHARING;
```
## Eksempler
### 2 Events Stack
```
Event A: 09:00-11:00 (base) → margin-left: 0px, z-index: 100
Event B: 09:45-10:30 (stacked) → margin-left: 15px, z-index: 101
```
**Stack chain:**
```
A ←→ B
```
**Data attributes:**
- A: `{"stackLevel":0,"next":"B"}`
- B: `{"prev":"A","stackLevel":1}`
### 3 Events Stack
```
Event A: 09:00-11:00 (base) → margin-left: 0px, z-index: 100
Event B: 09:45-10:30 (middle) → margin-left: 15px, z-index: 101
Event C: 10:15-11:15 (top) → margin-left: 30px, z-index: 102
```
**Stack chain:**
```
A ←→ B ←→ C
```
**Data attributes:**
- A: `{"stackLevel":0,"next":"B"}`
- B: `{"prev":"A","next":"C","stackLevel":1}`
- C: `{"prev":"B","stackLevel":2}`
## Visual Stack Chain Diagram
```mermaid
graph TD
A[Event A - Base Event] --> A1[data-stack-link]
B[Event B - Middle Event] --> B1[data-stack-link]
C[Event C - Top Event] --> C1[data-stack-link]
A1 --> A2[stackLevel: 0<br/>next: event-B]
B1 --> B2[prev: event-A<br/>next: event-C<br/>stackLevel: 1]
C1 --> C2[prev: event-B<br/>stackLevel: 2]
A2 --> A3[margin-left: 0px<br/>z-index: 100]
B2 --> B3[margin-left: 15px<br/>z-index: 101]
C2 --> C3[margin-left: 30px<br/>z-index: 102]
subgraph Stack Chain Navigation
A4[Event A] -.->|next| B4[Event B]
B4 -.->|next| C4[Event C]
C4 -.->|prev| B4
B4 -.->|prev| A4
end
subgraph Visual Result
V1[Event A - Full Width]
V2[Event B - 15px Offset]
V3[Event C - 30px Offset]
V1 -.-> V2
V2 -.-> V3
end
```
## Stack Chain Operations
```mermaid
sequenceDiagram
participant DOM as DOM Element
participant SM as SimpleEventOverlapManager
participant Chain as Stack Chain
Note over DOM,Chain: Creating Stack Chain
DOM->>SM: createStackedEvent(eventB, eventA, 1)
SM->>Chain: Set eventA: {stackLevel:0, next:"eventB"}
SM->>Chain: Set eventB: {prev:"eventA", stackLevel:1}
SM->>DOM: Apply margin-left: 15px, z-index: 101
Note over DOM,Chain: Removing from Chain
DOM->>SM: removeStackedStyling(eventB)
SM->>Chain: Get eventB links
SM->>Chain: Link eventA -> eventC directly
SM->>Chain: Update eventC stackLevel: 1
SM->>DOM: Update eventC margin-left: 15px
SM->>Chain: Delete eventB entry
```
## Fordele ved Data-Attribute Approach
1. **Ingen global state** - alt information er på DOM elementerne
2. **Persistent** - overlever DOM manipulationer
3. **Debuggable** - kan inspiceres i browser dev tools
4. **Performant** - ingen in-memory maps at vedligeholde
5. **Robust** - automatisk cleanup når elements fjernes
## Se Også
- [`SimpleEventOverlapManager.ts`](../src/managers/SimpleEventOverlapManager.ts) - Implementation
- [`EventRenderer.ts`](../src/renderers/EventRenderer.ts) - Usage
- [Event Overlap CSS](../wwwroot/css/calendar-events-css.css) - Styling
- [Complexity Comparison](../complexity_comparison.md) - Before/after analysis

View file

@ -1,143 +0,0 @@
# Event Overlap Rendering Implementation Plan
## Oversigt
Implementer event overlap rendering med to forskellige patterns:
1. **Column Sharing**: Events med samme start tid deles om bredden med flexbox
2. **Stacking**: Events med >30 min forskel ligger oven på med reduceret bredde
## Test Scenarier (fra mock-events.json)
### September 2 - Stacking Test
- Event 93: "Team Standup" 09:00-09:30
- Event 94: "Product Planning" 14:00-16:00
- Event 96: "Deep Work" 15:00-15:30 (>30 min efter standup, skal være 15px mindre)
### September 4 - Column Sharing Test
- Event 97: "Team Standup" 09:00-09:30
- Event 98: "Technical Review" 15:00-16:30
- Event 100: "Sprint Review" 15:00-16:00 (samme start tid som Technical Review - skal deles 50/50)
## Teknisk Arkitektur
### 1. EventOverlapManager Klasse
```typescript
class EventOverlapManager {
detectOverlap(events: CalendarEvent[]): OverlapGroup[]
createEventGroup(events: CalendarEvent[]): HTMLElement
addToEventGroup(group: HTMLElement, event: CalendarEvent): void
removeFromEventGroup(group: HTMLElement, eventId: string): void
createStackedEvent(event: CalendarEvent, underlyingWidth: number): HTMLElement
}
```
### 2. CSS Struktur
```css
.event-group {
position: absolute;
display: flex;
gap: 1px;
width: 100%;
}
.event-group swp-event {
flex: 1;
position: relative;
}
.stacked-event {
position: absolute;
width: calc(100% - 15px);
right: 0;
z-index: var(--z-stacked-event);
}
```
### 3. DOM Struktur
```html
<!-- Normal event -->
<swp-event>Single Event</swp-event>
<!-- Column sharing group -->
<div class="event-group" style="position: absolute; top: 200px;">
<swp-event>Event 1</swp-event>
<swp-event>Event 2</swp-event>
</div>
<!-- Stacked event -->
<swp-event class="stacked-event" style="top: 210px;">Stacked Event</swp-event>
```
## Implementerings Steps
### Phase 1: Core Infrastructure
1. Opret EventOverlapManager klasse
2. Implementer overlap detection algoritme
3. Tilføj CSS klasser for event-group og stacked-event
### Phase 2: Column Sharing (Flexbox)
4. Implementer createEventGroup metode med flexbox
5. Implementer addToEventGroup og removeFromEventGroup
6. Integrér i BaseEventRenderer.renderEvent
### Phase 3: Stacking Logic
7. Implementer stacking detection (>30 min forskel)
8. Implementer createStackedEvent med reduceret bredde
9. Tilføj z-index management
### Phase 4: Drag & Drop Integration
10. Modificer drag & drop handleDragEnd til overlap detection
11. Implementer event repositioning ved drop på eksisterende events
12. Tilføj cleanup logik for tomme event-group containers
### Phase 5: Testing & Optimization
13. Test column sharing med September 4 events (samme start tid)
14. Test stacking med September 2 events (>30 min forskel)
15. Test kombinerede scenarier
16. Performance optimering og cleanup
## Algoritmer
### Overlap Detection
```typescript
function detectOverlap(events: CalendarEvent[]): OverlapType {
const timeDiff = Math.abs(event1.startTime - event2.startTime);
if (timeDiff === 0) return 'COLUMN_SHARING';
if (timeDiff > 30 * 60 * 1000) return 'STACKING';
return 'NORMAL';
}
```
### Column Sharing Calculation
```typescript
function calculateColumnSharing(events: CalendarEvent[]) {
const eventCount = events.length;
// Flexbox håndterer automatisk: flex: 1 på hver event
return { width: `${100 / eventCount}%`, flex: 1 };
}
```
### Stacking Calculation
```typescript
function calculateStacking(underlyingEvent: HTMLElement) {
const underlyingWidth = underlyingEvent.offsetWidth;
return {
width: underlyingWidth - 15,
right: 0,
zIndex: getNextZIndex()
};
}
```
## Event Bus Integration
- `overlap:detected` - Når overlap detekteres
- `overlap:group-created` - Når event-group oprettes
- `overlap:event-stacked` - Når event stacks oven på andet
- `overlap:group-cleanup` - Når tom group fjernes
## Success Criteria
- [x] September 4: Technical Review og Sprint Review deles 50/50 i bredden
- [x] September 2: Deep Work ligger oven på med 15px mindre bredde
- [x] Drag & drop fungerer med overlap detection
- [x] Cleanup af tomme event-group containers
- [x] Z-index management - nyere events øverst

View file

@ -1,85 +0,0 @@
# Overlap Detection Fix Plan
## Problem Analysis
Den nuværende overlap detection logik i EventOverlapManager tjekker kun på tidsforskel mellem start tidspunkter, men ikke om events faktisk overlapper i tid. Dette resulterer i forkert stacking behavior.
## Updated Overlap Logic Requirements
### Scenario 1: Column Sharing (Flexbox)
**Regel**: Events med samme start tid ELLER start tid indenfor 30 minutter
- **Eksempel**: Event A (09:00-10:00) + Event B (09:15-10:30)
- **Resultat**: Deler pladsen med flexbox - ingen stacking
### Scenario 2: Stacking
**Regel**: Events overlapper i tid MEN har >30 min forskel i start tid
- **Eksempel**: Product Planning (14:00-16:00) + Deep Work (15:00-15:30)
- **Resultat**: Stacking med reduceret bredde for kortere event
### Scenario 3: Ingen Overlap
**Regel**: Events overlapper ikke i tid ELLER står alene
- **Eksempel**: Standalone 30 min event kl. 10:00-10:30
- **Resultat**: Normal rendering, fuld bredde
## Implementation Plan
### 1. Fix EventOverlapManager.detectOverlap()
```typescript
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
// Først: Tjek om events overlapper i tid
if (!this.eventsOverlapInTime(event1, event2)) {
return OverlapType.NONE;
}
// Events overlapper i tid - nu tjek start tid forskel
const start1 = new Date(event1.start).getTime();
const start2 = new Date(event2.start).getTime();
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
// Indenfor 30 min start forskel = column sharing
if (timeDiffMinutes <= 30) {
return OverlapType.COLUMN_SHARING;
}
// Mere end 30 min start forskel = stacking
return OverlapType.STACKING;
}
```
### 2. Add eventsOverlapInTime() method
```typescript
private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean {
const start1 = new Date(event1.start).getTime();
const end1 = new Date(event1.end).getTime();
const start2 = new Date(event2.start).getTime();
const end2 = new Date(event2.end).getTime();
// Events overlapper hvis de deler mindst ét tidspunkt
return !(end1 <= start2 || end2 <= start1);
}
```
### 3. Remove Unnecessary Data Attributes
- Fjern `overlapType` og `stackedWidth` data attributter fra createStackedEvent()
- Simplificér removeStackedStyling() metoden
### 4. Test Scenarios
- Test med Product Planning (14:00-16:00) + Deep Work (15:00-15:30) = stacking
- Test med events indenfor 30 min start forskel = column sharing
- Test med standalone events = normal rendering
## Changes Required
### EventOverlapManager.ts
1. Tilføj eventsOverlapInTime() private metode
2. Modificer detectOverlap() metode med ny logik
3. Fjern data attributter i createStackedEvent()
4. Simplificér removeStackedStyling()
### EventRenderer.ts
- Ingen ændringer nødvendige - bruger allerede EventOverlapManager
## Expected Outcome
- Korrekt column sharing for events med start tid indenfor 30 min
- Korrekt stacking kun når events faktisk overlapper med >30 min start forskel
- Normale events renderes med fuld bredde når de står alene
- Renere kode uden unødvendige data attributter

2230
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,22 @@
"build": "node build.js",
"build-simple": "esbuild src/**/*.ts --outdir=js --format=esm --sourcemap=inline --target=es2020",
"watch": "esbuild src/**/*.ts --outdir=js --format=esm --sourcemap=inline --target=es2020 --watch",
"clean": "powershell -Command \"if (Test-Path js) { Remove-Item -Recurse -Force js }\""
"clean": "powershell -Command \"if (Test-Path js) { Remove-Item -Recurse -Force js }\"",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@vitest/ui": "^3.2.4",
"esbuild": "^0.19.0",
"typescript": "^5.0.0"
"jsdom": "^27.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"dependencies": {
"@rollup/rollup-win32-x64-msvc": "^4.52.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"fuse.js": "^7.1.0"
}
}

View file

@ -1,166 +0,0 @@
# Resource Calendar JSON Structure (Opdateret)
Her er den opdaterede JSON struktur med resources som array og detaljerede resource informationer:
```json
{
"date": "2025-08-05",
"resources": [
{
"name": "karina.knudsen",
"displayName": "Karina Knudsen",
"avatarUrl": "/avatars/karina.jpg",
"employeeId": "EMP001",
"events": [
{
"id": "1",
"title": "Balayage langt hår",
"start": "2025-08-05T10:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#9c27b0" }
},
{
"id": "2",
"title": "Klipning og styling",
"start": "2025-08-05T14:00:00",
"end": "2025-08-05T15:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#e91e63" }
}
]
},
{
"name": "maria.hansen",
"displayName": "Maria Hansen",
"avatarUrl": "/avatars/maria.jpg",
"employeeId": "EMP002",
"events": [
{
"id": "3",
"title": "Permanent",
"start": "2025-08-05T09:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#3f51b5" }
},
{
"id": "4",
"title": "Farve behandling",
"start": "2025-08-05T13:00:00",
"end": "2025-08-05T15:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#ff9800" }
}
]
},
{
"name": "lars.nielsen",
"displayName": "Lars Nielsen",
"avatarUrl": "/avatars/lars.jpg",
"employeeId": "EMP003",
"events": [
{
"id": "5",
"title": "Herreklipning",
"start": "2025-08-05T11:00:00",
"end": "2025-08-05T11:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#795548" }
},
{
"id": "6",
"title": "Skæg trimning",
"start": "2025-08-05T16:00:00",
"end": "2025-08-05T16:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#607d8b" }
}
]
},
{
"name": "anna.petersen",
"displayName": "Anna Petersen",
"avatarUrl": "/avatars/anna.jpg",
"employeeId": "EMP004",
"events": [
{
"id": "7",
"title": "Bryllupsfrisure",
"start": "2025-08-05T08:00:00",
"end": "2025-08-05T10:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#009688" }
}
]
},
{
"name": "thomas.olsen",
"displayName": "Thomas Olsen",
"avatarUrl": "/avatars/thomas.jpg",
"employeeId": "EMP005",
"events": [
{
"id": "8",
"title": "Highlights",
"start": "2025-08-05T12:00:00",
"end": "2025-08-05T14:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#8bc34a" }
},
{
"id": "9",
"title": "Styling konsultation",
"start": "2025-08-05T15:00:00",
"end": "2025-08-05T15:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#cddc39" }
}
]
}
]
}
```
## Struktur Forklaring
- **date**: Den specifikke dato for resource calendar visningen
- **resources**: Array af resource objekter
- **Resource objekt**:
- **name**: Unikt navn/ID (kebab-case)
- **displayName**: Navn til visning i UI
- **avatarUrl**: URL til profilbillede
- **employeeId**: Medarbejder ID
- **events**: Array af events for denne resource
## Fordele ved denne struktur:
1. **Fleksibel**: Nemt at tilføje flere resource felter
2. **Skalerbar**: Kan håndtere mange resources
3. **UI-venlig**: DisplayName og avatar til visning
4. **Struktureret**: Klar separation mellem resource info og events
5. **Søgbar**: Name og employeeId til filtrering/søgning
Denne struktur gør det nemt at:
- Vise resource info i headers (displayName, avatar)
- Filtrere events per resource
- Håndtere kun én dato ad gangen i resource mode
- Udvide med flere resource felter senere

62
scenarios/scenario-1.html Normal file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 1: No Overlap</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 1: No Overlap</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Three sequential events with no time overlap. All events should have stack level 0 since they don't conflict.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=0 (stacked)<br>
Event C: stackLevel=0 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S1A" data-title="Scenario 1: Event A" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 1: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S1B" data-title="Scenario 1: Event B" data-start="2025-10-06T09:00:00.000Z" data-end="2025-10-06T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 81px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 1: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S1C" data-title="Scenario 1: Event C" data-start="2025-10-06T10:00:00.000Z" data-end="2025-10-06T11:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 161px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 1: Event C</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-1',
expected: [
{ eventId: 'S1A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1B', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1C', stackLevel: 0, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 10: Four Column Grid</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 10: Four Column Grid</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Four events all starting at exactly the same time (14:00). Tests maximum column sharing with a 4-column grid layout.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 4 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid<br>
Event C: in grid<br>
Event D: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-4 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 561px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S10A" data-title="Scenario 10: Event A" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10B" data-title="Scenario 10: Event B" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event B</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10C" data-title="Scenario 10: Event C" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event C</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10D" data-title="Scenario 10: Event D" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event D</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-10',
expected: [
{ eventId: 'S10A', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10B', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10C', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10D', stackLevel: 0, cols: 4, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

61
scenarios/scenario-2.html Normal file
View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 2: Column Sharing (Grid)</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 2: Column Sharing (Grid)</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Two events starting at exactly the same time (10:00). These should be placed in a grid container with 2 columns, allowing them to share horizontal space.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 2 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 1px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S2A" data-title="Scenario 2: Event A" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 2: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S2B" data-title="Scenario 2: Event B" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 2: Event B</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-2',
expected: [
{ eventId: 'S2A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S2B', stackLevel: 0, cols: 2, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

69
scenarios/scenario-3.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 3: Nested Stacking</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 3: Nested Stacking</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Progressive nesting pattern: Event A (09:00-15:00) contains B (10:00-13:00), B contains C (11:00-12:00), and C overlaps with D (12:30-13:30). Tests correct stack level calculation for nested events.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=2 (stacked)<br>
Event D: stackLevel=2 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S3A" data-title="Scenario 3: Event A" data-start="2025-10-07T07:00:00.000Z" data-end="2025-10-07T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 3: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S3B" data-title="Scenario 3: Event B" data-start="2025-10-07T08:00:00.000Z" data-end="2025-10-07T11:00:00.000Z" data-type="work" data-duration="180" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 81px; height: 217px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="180">10:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 3: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S3C" data-title="Scenario 3: Event C" data-start="2025-10-07T09:00:00.000Z" data-end="2025-10-07T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 161px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 3: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S3D" data-title="Scenario 3: Event D" data-start="2025-10-07T10:30:00.000Z" data-end="2025-10-07T11:30:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 3: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-3',
expected: [
{ eventId: 'S3A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S3B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S3C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S3D', stackLevel: 2, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

69
scenarios/scenario-4.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 4: Complex Stacking</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 4: Complex Stacking</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Long event A (14:00-20:00) with multiple shorter events (B, C, D) nested inside at different times. Tests multiple stack levels with varying overlap patterns.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=2 (stacked)<br>
Event D: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S4A" data-title="Scenario 4: Event A" data-start="2025-10-07T12:00:00.000Z" data-end="2025-10-07T18:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 481px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">14:00 - 20:00</swp-event-time>
<swp-event-title>Scenario 4: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S4B" data-title="Scenario 4: Event B" data-start="2025-10-07T13:00:00.000Z" data-end="2025-10-07T15:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 561px; height: 157px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="120">15:00 - 17:00</swp-event-time>
<swp-event-title>Scenario 4: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S4C" data-title="Scenario 4: Event C" data-start="2025-10-07T13:30:00.000Z" data-end="2025-10-07T14:30:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 601px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">15:30 - 16:30</swp-event-time>
<swp-event-title>Scenario 4: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S4D" data-title="Scenario 4: Event D" data-start="2025-10-07T16:00:00.000Z" data-end="2025-10-07T17:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 721px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">18:00 - 19:00</swp-event-time>
<swp-event-title>Scenario 4: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-4',
expected: [
{ eventId: 'S4A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S4B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S4C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S4D', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

69
scenarios/scenario-5.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 5: Three Column Share</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 5: Three Column Share</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Three events all starting at exactly the same time (10:00). Should create a grid layout with 3 columns.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 3 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid<br>
Event C: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-3 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 1px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S5A" data-title="Scenario 5: Event A" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S5B" data-title="Scenario 5: Event B" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event B</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S5C" data-title="Scenario 5: Event C" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event C</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-5',
expected: [
{ eventId: 'S5A', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5B', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5C', stackLevel: 0, cols: 3, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

69
scenarios/scenario-6.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 6: Overlapping Pairs</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 6: Overlapping Pairs</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Two separate pairs of overlapping events: (A, B) and (C, D). Each pair should be independent with their own stack levels.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=0 (stacked)<br>
Event D: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S6A" data-title="Scenario 6: Event A" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T10:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 161px; height: 157px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="120">10:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 6: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S6B" data-title="Scenario 6: Event B" data-start="2025-10-08T09:00:00.000Z" data-end="2025-10-08T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 6: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S6C" data-title="Scenario 6: Event C" data-start="2025-10-08T11:00:00.000Z" data-end="2025-10-08T13:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 401px; height: 157px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="120">13:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 6: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S6D" data-title="Scenario 6: Event D" data-start="2025-10-08T12:00:00.000Z" data-end="2025-10-08T13:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 481px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 6: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-6',
expected: [
{ eventId: 'S6A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S6C', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6D', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

62
scenarios/scenario-7.html Normal file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 7: Long Event Container</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 7: Long Event Container</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>One long event (A: 09:00-15:00) containing two shorter events (B: 10:00-11:00, C: 12:00-13:00) that don't overlap with each other. B and C should both have the same stack level.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S7A" data-title="Scenario 7: Event A" data-start="2025-10-09T07:00:00.000Z" data-end="2025-10-09T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 7: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S7B" data-title="Scenario 7: Event B" data-start="2025-10-09T08:00:00.000Z" data-end="2025-10-09T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 81px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 7: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S7C" data-title="Scenario 7: Event C" data-start="2025-10-09T10:00:00.000Z" data-end="2025-10-09T11:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 7: Event C</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-7',
expected: [
{ eventId: 'S7A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S7B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S7C', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

55
scenarios/scenario-8.html Normal file
View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 8: Edge-Adjacent Events</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 8: Edge-Adjacent Events</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Events that touch but don't overlap: Event A (10:00-11:00) and Event B (11:00-12:00). A ends exactly when B starts, so they should NOT stack.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=0 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S8A" data-title="Scenario 8: Event A" data-start="2025-10-09T08:00:00.000Z" data-end="2025-10-09T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 8: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S8B" data-title="Scenario 8: Event B" data-start="2025-10-09T09:00:00.000Z" data-end="2025-10-09T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 321px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 8: Event B</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-8',
expected: [
{ eventId: 'S8A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S8B', stackLevel: 0, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

67
scenarios/scenario-9.html Normal file
View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 9: End-to-Start Chain</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 9: End-to-Start Chain</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Events linked by end-to-start conflicts within the threshold: Event A (12:00-13:00), Event B (12:30-13:30), and Event C (13:15-15:00). Even though C doesn't start close to A, it starts within threshold before B ends, creating a conflict chain A→B→C.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 2 columns at stackLevel=0<br>
Event A: in grid (column 1)<br>
Event B: in grid (column 2)<br>
Event C: in grid (column 1)
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 481px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S9A" data-title="Scenario 9: Event A" data-start="2025-10-09T10:00:00.000Z" data-end="2025-10-09T11:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 9: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S9C" data-title="Scenario 9: Event C" data-start="2025-10-09T11:15:00.000Z" data-end="2025-10-09T13:00:00.000Z" data-type="work" data-duration="105" style="position: absolute; top: 100px; height: 137px; left: 0px; right: 0px;">
<swp-event-time data-duration="105">13:15 - 15:00</swp-event-time>
<swp-event-title>Scenario 9: Event C</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S9B" data-title="Scenario 9: Event B" data-start="2025-10-09T10:30:00.000Z" data-end="2025-10-09T11:30:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 40px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 9: Event B</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-9',
expected: [
{ eventId: 'S9A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9B', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9C', stackLevel: 0, cols: 2, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,162 @@
/* Shared styles for all scenario visualization files */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.scenario-container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1, h2, h3 {
color: #333;
margin-top: 0;
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e0e0e0;
}
.scenario-title {
margin: 0;
}
.test-badge {
display: inline-block;
margin-left: 15px;
padding: 6px 14px;
border-radius: 6px;
font-weight: bold;
font-size: 14px;
vertical-align: middle;
}
.test-passed {
background: #4caf50;
color: white;
}
.test-failed {
background: #f44336;
color: white;
}
.test-pending {
background: #ff9800;
color: white;
}
.scenario-description {
background: #f8f9fa;
padding: 15px;
border-left: 4px solid #b53f7a;
margin-bottom: 20px;
}
.expected-result {
background: #e8f5e9;
padding: 12px;
border-radius: 4px;
margin: 15px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.calendar-column {
position: relative;
width: 350px;
height: 800px;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
margin: 20px 0;
}
/* Event styling */
swp-event-group {
position: absolute;
left: 0;
right: 0;
display: flex;
gap: 4px;
}
swp-event-group > div {
flex: 1;
min-width: 0;
}
swp-event {
display: block;
padding: 8px;
border-left: 4px solid #b53f7a;
background: #fff3e0;
border-radius: 4px;
font-size: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
swp-event-time {
display: block;
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
swp-event-title {
display: block;
color: #333;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #b53f7a;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.test-details {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.test-details h4 {
margin-top: 0;
color: #666;
}
.test-result-line {
padding: 4px 0;
}
.test-result-line.passed {
color: #4caf50;
}
.test-result-line.failed {
color: #f44336;
}

View file

@ -0,0 +1,152 @@
/**
* Scenario Test Runner
* Validates that rendered events match expected layout
*/
export class ScenarioTestRunner {
/**
* Validate a scenario's rendered output
* @param {string} scenarioId - e.g., "scenario-1"
* @param {Array} expectedResults - Array of {eventId, stackLevel, cols?, type: 'grid'|'stacked'}
* @returns {object} - {passed: boolean, results: Array, message: string}
*/
static validateScenario(scenarioId, expectedResults) {
const results = [];
let allPassed = true;
for (const expected of expectedResults) {
const result = this.validateEvent(expected.eventId, expected);
results.push(result);
if (!result.passed) {
allPassed = false;
}
}
return {
passed: allPassed,
results,
message: allPassed ? 'All tests passed ✅' : 'Some tests failed ❌'
};
}
/**
* Validate a single event
*/
static validateEvent(eventId, expected) {
const eventEl = document.querySelector(`swp-event[data-event-id="${eventId}"]`);
if (!eventEl) {
return {
passed: false,
eventId,
message: `Event ${eventId} not found in DOM`
};
}
const errors = [];
// Check if in grid group
const gridGroup = eventEl.closest('swp-event-group');
if (expected.type === 'grid') {
if (!gridGroup) {
errors.push(`Expected to be in grid group, but found as stacked event`);
} else {
// Validate grid group properties
const groupStackLevel = this.getStackLevel(gridGroup);
if (groupStackLevel !== expected.stackLevel) {
errors.push(`Grid group stack level: expected ${expected.stackLevel}, got ${groupStackLevel}`);
}
if (expected.cols) {
const cols = this.getColumnCount(gridGroup);
if (cols !== expected.cols) {
errors.push(`Grid columns: expected ${expected.cols}, got ${cols}`);
}
}
}
} else if (expected.type === 'stacked') {
if (gridGroup) {
errors.push(`Expected to be stacked, but found in grid group`);
} else {
// Validate stacked event properties
const stackLevel = this.getStackLevel(eventEl);
if (stackLevel !== expected.stackLevel) {
errors.push(`Stack level: expected ${expected.stackLevel}, got ${stackLevel}`);
}
}
}
return {
passed: errors.length === 0,
eventId,
message: errors.length === 0 ? '✅' : errors.join('; ')
};
}
/**
* Get stack level from element
*/
static getStackLevel(element) {
const stackLink = element.getAttribute('data-stack-link');
if (stackLink) {
try {
const parsed = JSON.parse(stackLink);
return parsed.stackLevel;
} catch (e) {
return null;
}
}
// Try class name fallback
const classMatch = element.className.match(/stack-level-(\d+)/);
return classMatch ? parseInt(classMatch[1]) : null;
}
/**
* Get column count from grid group
*/
static getColumnCount(gridGroup) {
const classMatch = gridGroup.className.match(/cols-(\d+)/);
return classMatch ? parseInt(classMatch[1]) : null;
}
/**
* Display test results in the DOM
*/
static displayResults(containerId, results) {
const container = document.getElementById(containerId);
if (!container) return;
const badge = document.createElement('span');
badge.className = `test-badge ${results.passed ? 'test-passed' : 'test-failed'}`;
badge.textContent = results.message;
container.appendChild(badge);
// Add detailed results
const details = document.createElement('div');
details.className = 'test-details';
details.innerHTML = '<h4>Test Results:</h4>';
results.results.forEach(r => {
const line = document.createElement('div');
line.className = `test-result-line ${r.passed ? 'passed' : 'failed'}`;
line.textContent = `${r.eventId}: ${r.message}`;
details.appendChild(line);
});
container.appendChild(details);
}
}
// Auto-run tests if window.scenarioTests is defined
window.addEventListener('DOMContentLoaded', () => {
if (window.scenarioTests) {
const results = ScenarioTestRunner.validateScenario(
window.scenarioTests.id,
window.scenarioTests.expected
);
ScenarioTestRunner.displayResults('test-results', results);
}
});

View file

@ -46,46 +46,3 @@ export const CoreEvents = {
// Rendering events (1)
EVENTS_RENDERED: 'events:rendered'
} as const;
// Type for the event values
export type CoreEventType = typeof CoreEvents[keyof typeof CoreEvents];
/**
* Migration map from old EventTypes to CoreEvents
* This helps transition existing code gradually
*/
export const EVENT_MIGRATION_MAP: Record<string, string> = {
// Lifecycle
'calendar:initialized': CoreEvents.INITIALIZED,
'calendar:ready': CoreEvents.READY,
// View
'calendar:viewchanged': CoreEvents.VIEW_CHANGED,
'calendar:viewrendered': CoreEvents.VIEW_RENDERED,
'calendar:workweekchanged': CoreEvents.WORKWEEK_CHANGED,
// Navigation
'calendar:datechanged': CoreEvents.DATE_CHANGED,
'calendar:navigationcompleted': CoreEvents.NAVIGATION_COMPLETED,
'calendar:periodinfoUpdate': CoreEvents.PERIOD_INFO_UPDATE,
// Data
'calendar:datafetchstart': CoreEvents.DATA_LOADING,
'calendar:datafetchsuccess': CoreEvents.DATA_LOADED,
'calendar:datafetcherror': CoreEvents.DATA_ERROR,
'calendar:eventsloaded': CoreEvents.DATA_LOADED,
// Grid
'calendar:gridrendered': CoreEvents.GRID_RENDERED,
'calendar:gridclick': CoreEvents.GRID_CLICKED,
// Event management
'calendar:eventcreated': CoreEvents.EVENT_CREATED,
'calendar:eventupdated': CoreEvents.EVENT_UPDATED,
'calendar:eventdeleted': CoreEvents.EVENT_DELETED,
'calendar:eventselected': CoreEvents.EVENT_SELECTED,
// System
'calendar:error': CoreEvents.ERROR,
'calendar:refreshrequested': CoreEvents.REFRESH_REQUESTED
};

View file

@ -3,6 +3,7 @@
import { eventBus } from './EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { CalendarConfig as ICalendarConfig, ViewPeriod, CalendarMode } from '../types/CalendarTypes';
import { TimeFormatter, TimeFormatSettings } from '../utils/TimeFormatter';
/**
* All-day event layout constants
@ -11,8 +12,9 @@ export const ALL_DAY_CONSTANTS = {
EVENT_HEIGHT: 22, // Height of single all-day event
EVENT_GAP: 2, // Gap between stacked events
CONTAINER_PADDING: 4, // Container padding (top + bottom)
MAX_COLLAPSED_ROWS: 4, // Show 4 rows when collapsed (3 events + 1 indicator row)
get SINGLE_ROW_HEIGHT() {
return this.EVENT_HEIGHT + this.EVENT_GAP + this.CONTAINER_PADDING; // 28px
return this.EVENT_HEIGHT + this.EVENT_GAP; // 28px
}
} as const;
@ -32,6 +34,9 @@ interface GridSettings {
fitToWidth: boolean;
scrollToHour: number | null;
// Event grouping settings
gridStartThresholdMinutes: number; // ±N minutes for events to share grid columns
// Display options
showCurrentTime: boolean;
showWorkHours: boolean;
@ -69,6 +74,17 @@ interface ResourceViewSettings {
showAllDay: boolean; // Show all-day event row
}
/**
* Time format configuration settings
*/
interface TimeFormatConfig {
timezone: string;
use24HourFormat: boolean;
locale: string;
dateFormat: 'locale' | 'technical';
showSeconds: boolean;
}
/**
* Calendar configuration management
*/
@ -80,6 +96,7 @@ export class CalendarConfig {
private dateViewSettings: DateViewSettings;
private resourceViewSettings: ResourceViewSettings;
private currentWorkWeek: string = 'standard';
private timeFormatConfig: TimeFormatConfig;
constructor() {
this.config = {
@ -118,6 +135,7 @@ export class CalendarConfig {
workStartHour: 8,
workEndHour: 17,
snapInterval: 15,
gridStartThresholdMinutes: 30, // Events starting within ±15 min share grid columns
showCurrentTime: true,
showWorkHours: true,
fitToWidth: false,
@ -142,9 +160,21 @@ export class CalendarConfig {
showAllDay: true
};
// Time format settings - default to Denmark with technical format
this.timeFormatConfig = {
timezone: 'Europe/Copenhagen',
use24HourFormat: true,
locale: 'da-DK',
dateFormat: 'technical',
showSeconds: false
};
// Set computed values
this.config.minEventDuration = this.gridSettings.snapInterval;
// Initialize TimeFormatter with default settings
TimeFormatter.configure(this.timeFormatConfig);
// Load calendar type from URL parameter
this.loadCalendarType();
@ -472,6 +502,78 @@ export class CalendarConfig {
return this.currentWorkWeek;
}
/**
* Get time format settings
*/
getTimeFormatSettings(): TimeFormatConfig {
return { ...this.timeFormatConfig };
}
/**
* Update time format settings
*/
updateTimeFormatSettings(updates: Partial<TimeFormatConfig>): void {
this.timeFormatConfig = { ...this.timeFormatConfig, ...updates };
// Update TimeFormatter with new settings
TimeFormatter.configure(this.timeFormatConfig);
// Emit time format change event
eventBus.emit(CoreEvents.REFRESH_REQUESTED, {
key: 'timeFormatSettings',
value: this.timeFormatConfig
});
}
/**
* Set timezone (convenience method)
*/
setTimezone(timezone: string): void {
this.updateTimeFormatSettings({ timezone });
}
/**
* Set 12/24 hour format (convenience method)
*/
set24HourFormat(use24Hour: boolean): void {
this.updateTimeFormatSettings({ use24HourFormat: use24Hour });
}
/**
* Get configured timezone
*/
getTimezone(): string {
return this.timeFormatConfig.timezone;
}
/**
* Check if using 24-hour format
*/
is24HourFormat(): boolean {
return this.timeFormatConfig.use24HourFormat;
}
/**
* Set date format (convenience method)
*/
setDateFormat(format: 'locale' | 'technical'): void {
this.updateTimeFormatSettings({ dateFormat: format });
}
/**
* Set whether to show seconds (convenience method)
*/
setShowSeconds(show: boolean): void {
this.updateTimeFormatSettings({ showSeconds: show });
}
/**
* Get current date format
*/
getDateFormat(): 'locale' | 'technical' {
return this.timeFormatConfig.dateFormat;
}
}
// Create singleton instance

View file

@ -59,14 +59,14 @@ export class EventBus implements IEventBus {
/**
* Emit an event via DOM CustomEvent
*/
emit(eventType: string, detail: any = {}): boolean {
emit(eventType: string, detail: unknown = {}): boolean {
// Validate eventType
if (!eventType || typeof eventType !== 'string') {
if (!eventType) {
return false;
}
const event = new CustomEvent(eventType, {
detail,
detail: detail ?? {},
bubbles: true,
cancelable: true
});
@ -78,7 +78,7 @@ export class EventBus implements IEventBus {
this.eventLog.push({
type: eventType,
detail,
detail: detail ?? {},
timestamp: Date.now()
});
@ -89,7 +89,7 @@ export class EventBus implements IEventBus {
/**
* Log event with console grouping
*/
private logEventWithGrouping(eventType: string, detail: any): void {
private logEventWithGrouping(eventType: string, detail: unknown): void {
// Extract category from event type (e.g., 'calendar:datechanged' → 'calendar')
const category = this.extractCategory(eventType);
@ -108,7 +108,7 @@ export class EventBus implements IEventBus {
* Extract category from event type
*/
private extractCategory(eventType: string): string {
if (!eventType || typeof eventType !== 'string') {
if (!eventType) {
return 'unknown';
}
@ -174,17 +174,6 @@ export class EventBus implements IEventBus {
setDebug(enabled: boolean): void {
this.debug = enabled;
}
/**
* Clean up all tracked listeners
*/
destroy(): void {
for (const listener of this.listeners) {
document.removeEventListener(listener.eventType, listener.handler);
}
this.listeners.clear();
this.eventLog = [];
}
}
// Create singleton instance

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,390 @@
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
import { DateService } from '../utils/DateService';
/**
* Base class for event elements
*/
export abstract class BaseSwpEventElement extends HTMLElement {
protected dateService: DateService;
constructor() {
super();
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
}
// ============================================
// 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 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-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
* @param columnDate - The date of the column
* @param snappedY - The Y position in pixels
*/
public updatePosition(columnDate: Date, snappedY: number): void {
// 1. Update visual position
this.style.top = `${snappedY + 1}px`;
// 2. Calculate new timestamps
const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY);
// 3. Update data attributes (triggers attributeChangedCallback)
const startDate = this.dateService.createDateAtTime(columnDate, startMinutes);
let endDate = this.dateService.createDateAtTime(columnDate, 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 = calendarConfig.getGridSettings();
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 = `clone-${this.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>
`;
}
/**
* Update time display when attributes change
*/
private updateDisplay(): void {
const timeEl = this.querySelector('swp-event-time');
const titleEl = this.querySelector('swp-event-title');
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;
}
}
/**
* Calculate start/end minutes from Y position
*/
private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } {
const gridSettings = calendarConfig.getGridSettings();
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 CalendarEvent
*/
public static fromCalendarEvent(event: CalendarEvent): SwpEventElement {
const element = document.createElement('swp-event') as SwpEventElement;
const timezone = calendarConfig.getTimezone?.();
const dateService = new DateService(timezone);
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.duration = event.metadata?.duration?.toString() || '60';
return element;
}
/**
* Extract CalendarEvent from DOM element
*/
public static extractCalendarEventFromElement(element: HTMLElement): CalendarEvent {
return {
id: element.dataset.eventId || '',
title: element.dataset.title || '',
start: new Date(element.dataset.start || ''),
end: new Date(element.dataset.end || ''),
type: element.dataset.type || 'work',
allDay: false,
syncStatus: 'synced',
metadata: {
duration: element.dataset.duration
}
};
}
/**
* Factory method to convert an all-day HTML element to a timed SwpEventElement
*/
public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement {
const eventId = allDayElement.dataset.eventId || '';
const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
const type = allDayElement.dataset.type || 'work';
const startStr = allDayElement.dataset.start;
const endStr = allDayElement.dataset.end;
const durationStr = allDayElement.dataset.duration;
if (!startStr || !endStr) {
throw new Error('All-day element missing start/end dates');
}
const originalStart = new Date(startStr);
const duration = durationStr ? parseInt(durationStr) : 60;
const now = new Date();
const startDate = new Date(originalStart);
startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0);
const endDate = new Date(startDate);
endDate.setMinutes(endDate.getMinutes() + duration);
const calendarEvent: CalendarEvent = {
id: eventId,
title: title,
start: startDate,
end: endDate,
type: type,
allDay: false,
syncStatus: 'synced',
metadata: {
duration: duration.toString()
}
};
return SwpEventElement.fromCalendarEvent(calendarEvent);
}
}
/**
* 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 = `clone-${this.eventId}`;
// Disable pointer events on clone so it doesn't interfere with hover detection
clone.style.pointerEvents = 'none';
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 CalendarEvent
*/
public static fromCalendarEvent(event: CalendarEvent): SwpAllDayEventElement {
const element = document.createElement('swp-allday-event') as SwpAllDayEventElement;
const timezone = calendarConfig.getTimezone?.();
const dateService = new DateService(timezone);
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;
return element;
}
}
// Register custom elements
customElements.define('swp-event', SwpEventElement);
customElements.define('swp-allday-event', SwpAllDayEventElement);

View file

@ -3,7 +3,7 @@
import { CalendarMode } from '../types/CalendarTypes';
import { HeaderRenderer, DateHeaderRenderer, ResourceHeaderRenderer } from '../renderers/HeaderRenderer';
import { ColumnRenderer, DateColumnRenderer, ResourceColumnRenderer } from '../renderers/ColumnRenderer';
import { EventRendererStrategy, DateEventRenderer, ResourceEventRenderer } from '../renderers/EventRenderer';
import { EventRendererStrategy, DateEventRenderer } from '../renderers/EventRenderer';
import { calendarConfig } from '../core/CalendarConfig';
/**
@ -37,11 +37,11 @@ export class CalendarTypeFactory {
eventRenderer: new DateEventRenderer()
});
this.registerRenderers('resource', {
headerRenderer: new ResourceHeaderRenderer(),
columnRenderer: new ResourceColumnRenderer(),
eventRenderer: new ResourceEventRenderer()
});
//this.registerRenderers('resource', {
// headerRenderer: new ResourceHeaderRenderer(),
// columnRenderer: new ResourceColumnRenderer(),
// eventRenderer: new ResourceEventRenderer()
//});
this.isInitialized = true;
}

View file

@ -7,6 +7,9 @@ import { NavigationManager } from '../managers/NavigationManager';
import { ViewManager } from '../managers/ViewManager';
import { CalendarManager } from '../managers/CalendarManager';
import { DragDropManager } from '../managers/DragDropManager';
import { AllDayManager } from '../managers/AllDayManager';
import { ResizeHandleManager } from '../managers/ResizeHandleManager';
import { CalendarManagers } from '../types/ManagerTypes';
/**
* Factory for creating and managing calendar managers with proper dependency injection
@ -26,16 +29,7 @@ export class ManagerFactory {
/**
* Create all managers with proper dependency injection
*/
public createManagers(eventBus: IEventBus): {
eventManager: EventManager;
eventRenderer: EventRenderingService;
gridManager: GridManager;
scrollManager: ScrollManager;
navigationManager: NavigationManager;
viewManager: ViewManager;
calendarManager: CalendarManager;
dragDropManager: DragDropManager;
} {
public createManagers(eventBus: IEventBus): CalendarManagers {
// Create managers in dependency order
const eventManager = new EventManager(eventBus);
@ -45,6 +39,8 @@ export class ManagerFactory {
const navigationManager = new NavigationManager(eventBus, eventRenderer);
const viewManager = new ViewManager(eventBus);
const dragDropManager = new DragDropManager(eventBus);
const allDayManager = new AllDayManager(eventManager);
const resizeHandleManager = new ResizeHandleManager();
// CalendarManager depends on all other managers
const calendarManager = new CalendarManager(
@ -64,20 +60,22 @@ export class ManagerFactory {
navigationManager,
viewManager,
calendarManager,
dragDropManager
dragDropManager,
allDayManager,
resizeHandleManager
};
}
/**
* Initialize all managers in the correct order
*/
public async initializeManagers(managers: {
calendarManager: CalendarManager;
[key: string]: any;
}): Promise<void> {
public async initializeManagers(managers: CalendarManagers): Promise<void> {
try {
await managers.calendarManager.initialize();
await managers.calendarManager.initialize?.();
if (managers.resizeHandleManager && managers.resizeHandleManager.initialize) {
managers.resizeHandleManager.initialize();
}
} catch (error) {
throw error;
}

View file

@ -1,15 +1,15 @@
// Main entry point for Calendar Plantempus
import { eventBus } from './core/EventBus.js';
import { calendarConfig } from './core/CalendarConfig.js';
import { CalendarTypeFactory } from './factories/CalendarTypeFactory.js';
import { ManagerFactory } from './factories/ManagerFactory.js';
import { DateCalculator } from './utils/DateCalculator.js';
import { URLManager } from './utils/URLManager.js';
import { eventBus } from './core/EventBus';
import { calendarConfig } from './core/CalendarConfig';
import { CalendarTypeFactory } from './factories/CalendarTypeFactory';
import { ManagerFactory } from './factories/ManagerFactory';
import { URLManager } from './utils/URLManager';
import { CalendarManagers } from './types/ManagerTypes';
/**
* Handle deep linking functionality after managers are initialized
*/
async function handleDeepLinking(managers: any): Promise<void> {
async function handleDeepLinking(managers: CalendarManagers): Promise<void> {
try {
const urlManager = new URLManager(eventBus);
const eventId = urlManager.parseEventIdFromURL();
@ -39,9 +39,6 @@ async function initializeCalendar(): Promise<void> {
// Use the singleton calendar configuration
const config = calendarConfig;
// Initialize DateCalculator with config first
DateCalculator.initialize(config);
// Initialize the CalendarTypeFactory before creating managers
CalendarTypeFactory.initialize();
@ -58,8 +55,12 @@ async function initializeCalendar(): Promise<void> {
// Handle deep linking after managers are initialized
await handleDeepLinking(managers);
// Expose to window for debugging
(window as any).calendarDebug = {
// Expose to window for debugging (with proper typing)
(window as Window & {
calendarDebug?: {
eventBus: typeof eventBus;
} & CalendarManagers;
}).calendarDebug = {
eventBus,
...managers
};

View file

@ -1,53 +0,0 @@
/**
* Base interface for all managers
*/
export interface IManager {
/**
* Initialize the manager
*/
initialize?(): Promise<void> | void;
/**
* Refresh the manager's state
*/
refresh?(): void;
/**
* Destroy the manager and clean up resources
*/
destroy?(): void;
}
/**
* Interface for managers that handle events
*/
export interface IEventManager extends IManager {
loadData(): Promise<void>;
getEvents(): any[];
getEventsForPeriod(startDate: Date, endDate: Date): any[];
}
/**
* Interface for managers that handle rendering
*/
export interface IRenderingManager extends IManager {
render(): Promise<void> | void;
}
/**
* Interface for managers that handle navigation
*/
export interface INavigationManager extends IManager {
getCurrentWeek(): Date;
navigateToToday(): void;
navigateToNextWeek(): void;
navigateToPreviousWeek(): void;
}
/**
* Interface for managers that handle scrolling
*/
export interface IScrollManager extends IManager {
scrollTo(scrollTop: number): void;
scrollToHour(hour: number): void;
}

View file

@ -0,0 +1,624 @@
// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS, calendarConfig } from '../core/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { AllDayLayoutEngine, EventLayout } from '../utils/AllDayLayoutEngine';
import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { CalendarEvent } from '../types/CalendarTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import {
DragMouseEnterHeaderEventPayload,
DragStartEventPayload,
DragMoveEventPayload,
DragEndEventPayload,
DragColumnChangeEventPayload,
HeaderReadyEventPayload
} from '../types/EventTypes';
import { DragOffset, MousePosition } from '../types/DragDropTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { EventManager } from './EventManager';
import { differenceInCalendarDays } from 'date-fns';
import { DateService } from '../utils/DateService';
/**
* 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 layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for differential updates
private currentLayouts: EventLayout[] = [];
private currentAllDayEvents: CalendarEvent[] = [];
private currentWeekDates: ColumnBounds[] = [];
private newLayouts: EventLayout[] = [];
// Expand/collapse state
private isExpanded: boolean = false;
private actualRowCount: number = 0;
constructor(eventManager: EventManager) {
this.eventManager = eventManager;
this.allDayEventRenderer = new AllDayEventRenderer();
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
// 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<DragMouseEnterHeaderEventPayload>).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: DragStartEventPayload = (event as CustomEvent<DragStartEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload: DragColumnChangeEventPayload = (event as CustomEvent<DragColumnChangeEventPayload>).detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let draggedElement: DragEndEventPayload = (event as CustomEvent<DragEndEventPayload>).detail;
if (draggedElement.target != 'swp-day-header') // we are not inside the swp-day-header, so just ignore.
return;
this.handleDragEnd(draggedElement);
});
// 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', (event: Event) => {
let headerReadyEventPayload = (event as CustomEvent<HeaderReadyEventPayload>).detail;
let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date);
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.date);
let events: CalendarEvent[] = this.eventManager.getEventsForPeriod(startDate, endDate);
// Filter for all-day events
const allDayEvents = events.filter(event => event.allDay);
this.currentLayouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements)
this.allDayEventRenderer.renderAllDayEventsForPeriod(this.currentLayouts);
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');
}
/**
* 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 + 2;
// 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 };
}
/**
* Collapse all-day row when no events
*/
public collapseAllDayRow(): void {
this.animateToRows(0);
}
/**
* Check current all-day events and animate to correct height
*/
public checkAndAnimateAllDayHeight(): void {
// Calculate required rows - 0 if no events (will collapse)
let maxRows = 0;
if (this.currentLayouts.length > 0) {
// Find the HIGHEST row number in use from currentLayouts
let highestRow = 0;
this.currentLayouts.forEach((layout) => {
highestRow = Math.max(highestRow, layout.row);
});
// Max rows = highest row number (e.g. if row 3 is used, height = 3 rows)
maxRows = highestRow;
}
// 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();
}
// 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: CalendarEvent[], dayHeaders: ColumnBounds[]): EventLayout[] {
// Store current state
this.currentAllDayEvents = events;
this.currentWeekDates = dayHeaders;
// Initialize layout engine with provided week dates
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.date));
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
}
private handleConvertToAllDay(payload: DragMouseEnterHeaderEventPayload): void {
let allDayContainer = this.getAllDayContainer();
if (!allDayContainer) return;
// Create SwpAllDayEventElement from CalendarEvent
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();
}
/**
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
*/
private handleColumnChange(dragColumnChangeEventPayload: DragColumnChangeEventPayload): 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 {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
setTimeout(() => {
element.remove();
}, 300);
}
private handleDragEnd(dragEndEvent: DragEndEventPayload): void {
const getEventDurationDays = (start: string | undefined, end: string | undefined): number => {
if (!start || !end)
throw new Error('Undefined start or end - date');
const startDate = new Date(start);
const endDate = new Date(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error('Ugyldig start eller slut-dato i dataset');
}
// Use differenceInCalendarDays for proper calendar day calculation
// This correctly handles timezone differences and DST changes
return differenceInCalendarDays(endDate, startDate);
};
if (dragEndEvent.draggedClone == null)
return;
// 2. Normalize clone ID
dragEndEvent.draggedClone.dataset.eventId = dragEndEvent.draggedClone.dataset.eventId?.replace('clone-', '');
dragEndEvent.draggedClone.style.pointerEvents = ''; // Re-enable pointer events
dragEndEvent.originalElement.dataset.eventId += '_';
let eventId = dragEndEvent.draggedClone.dataset.eventId;
let eventDate = dragEndEvent.finalPosition.column?.date;
let eventType = dragEndEvent.draggedClone.dataset.type;
if (eventDate == null || eventId == null || eventType == null)
return;
const durationDays = getEventDurationDays(dragEndEvent.draggedClone.dataset.start, dragEndEvent.draggedClone.dataset.end);
// Get original dates to preserve time
const originalStartDate = new Date(dragEndEvent.draggedClone.dataset.start!);
const originalEndDate = new Date(dragEndEvent.draggedClone.dataset.end!);
// Create new start date with the new day but preserve original time
const newStartDate = new Date(eventDate);
newStartDate.setHours(originalStartDate.getHours(), originalStartDate.getMinutes(), originalStartDate.getSeconds(), originalStartDate.getMilliseconds());
// Create new end date with the new day + duration, preserving original end time
const newEndDate = new Date(eventDate);
newEndDate.setDate(newEndDate.getDate() + durationDays);
newEndDate.setHours(originalEndDate.getHours(), originalEndDate.getMinutes(), originalEndDate.getSeconds(), originalEndDate.getMilliseconds());
// Update data attributes with new dates (convert to UTC)
dragEndEvent.draggedClone.dataset.start = this.dateService.toUTC(newStartDate);
dragEndEvent.draggedClone.dataset.end = this.dateService.toUTC(newEndDate);
const droppedEvent: CalendarEvent = {
id: eventId,
title: dragEndEvent.draggedClone.dataset.title || '',
start: newStartDate,
end: newEndDate,
type: eventType,
allDay: true,
syncStatus: 'synced'
};
// Use current events + dropped event for calculation
const tempEvents = [
...this.currentAllDayEvents.filter(event => event.id !== eventId),
droppedEvent
];
// 4. Calculate new layouts for ALL events
this.newLayouts = this.calculateAllDayEventsLayout(tempEvents, this.currentWeekDates);
// 5. Apply differential updates - only update events that changed
let changedCount = 0;
let container = this.getAllDayContainer();
this.newLayouts.forEach((layout) => {
// Find current layout for this event
let currentLayout = this.currentLayouts.find(old => old.calenderEvent.id === layout.calenderEvent.id);
if (currentLayout?.gridArea !== layout.gridArea) {
changedCount++;
let element = container?.querySelector(`[data-event-id="${layout.calenderEvent.id}"]`) as HTMLElement;
if (element) {
element.classList.add('transitioning');
element.style.gridArea = layout.gridArea;
element.style.gridRow = layout.row.toString();
element.style.gridColumn = `${layout.startColumn} / ${layout.endColumn + 1}`;
element.classList.remove('max-event-overflow-hide');
element.classList.remove('max-event-overflow-show');
if (layout.row > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS)
if (!this.isExpanded)
element.classList.add('max-event-overflow-hide');
else
element.classList.add('max-event-overflow-show');
// Remove transition class after animation
setTimeout(() => element.classList.remove('transitioning'), 200);
}
}
});
if (changedCount > 0)
this.currentLayouts = this.newLayouts;
// 6. Clean up drag styles from the dropped clone
dragEndEvent.draggedClone.classList.remove('dragging');
dragEndEvent.draggedClone.style.zIndex = '';
dragEndEvent.draggedClone.style.cursor = '';
dragEndEvent.draggedClone.style.opacity = '';
this.fadeOutAndRemove(dragEndEvent.originalElement);
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 ColumnBounds
*/
private countEventsInColumn(columnBounds: ColumnBounds): number {
let columnIndex = columnBounds.index;
let count = 0;
this.currentLayouts.forEach((layout) => {
// Check if event spans this column
if (layout.startColumn <= columnIndex && layout.endColumn >= columnIndex) {
count++;
}
});
return count;
}
/**
* 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,13 +1,15 @@
import { EventBus } from '../core/EventBus.js';
import { CoreEvents } from '../constants/CoreEvents.js';
import { calendarConfig } from '../core/CalendarConfig.js';
import { CalendarEvent, CalendarView, IEventBus } from '../types/CalendarTypes.js';
import { EventManager } from './EventManager.js';
import { GridManager } from './GridManager.js';
import { EventRenderingService } from '../renderers/EventRendererManager.js';
import { ScrollManager } from './ScrollManager.js';
import { DateCalculator } from '../utils/DateCalculator.js';
import { EventFilterManager } from './EventFilterManager.js';
import { EventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
import { CalendarEvent, CalendarView, IEventBus } from '../types/CalendarTypes';
import { EventManager } from './EventManager';
import { GridManager } from './GridManager';
import { HeaderManager } from './HeaderManager';
import { EventRenderingService } from '../renderers/EventRendererManager';
import { ScrollManager } from './ScrollManager';
import { DateService } from '../utils/DateService';
import { EventFilterManager } from './EventFilterManager';
import { InitializationReport } from '../types/ManagerTypes';
/**
* CalendarManager - Main coordinator for all calendar managers
@ -17,10 +19,11 @@ export class CalendarManager {
private eventBus: IEventBus;
private eventManager: EventManager;
private gridManager: GridManager;
private headerManager: HeaderManager;
private eventRenderer: EventRenderingService;
private scrollManager: ScrollManager;
private eventFilterManager: EventFilterManager;
private dateCalculator: DateCalculator;
private dateService: DateService;
private currentView: CalendarView = 'week';
private currentDate: Date = new Date();
private isInitialized: boolean = false;
@ -35,11 +38,12 @@ export class CalendarManager {
this.eventBus = eventBus;
this.eventManager = eventManager;
this.gridManager = gridManager;
this.headerManager = new HeaderManager();
this.eventRenderer = eventRenderer;
this.scrollManager = scrollManager;
this.eventFilterManager = new EventFilterManager();
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
this.setupEventListeners();
}
@ -62,10 +66,13 @@ export class CalendarManager {
// Step 2: Pass data to GridManager and render grid structure
if (calendarType === 'resource') {
const resourceData = this.eventManager.getResourceData();
this.gridManager.setResourceData(resourceData);
this.gridManager.setResourceData(this.eventManager.getRawData() as import('../types/CalendarTypes').ResourceCalendarData);
}
await this.gridManager.render();
// Step 2a: Setup header drag listeners after grid render (when DOM is available)
this.headerManager.setupHeaderDragListeners();
// Step 2b: Trigger event rendering now that data is loaded
// Re-emit GRID_RENDERED to trigger EventRendererManager
const gridContainer = document.querySelector('swp-calendar-container');
@ -205,12 +212,17 @@ export class CalendarManager {
/**
* Get initialization report for debugging
*/
public getInitializationReport(): any {
public getInitializationReport(): InitializationReport {
return {
isInitialized: this.isInitialized,
currentView: this.currentView,
currentDate: this.currentDate,
initializationTime: 'N/A - simple initialization'
initialized: this.isInitialized,
timestamp: Date.now(),
managers: {
calendar: { initialized: this.isInitialized },
event: { initialized: true },
grid: { initialized: true },
header: { initialized: true },
scroll: { initialized: true }
}
};
}
@ -374,6 +386,13 @@ export class CalendarManager {
// Re-render events in the new grid structure
this.rerenderEvents();
// Notify HeaderManager with correct current date after grid rebuild
this.eventBus.emit('workweek:header-update', {
currentDate: this.currentDate,
currentView: this.currentView,
workweek: calendarConfig.getCurrentWorkWeek()
});
}
/**
@ -432,10 +451,10 @@ export class CalendarManager {
const lastDate = new Date(lastDateStr);
// Calculate week number from first date
const weekNumber = DateCalculator.getWeekNumber(firstDate);
const weekNumber = this.dateService.getWeekNumber(firstDate);
// Format date range
const dateRange = DateCalculator.formatDateRange(firstDate, lastDate);
const dateRange = this.dateService.formatDateRange(firstDate, lastDate);
// Emit week info update
this.eventBus.emit(CoreEvents.PERIOD_INFO_UPDATE, {

View file

@ -5,40 +5,56 @@
import { IEventBus } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { PositionUtils } from '../utils/PositionUtils';
import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { SwpEventElement, BaseSwpEventElement } from '../elements/SwpEventElement';
import {
DragStartEventPayload,
DragMoveEventPayload,
DragEndEventPayload,
DragMouseEnterHeaderEventPayload,
DragMouseLeaveHeaderEventPayload,
DragColumnChangeEventPayload
} from '../types/EventTypes';
import { MousePosition } from '../types/DragDropTypes';
interface CachedElements {
scrollContainer: HTMLElement | null;
currentColumn: HTMLElement | null;
lastColumnDate: string | null;
}
interface Position {
x: number;
y: number;
}
export class DragDropManager {
private eventBus: IEventBus;
// Mouse tracking with optimized state
private isMouseDown = false;
private lastMousePosition: Position = { x: 0, y: 0 };
private lastLoggedPosition: Position = { x: 0, y: 0 };
private lastMousePosition: MousePosition = { x: 0, y: 0 };
private lastLoggedPosition: MousePosition = { x: 0, y: 0 };
private currentMouseY = 0;
private mouseOffset: Position = { x: 0, y: 0 };
private mouseOffset: MousePosition = { x: 0, y: 0 };
private initialMousePosition: MousePosition = { x: 0, y: 0 };
private lastColumn: ColumnBounds | null = null;
// Drag state
private draggedEventId: string | null = null;
private originalElement: HTMLElement | null = null;
private currentColumn: string | null = null;
private draggedElement!: HTMLElement | null;
private draggedClone!: HTMLElement | null;
private currentColumnBounds: ColumnBounds | null = null;
private initialColumnBounds: ColumnBounds | null = null; // Track source column
private isDragStarted = false;
// Hover state
private isHoverTrackingActive = false;
private currentHoveredEvent: HTMLElement | null = null;
// Movement threshold to distinguish click from drag
private readonly dragThreshold = 5; // pixels
private scrollContainer!: HTMLElement | null;
// Cached DOM elements for performance
private cachedElements: CachedElements = {
scrollContainer: null,
currentColumn: null,
lastColumnDate: null
};
// Auto-scroll properties
private autoScrollAnimationId: number | null = null;
@ -49,12 +65,11 @@ export class DragDropManager {
private snapIntervalMinutes = 15; // Default 15 minutes
private hourHeightPx: number; // Will be set from config
// Event listener references for proper cleanup
private boundHandlers = {
mouseMove: this.handleMouseMove.bind(this),
mouseDown: this.handleMouseDown.bind(this),
mouseUp: this.handleMouseUp.bind(this)
};
// Smooth drag animation
private dragAnimationId: number | null = null;
private targetY = 0;
private currentY = 0;
private targetColumn: ColumnBounds | null = null;
private get snapDistancePx(): number {
return (this.snapIntervalMinutes / 60) * this.hourHeightPx;
@ -62,7 +77,6 @@ export class DragDropManager {
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
// Get config values
const gridSettings = calendarConfig.getGridSettings();
this.hourHeightPx = gridSettings.hourHeight;
@ -82,37 +96,83 @@ export class DragDropManager {
* Initialize with optimized event listener setup
*/
private init(): void {
// Use bound handlers for proper cleanup
document.body.addEventListener('mousemove', this.boundHandlers.mouseMove);
document.body.addEventListener('mousedown', this.boundHandlers.mouseDown);
document.body.addEventListener('mouseup', this.boundHandlers.mouseUp);
// Add event listeners
document.body.addEventListener('mousemove', this.handleMouseMove.bind(this));
document.body.addEventListener('mousedown', this.handleMouseDown.bind(this));
document.body.addEventListener('mouseup', this.handleMouseUp.bind(this));
// Listen for header mouseover events
this.eventBus.on('header:mouseover', (event) => {
const { element, targetDate, headerRenderer } = (event as CustomEvent).detail;
this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
const calendarContainer = document.querySelector('swp-calendar-container');
if (this.isMouseDown && this.draggedEventId && targetDate) {
// Emit event to convert to all-day
this.eventBus.emit('drag:convert-to-allday', {
eventId: this.draggedEventId,
targetDate,
element,
headerRenderer
});
}
if (calendarContainer) {
calendarContainer.addEventListener('mouseleave', () => {
if (this.draggedElement && this.isDragStarted) {
this.cancelDrag();
}
});
// Event delegation for header enter/leave
calendarContainer.addEventListener('mouseenter', (e) => {
const target = e.target as HTMLElement;
if (target.closest('swp-calendar-header')) {
this.handleHeaderMouseEnter(e as MouseEvent);
} else if (target.closest('swp-event')) {
// Entered an event - activate hover tracking and set color
const eventElement = target.closest<HTMLElement>('swp-event');
const mouseEvent = e as MouseEvent;
// Only handle hover if mouse button is up
if (eventElement && !this.isDragStarted && mouseEvent.buttons === 0) {
// Clear any previous hover first
if (this.currentHoveredEvent && this.currentHoveredEvent !== eventElement) {
this.currentHoveredEvent.classList.remove('hover');
}
this.isHoverTrackingActive = true;
this.currentHoveredEvent = eventElement;
eventElement.classList.add('hover');
}
}
}, true); // Use capture phase
calendarContainer.addEventListener('mouseleave', (e) => {
const target = e.target as HTMLElement;
if (target.closest('swp-calendar-header')) {
this.handleHeaderMouseLeave(e as MouseEvent);
}
// Don't handle swp-event mouseleave here - let mousemove handle it
}, true); // Use capture phase
}
// Initialize column bounds cache
ColumnDetectionUtils.updateColumnBoundsCache();
// Listen to resize events to update cache
window.addEventListener('resize', () => {
ColumnDetectionUtils.updateColumnBoundsCache();
});
// Listen to navigation events to update cache
this.eventBus.on('navigation:completed', () => {
ColumnDetectionUtils.updateColumnBoundsCache();
});
}
private handleMouseDown(event: MouseEvent): void {
this.isMouseDown = true;
// Clean up drag state first
this.cleanupDragState();
ColumnDetectionUtils.updateColumnBoundsCache();
this.lastMousePosition = { x: event.clientX, y: event.clientY };
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
this.initialMousePosition = { x: event.clientX, y: event.clientY };
// Check if mousedown is on an event
const target = event.target as HTMLElement;
let eventElement = target;
while (eventElement && eventElement.tagName !== 'SWP-EVENTS-LAYER') {
while (eventElement && eventElement.tagName !== 'SWP-GRID-CONTAINER') {
if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') {
break;
}
@ -120,38 +180,22 @@ export class DragDropManager {
if (!eventElement) return;
}
// If we reached SWP-EVENTS-LAYER without finding an event, return
if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') {
return;
}
// Found an event - start dragging
// Found an event - check if clicking on resize handle first
if (eventElement) {
this.originalElement = eventElement;
this.draggedEventId = eventElement.dataset.eventId || null;
// Check if click is on resize handle
if (target.closest('swp-resize-handle')) {
return; // Exit early - this is a resize operation, let ResizeHandleManager handle it
}
// Normal drag - prepare for potential dragging
this.draggedElement = eventElement;
this.lastColumn = ColumnDetectionUtils.getColumnBounds(this.lastMousePosition)
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
// Detect current column
const column = this.detectColumn(event.clientX, event.clientY);
if (column) {
this.currentColumn = column;
}
// Emit drag start event
this.eventBus.emit('drag:start', {
originalElement: eventElement,
eventId: this.draggedEventId,
mousePosition: { x: event.clientX, y: event.clientY },
mouseOffset: this.mouseOffset,
column: this.currentColumn
});
}
}
@ -160,43 +204,97 @@ export class DragDropManager {
*/
private handleMouseMove(event: MouseEvent): void {
this.currentMouseY = event.clientY;
this.lastMousePosition = { x: event.clientX, y: event.clientY };
if (this.isMouseDown && this.draggedEventId) {
const currentPosition: Position = { x: event.clientX, y: event.clientY };
const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y);
// Check for event hover (coordinate-based) - only when mouse button is up
if (this.isHoverTrackingActive && event.buttons === 0) {
this.checkEventHover(event);
}
// Check for snap interval vertical movement (normal drag behavior)
if (deltaY >= this.snapDistancePx) {
this.lastLoggedPosition = currentPosition;
if (event.buttons === 1) {
const currentPosition: MousePosition = { x: event.clientX, y: event.clientY };
// Consolidated position calculations with snapping for normal drag
const positionData = this.calculateDragPosition(currentPosition);
// Check if we need to start drag (movement threshold)
if (!this.isDragStarted && this.draggedElement) {
const deltaX = Math.abs(currentPosition.x - this.initialMousePosition.x);
const deltaY = Math.abs(currentPosition.y - this.initialMousePosition.y);
const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Emit drag move event with snapped position (normal behavior)
this.eventBus.emit('drag:move', {
eventId: this.draggedEventId,
mousePosition: currentPosition,
snappedY: positionData.snappedY,
column: positionData.column,
mouseOffset: this.mouseOffset
});
if (totalMovement >= this.dragThreshold) {
// Start drag - emit drag:start event
this.isDragStarted = true;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.draggedElement.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '9999';
} else {
this.draggedElement.style.zIndex = '9999';
}
// Detect current column and save as initial source column
this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition);
this.initialColumnBounds = this.currentColumnBounds; // Save source column
// Cast to BaseSwpEventElement and create clone (works for both SwpEventElement and SwpAllDayEventElement)
const originalElement = this.draggedElement as BaseSwpEventElement;
this.draggedClone = originalElement.createClone();
const dragStartPayload: DragStartEventPayload = {
draggedElement: this.draggedElement,
draggedClone: this.draggedClone,
mousePosition: this.initialMousePosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumnBounds
};
this.eventBus.emit('drag:start', dragStartPayload);
} else {
// Not enough movement yet - don't start drag
return;
}
}
// Check for auto-scroll
this.checkAutoScroll(event);
// Continue with normal drag behavior only if drag has started
if (this.isDragStarted && this.draggedElement && this.draggedClone) {
if (!this.draggedElement.hasAttribute("data-allday")) {
// Calculate raw position from mouse (no snapping)
const column = ColumnDetectionUtils.getColumnBounds(currentPosition);
// Check for column change using cached data
const newColumn = this.getColumnFromCache(currentPosition);
if (newColumn && newColumn !== this.currentColumn) {
const previousColumn = this.currentColumn;
this.currentColumn = newColumn;
if (column) {
// Calculate raw Y position relative to column (accounting for mouse offset)
const columnRect = column.boundingClientRect;
const eventTopY = currentPosition.y - columnRect.top - this.mouseOffset.y;
this.targetY = Math.max(0, eventTopY); // Store raw Y as target (no snapping)
this.targetColumn = column;
this.eventBus.emit('drag:column-change', {
eventId: this.draggedEventId,
previousColumn,
newColumn,
mousePosition: currentPosition
});
// Start animation loop if not already running
if (this.dragAnimationId === null) {
this.currentY = parseFloat(this.draggedClone.style.top) || 0;
this.animateDrag();
}
}
// Check for auto-scroll
this.checkAutoScroll(currentPosition);
}
const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (newColumn == null)
return;
if (newColumn?.index !== this.currentColumnBounds?.index) {
const previousColumn = this.currentColumnBounds;
this.currentColumnBounds = newColumn;
const dragColumnChangePayload: DragColumnChangeEventPayload = {
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
previousColumn,
newColumn,
mousePosition: currentPosition
};
this.eventBus.emit('drag:column-change', dragColumnChangePayload);
}
}
}
}
@ -205,163 +303,204 @@ export class DragDropManager {
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void {
if (!this.isMouseDown) return;
this.isMouseDown = false;
this.stopAutoScroll();
this.stopDragAnimation();
if (this.draggedEventId && this.originalElement) {
const finalPosition: Position = { x: event.clientX, y: event.clientY };
if (this.draggedElement) {
// Use consolidated position calculation
const positionData = this.calculateDragPosition(finalPosition);
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
const mousePosition: MousePosition = { x: event.clientX, y: event.clientY };
// Emit drag end event
this.eventBus.emit('drag:end', {
eventId: this.draggedEventId,
originalElement: this.originalElement,
finalPosition,
finalColumn: positionData.column,
finalY: positionData.snappedY
});
// Snap to grid on mouse up (like ResizeHandleManager)
const column = ColumnDetectionUtils.getColumnBounds(mousePosition);
// Clean up drag state
this.cleanupDragState();
if (!column) {
console.warn('No column detected on mouseUp');
return;
}
// Get current position and snap it to grid
const currentY = parseFloat(this.draggedClone?.style.top || '0');
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
// Update clone to snapped position immediately
if (this.draggedClone) {
this.draggedClone.style.top = `${snappedY}px`;
}
// Detect drop target (swp-day-column or swp-day-header)
const dropTarget = this.detectDropTarget(mousePosition);
if (!dropTarget)
throw "dropTarget is null";
console.log('🎯 DragDropManager: Emitting drag:end', {
draggedElement: this.draggedElement.dataset.eventId,
finalColumn: column,
finalY: snappedY,
dropTarget: dropTarget,
isDragStarted: this.isDragStarted
});
const dragEndPayload: DragEndEventPayload = {
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
sourceColumn: this.initialColumnBounds, // Where drag started
mousePosition,
finalPosition: { column, snappedY }, // Where drag ended
target: dropTarget
};
this.eventBus.emit('drag:end', dragEndPayload);
this.cleanupDragState();
} else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
draggedElement: this.draggedElement,
mousePosition: { x: event.clientX, y: event.clientY }
});
}
}
}
// Add a cleanup method that finds and removes ALL clones
private cleanupAllClones(): void {
// Remove clones from all possible locations
const allClones = document.querySelectorAll('[data-event-id^="clone"]');
if (allClones.length > 0) {
console.log(`🧹 DragDropManager: Removing ${allClones.length} clone(s)`);
allClones.forEach(clone => clone.remove());
}
}
/**
* Consolidated position calculation method
* Cancel drag operation when mouse leaves grid container
*/
private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } {
const column = this.detectColumn(mousePosition.x, mousePosition.y);
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
private cancelDrag(): void {
if (!this.draggedElement) return;
console.log('🚫 DragDropManager: Cancelling drag - mouse left grid container');
const draggedElement = this.draggedElement;
// 1. Remove all clones
this.cleanupAllClones();
// 2. Restore original element
if (draggedElement) {
draggedElement.style.opacity = '';
draggedElement.style.cursor = '';
}
// 3. Emit cancellation event
this.eventBus.emit('drag:cancelled', {
draggedElement: draggedElement,
reason: 'mouse-left-grid'
});
// 4. Clean up state
this.cleanupDragState();
this.stopAutoScroll();
this.stopDragAnimation();
}
/**
* Consolidated position calculation method using PositionUtils
*/
private calculateDragPosition(mousePosition: MousePosition): { column: ColumnBounds | null; snappedY: number } {
let column = ColumnDetectionUtils.getColumnBounds(mousePosition);
let snappedY = 0;
if (column) {
snappedY = this.calculateSnapPosition(mousePosition.y, column);
return { column, snappedY };
}
return { column, snappedY };
}
/**
* Calculate free position (follows mouse exactly)
* Optimized snap position calculation using PositionUtils
*/
private calculateFreePosition(mouseY: number, column: string | null = null): number {
const targetColumn = column || this.currentColumn;
private calculateSnapPosition(mouseY: number, column: ColumnBounds): number {
// Calculate where the event top would be (accounting for mouse offset)
const eventTopY = mouseY - this.mouseOffset.y;
// Use cached column element if available
const columnElement = this.getCachedColumnElement(targetColumn);
if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
// Return free position (no snapping)
return Math.max(0, relativeY);
}
/**
* Optimized snap position calculation with caching (used only on drop)
*/
private calculateSnapPosition(mouseY: number, column: string | null = null): number {
const targetColumn = column || this.currentColumn;
// Use cached column element if available
const columnElement = this.getCachedColumnElement(targetColumn);
if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
// Snap to nearest interval using DateCalculator precision
const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx;
// Snap the event top position, not the mouse position
const snappedY = PositionUtils.getPositionFromCoordinate(eventTopY, column);
return Math.max(0, snappedY);
}
/**
* Optimized column detection with caching
* Smooth drag animation using requestAnimationFrame
*/
private detectColumn(mouseX: number, mouseY: number): string | null {
const element = document.elementFromPoint(mouseX, mouseY);
if (!element) return null;
// Walk up DOM tree to find swp-day-column
let current = element as HTMLElement;
while (current && current.tagName !== 'SWP-DAY-COLUMN') {
current = current.parentElement as HTMLElement;
if (!current) return null;
private animateDrag(): void {
if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) {
this.dragAnimationId = null;
return;
}
const columnDate = current.dataset.date || null;
// Smooth interpolation towards target
const diff = this.targetY - this.currentY;
const step = diff * 0.3; // 30% of distance per frame
// Update cache if we found a new column
if (columnDate && columnDate !== this.cachedElements.lastColumnDate) {
this.cachedElements.currentColumn = current;
this.cachedElements.lastColumnDate = columnDate;
// Update if difference is significant
if (Math.abs(diff) > 0.5) {
this.currentY += step;
// Emit drag move event with interpolated position
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
draggedClone: this.draggedClone,
mousePosition: this.lastMousePosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = requestAnimationFrame(() => this.animateDrag());
} else {
// Close enough - snap to target
this.currentY = this.targetY;
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
draggedClone: this.draggedClone,
mousePosition: this.lastMousePosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = null;
}
return columnDate;
}
/**
* Get column from cache or detect new one
*/
private getColumnFromCache(mousePosition: Position): string | null {
// Try to use cached column first
if (this.cachedElements.currentColumn && this.cachedElements.lastColumnDate) {
const rect = this.cachedElements.currentColumn.getBoundingClientRect();
if (mousePosition.x >= rect.left && mousePosition.x <= rect.right) {
return this.cachedElements.lastColumnDate;
}
}
// Cache miss - detect new column
return this.detectColumn(mousePosition.x, mousePosition.y);
}
/**
* Get cached column element or query for new one
*/
private getCachedColumnElement(columnDate: string | null): HTMLElement | null {
if (!columnDate) return null;
// Return cached element if it matches
if (this.cachedElements.lastColumnDate === columnDate && this.cachedElements.currentColumn) {
return this.cachedElements.currentColumn;
}
// Query for new element and cache it
const element = document.querySelector(`swp-day-column[data-date="${columnDate}"]`) as HTMLElement;
if (element) {
this.cachedElements.currentColumn = element;
this.cachedElements.lastColumnDate = columnDate;
}
return element;
}
/**
* Optimized auto-scroll check with cached container
*/
private checkAutoScroll(event: MouseEvent): void {
// Use cached scroll container
if (!this.cachedElements.scrollContainer) {
this.cachedElements.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
if (!this.cachedElements.scrollContainer) {
return;
}
}
private checkAutoScroll(mousePosition: MousePosition): void {
const containerRect = this.cachedElements.scrollContainer.getBoundingClientRect();
const mouseY = event.clientY;
if (this.scrollContainer == null)
return;
const containerRect = this.scrollContainer.getBoundingClientRect();
const mouseY = mousePosition.clientY;
// Calculate distances from edges
const distanceFromTop = mouseY - containerRect.top;
const distanceFromBottom = containerRect.bottom - mouseY;
const distanceFromTop = mousePosition.y - containerRect.top;
const distanceFromBottom = containerRect.bottom - mousePosition.y;
// Check if we need to scroll
if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) {
this.startAutoScroll('up');
this.startAutoScroll('up', mousePosition);
} else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) {
this.startAutoScroll('down');
this.startAutoScroll('down', mousePosition);
} else {
this.stopAutoScroll();
}
@ -370,33 +509,34 @@ export class DragDropManager {
/**
* Optimized auto-scroll with cached container reference
*/
private startAutoScroll(direction: 'up' | 'down'): void {
private startAutoScroll(direction: 'up' | 'down', event: MousePosition): void {
if (this.autoScrollAnimationId !== null) return;
const scroll = () => {
if (!this.cachedElements.scrollContainer || !this.isMouseDown) {
if (!this.scrollContainer || !this.draggedElement) {
this.stopAutoScroll();
return;
}
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.cachedElements.scrollContainer.scrollTop += scrollAmount;
this.scrollContainer.scrollTop += scrollAmount;
// Emit updated position during scroll - adjust for scroll movement
if (this.draggedEventId) {
if (this.draggedElement) {
// During autoscroll, we need to calculate position relative to the scrolled content
// The mouse hasn't moved, but the content has scrolled
const columnElement = this.getCachedColumnElement(this.currentColumn);
const columnElement = ColumnDetectionUtils.getColumnBounds(event);
if (columnElement) {
const columnRect = columnElement.getBoundingClientRect();
const columnRect = columnElement.boundingClientRect;
// Calculate free position relative to column, accounting for scroll movement (no snapping during scroll)
const relativeY = this.currentMouseY - columnRect.top - this.mouseOffset.y;
const freeY = Math.max(0, relativeY);
this.eventBus.emit('drag:auto-scroll', {
eventId: this.draggedEventId,
draggedElement: this.draggedElement,
snappedY: freeY, // Actually free position during scroll
scrollTop: this.cachedElements.scrollContainer.scrollTop
scrollTop: this.scrollContainer.scrollTop
});
}
}
@ -418,35 +558,131 @@ export class DragDropManager {
}
/**
* Clean up drag state
* Stop drag animation
*/
private cleanupDragState(): void {
this.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
// Clear cached elements
this.cachedElements.currentColumn = null;
this.cachedElements.lastColumnDate = null;
private stopDragAnimation(): void {
if (this.dragAnimationId !== null) {
cancelAnimationFrame(this.dragAnimationId);
this.dragAnimationId = null;
}
}
/**
* Clean up all resources and event listeners
* Clean up drag state
*/
public destroy(): void {
this.stopAutoScroll();
private cleanupDragState(): void {
this.draggedElement = null;
this.draggedClone = null;
this.isDragStarted = false;
}
// Remove event listeners using bound references
document.body.removeEventListener('mousemove', this.boundHandlers.mouseMove);
document.body.removeEventListener('mousedown', this.boundHandlers.mouseDown);
document.body.removeEventListener('mouseup', this.boundHandlers.mouseUp);
/**
* Detect drop target - whether dropped in swp-day-column or swp-day-header
*/
private detectDropTarget(position: MousePosition): 'swp-day-column' | 'swp-day-header' | null {
// Clear all cached elements
this.cachedElements.scrollContainer = null;
this.cachedElements.currentColumn = null;
this.cachedElements.lastColumnDate = null;
// Traverse up the DOM tree to find the target container
let currentElement = this.draggedClone;
while (currentElement && currentElement !== document.body) {
if (currentElement.tagName === 'SWP-ALLDAY-CONTAINER') {
return 'swp-day-header';
}
if (currentElement.tagName === 'SWP-DAY-COLUMN') {
return 'swp-day-column';
}
currentElement = currentElement.parentElement as HTMLElement;
}
// Clean up drag state
this.cleanupDragState();
return null;
}
/**
* Handle mouse enter on calendar header - simplified using native events
*/
private handleHeaderMouseEnter(event: MouseEvent): void {
// Only handle if we're dragging a timed event (not all-day)
if (!this.isDragStarted || !this.draggedClone) {
return;
}
const position: MousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (targetColumn) {
console.log('🎯 DragDropManager: Mouse entered header', { targetDate: targetColumn });
// Extract CalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
// Delegate pattern - allows AllDayManager to replace the clone
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
}
};
this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload);
}
}
/**
* Handle mouse leave from calendar header - simplified using native events
*/
private handleHeaderMouseLeave(event: MouseEvent): void {
// Only handle if we're dragging an all-day event
if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute("data-allday")) {
return;
}
console.log('🚪 DragDropManager: Mouse left header');
const position: MousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
console.warn("No column detected when leaving header");
return;
}
const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = {
targetDate: targetColumn.date,
mousePosition: position,
originalElement: this.draggedElement,
draggedClone: this.draggedClone
};
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);
}
private checkEventHover(event: MouseEvent): void {
// Use currentHoveredEvent to check if mouse is still within bounds
if (!this.currentHoveredEvent) return;
const rect = this.currentHoveredEvent.getBoundingClientRect();
const mouseX = event.clientX;
const mouseY = event.clientY;
// Check if mouse is still within the current hovered event
const isStillInside = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
// If mouse left the event
if (!isStillInside) {
// Only disable tracking and clear if mouse is NOT pressed (allow resize to work)
if (event.buttons === 0) {
this.isHoverTrackingActive = false;
this.clearEventHover();
}
}
}
private clearEventHover(): void {
if (this.currentHoveredEvent) {
this.currentHoveredEvent.classList.remove('hover');
this.currentHoveredEvent = null;
}
}
}

View file

@ -7,9 +7,14 @@ import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { CalendarEvent } from '../types/CalendarTypes';
// Import Fuse.js ES module
// @ts-ignore - Fuse.js types not available for local file
import Fuse from '../../wwwroot/js/lib/fuse.min.mjs';
// Import Fuse.js from npm
import Fuse from 'fuse.js';
interface FuseResult {
item: CalendarEvent;
refIndex: number;
score?: number;
}
export class EventFilterManager {
private searchInput: HTMLInputElement | null = null;
@ -17,7 +22,7 @@ export class EventFilterManager {
private matchingEventIds: Set<string> = new Set();
private isFilterActive: boolean = false;
private frameRequest: number | null = null;
private fuse: any = null;
private fuse: Fuse<CalendarEvent> | null = null;
constructor() {
// Wait for DOM to be ready before initializing
@ -120,7 +125,7 @@ export class EventFilterManager {
// Extract matching event IDs
this.matchingEventIds.clear();
results.forEach((result: any) => {
results.forEach((result: FuseResult) => {
if (result.item && result.item.id) {
this.matchingEventIds.add(result.item.id);
}
@ -221,16 +226,4 @@ export class EventFilterManager {
};
}
/**
* Clean up
*/
public destroy(): void {
// Note: We can't easily remove anonymous event listeners
// In production, we'd store references to the bound functions
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
}
}

View file

@ -0,0 +1,276 @@
/**
* EventLayoutCoordinator - Coordinates event layout calculations
*
* Separates layout logic from rendering concerns.
* Calculates stack levels, groups events, and determines rendering strategy.
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { EventStackManager, EventGroup, StackLink } from './EventStackManager';
import { PositionUtils } from '../utils/PositionUtils';
import { calendarConfig } from '../core/CalendarConfig';
export interface GridGroupLayout {
events: CalendarEvent[];
stackLevel: number;
position: { top: number };
columns: CalendarEvent[][]; // Events grouped by column (events in same array share a column)
}
export interface StackedEventLayout {
event: CalendarEvent;
stackLink: StackLink;
position: { top: number; height: number };
}
export interface ColumnLayout {
gridGroups: GridGroupLayout[];
stackedEvents: StackedEventLayout[];
}
export class EventLayoutCoordinator {
private stackManager: EventStackManager;
constructor() {
this.stackManager = new EventStackManager();
}
/**
* Calculate complete layout for a column of events (recursive approach)
*/
public calculateColumnLayout(columnEvents: CalendarEvent[]): ColumnLayout {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
const gridGroupLayouts: GridGroupLayout[] = [];
const stackedEventLayouts: StackedEventLayout[] = [];
const renderedEventsWithLevels: Array<{ event: CalendarEvent; 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 = calendarConfig.getGridSettings();
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: EventGroup = {
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 = 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 = 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: CalendarEvent[],
renderedEventsWithLevels: Array<{ event: CalendarEvent; 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: CalendarEvent,
renderedEventsWithLevels: Array<{ event: CalendarEvent; 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: CalendarEvent, event2: CalendarEvent, 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: CalendarEvent,
remaining: CalendarEvent[],
thresholdMinutes: number
): CalendarEvent[] {
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: CalendarEvent[]): CalendarEvent[][] {
if (events.length === 0) return [];
if (events.length === 1) return [[events[0]]];
const columns: CalendarEvent[][] = [];
// 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

@ -2,7 +2,19 @@ import { EventBus } from '../core/EventBus';
import { IEventBus, CalendarEvent, ResourceCalendarData } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
import { ResourceData } from '../types/ManagerTypes';
interface RawEventData {
id: string;
title: string;
start: string | Date;
end: string | Date;
type : string;
color?: string;
allDay?: boolean;
[key: string]: unknown;
}
/**
* EventManager - Optimized event lifecycle and CRUD operations
@ -11,12 +23,15 @@ import { DateCalculator } from '../utils/DateCalculator';
export class EventManager {
private eventBus: IEventBus;
private events: CalendarEvent[] = [];
private rawData: any = null;
private rawData: ResourceCalendarData | RawEventData[] | null = null;
private eventCache = new Map<string, CalendarEvent[]>(); // Cache for period queries
private lastCacheKey: string = '';
private dateService: DateService;
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
}
/**
@ -57,12 +72,14 @@ export class EventManager {
/**
* Optimized data processing with better type safety
*/
private processCalendarData(calendarType: string, data: any): CalendarEvent[] {
private processCalendarData(calendarType: string, data: ResourceCalendarData | RawEventData[]): CalendarEvent[] {
if (calendarType === 'resource') {
const resourceData = data as ResourceCalendarData;
return resourceData.resources.flatMap(resource =>
resource.events.map(event => ({
...event,
start: new Date(event.start),
end: new Date(event.end),
resourceName: resource.name,
resourceDisplayName: resource.displayName,
resourceEmployeeId: resource.employeeId
@ -70,7 +87,15 @@ export class EventManager {
);
}
return data as CalendarEvent[];
const eventData = data as RawEventData[];
return eventData.map((event): CalendarEvent => ({
...event,
start: new Date(event.start),
end: new Date(event.end),
type : event.type,
allDay: event.allDay || false,
syncStatus: 'synced' as const
}));
}
/**
@ -91,7 +116,24 @@ export class EventManager {
/**
* Get raw resource data for resource calendar mode
*/
public getResourceData(): any {
public getResourceData(): ResourceData | null {
if (!this.rawData || !('resources' in this.rawData)) {
return null;
}
return {
resources: this.rawData.resources.map(r => ({
id: r.employeeId || r.name, // Use employeeId as id, fallback to name
name: r.name,
type: r.employeeId ? 'employee' : 'resource',
color: 'blue' // Default color since Resource interface doesn't have color
}))
};
}
/**
* Get raw data for compatibility
*/
public getRawData(): ResourceCalendarData | RawEventData[] | null {
return this.rawData;
}
@ -114,21 +156,23 @@ export class EventManager {
return null;
}
try {
const eventDate = new Date(event.start);
if (isNaN(eventDate.getTime())) {
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
return null;
}
return {
event,
eventDate
};
} catch (error) {
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
// 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
};
}
/**
@ -158,11 +202,11 @@ export class EventManager {
}
/**
* Optimized events for period with caching and DateCalculator
* Optimized events for period with caching and DateService
*/
public getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[] {
// Create cache key using DateCalculator for consistent formatting
const cacheKey = `${DateCalculator.formatISODate(startDate)}_${DateCalculator.formatISODate(endDate)}`;
// Create cache key using DateService for consistent formatting
const cacheKey = `${this.dateService.formatISODate(startDate)}_${this.dateService.formatISODate(endDate)}`;
// Return cached result if available
if (this.lastCacheKey === cacheKey && this.eventCache.has(cacheKey)) {
@ -171,12 +215,8 @@ export class EventManager {
// Filter events using optimized date operations
const filteredEvents = this.events.filter(event => {
// Use DateCalculator for consistent date parsing
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
// Event overlaps period if it starts before period ends AND ends after period starts
return eventStart <= endDate && eventEnd >= startDate;
return event.start <= endDate && event.end >= startDate;
});
// Cache the result
@ -249,13 +289,4 @@ export class EventManager {
public async refresh(): Promise<void> {
await this.loadData();
}
/**
* Clean up resources and clear caches
*/
public destroy(): void {
this.events = [];
this.rawData = null;
this.clearCache();
}
}

View file

@ -1,451 +0,0 @@
/**
* EventOverlapManager - Håndterer overlap detection og DOM manipulation for overlapping events
* Implementerer både column sharing (flexbox) og stacking patterns
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { DateCalculator } from '../utils/DateCalculator';
import { calendarConfig } from '../core/CalendarConfig';
export enum OverlapType {
NONE = 'none',
COLUMN_SHARING = 'column_sharing',
STACKING = 'stacking'
}
export interface OverlapGroup {
type: OverlapType;
events: CalendarEvent[];
position: { top: number; height: number };
container?: HTMLElement;
}
export class EventOverlapManager {
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
private nextZIndex = 100;
// Linked list til at holde styr på stacked events
private stackChains = new Map<string, { next?: string, prev?: string, stackLevel: number }>();
/**
* Detect overlap mellem events baseret faktisk time overlap og start tid forskel
*/
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
// Først: Tjek om events overlapper i tid
if (!this.eventsOverlapInTime(event1, event2)) {
return OverlapType.NONE;
}
// Events overlapper i tid - nu tjek start tid forskel
const start1 = new Date(event1.start).getTime();
const start2 = new Date(event2.start).getTime();
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
// Over 30 min start forskel = stacking
if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) {
return OverlapType.STACKING;
}
// Indenfor 30 min start forskel = column sharing
return OverlapType.COLUMN_SHARING;
}
/**
* Tjek om to events faktisk overlapper i tid
*/
private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean {
const start1 = new Date(event1.start).getTime();
const end1 = new Date(event1.end).getTime();
const start2 = new Date(event2.start).getTime();
const end2 = new Date(event2.end).getTime();
// Events overlapper hvis de deler mindst ét tidspunkt
return !(end1 <= start2 || end2 <= start1);
}
/**
* Gruppér events baseret overlap type
*/
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
const groups: OverlapGroup[] = [];
const processedEvents = new Set<string>();
for (const event of events) {
if (processedEvents.has(event.id)) continue;
const overlappingEvents = [event];
processedEvents.add(event.id);
// Find alle events der overlapper med dette event
for (const otherEvent of events) {
if (otherEvent.id === event.id || processedEvents.has(otherEvent.id)) continue;
const overlapType = this.detectOverlap(event, otherEvent);
if (overlapType !== OverlapType.NONE) {
overlappingEvents.push(otherEvent);
processedEvents.add(otherEvent.id);
}
}
// Opret gruppe hvis der er overlap
if (overlappingEvents.length > 1) {
const overlapType = this.detectOverlap(overlappingEvents[0], overlappingEvents[1]);
groups.push({
type: overlapType,
events: overlappingEvents,
position: this.calculateGroupPosition(overlappingEvents)
});
} else {
// Single event - ingen overlap
groups.push({
type: OverlapType.NONE,
events: [event],
position: this.calculateGroupPosition([event])
});
}
}
return groups;
}
/**
* Opret flexbox container for column sharing events
*/
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
const container = document.createElement('swp-event-group');
container.style.position = 'absolute';
container.style.top = `${position.top}px`;
// Ingen højde på gruppen - kun på individuelle events
container.style.left = '2px';
container.style.right = '2px';
return container;
}
/**
* Tilføj event til eksisterende event group
*/
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
// Sørg for at event har korrekt højde baseret på varighed
const duration = eventElement.dataset.duration;
if (duration) {
const durationMinutes = parseInt(duration);
const gridSettings = { hourHeight: 80 }; // Fra config
const height = (durationMinutes / 60) * gridSettings.hourHeight;
eventElement.style.height = `${height - 3}px`; // -3px som andre events
}
// Events i flexbox grupper skal bruge relative positioning
eventElement.style.position = 'relative';
container.appendChild(eventElement);
}
/**
* Fjern event fra event group og cleanup hvis tom
*/
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
if (!eventElement) return false;
// Tjek om det fjernede event var stacked
const wasStacked = this.isStackedEvent(eventElement);
// Beregn korrekt top position baseret på event data
const startTime = eventElement.dataset.start;
if (startTime) {
const startDate = new Date(startTime);
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
// Gendan absolute positioning med korrekt top position
eventElement.style.position = 'absolute';
eventElement.style.top = `${top + 1}px`; // +1px som andre events
eventElement.style.left = '2px';
eventElement.style.right = '2px';
// Fjern stacking styling
eventElement.style.marginLeft = '';
eventElement.style.zIndex = '';
}
eventElement.remove();
// Tæl resterende events
const remainingEvents = container.querySelectorAll('swp-event');
const remainingCount = remainingEvents.length;
// Cleanup hvis tom container
if (remainingCount === 0) {
container.remove();
return true; // Container blev fjernet
}
// Hvis kun ét event tilbage, konvertér tilbage til normal event
if (remainingCount === 1) {
const remainingEvent = remainingEvents[0] as HTMLElement;
// Beregn korrekt top position for remaining event
const remainingStartTime = remainingEvent.dataset.start;
if (remainingStartTime) {
const remainingStartDate = new Date(remainingStartTime);
const gridSettings = { dayStartHour: 6, hourHeight: 80 };
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
// Gendan normal event positioning (absolute for standalone events)
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = `${remainingTop + 1}px`; // +1px som andre events
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
// Fjern eventuel stacking styling
remainingEvent.style.marginLeft = '';
remainingEvent.style.zIndex = '';
}
// Indsæt før container og fjern container
container.parentElement?.insertBefore(remainingEvent, container);
container.remove();
return true; // Container blev fjernet
}
// Altid tjek for stack chain cleanup, uanset wasStacked flag
const removedEventId = eventElement.dataset.eventId;
console.log('Checking stack chain for removed event:', removedEventId, 'Has chain:', this.stackChains.has(removedEventId || ''));
if (removedEventId && this.stackChains.has(removedEventId)) {
console.log('Removing from stack chain:', removedEventId);
const affectedEventIds = this.removeFromStackChain(removedEventId);
console.log('Affected events:', affectedEventIds);
// Opdater margin-left for påvirkede events
affectedEventIds.forEach((affectedId: string) => {
const affectedElement = container.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
console.log('Found affected element:', affectedId, !!affectedElement);
if (affectedElement) {
const chainInfo = this.stackChains.get(affectedId);
if (chainInfo) {
const newMarginLeft = chainInfo.stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
console.log('Updating margin-left for', affectedId, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
affectedElement.style.marginLeft = `${newMarginLeft}px`;
}
}
});
}
return false; // Container blev ikke fjernet
}
/**
* Opret stacked event med margin-left offset
*/
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number = 1): void {
// Brug margin-left i stedet for width manipulation
const marginLeft = stackLevel * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
eventElement.style.marginLeft = `${marginLeft}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.width = '';
eventElement.style.zIndex = this.getNextZIndex().toString();
// Tilføj til stack chain
const eventId = eventElement.dataset.eventId;
const underlyingId = underlyingElement.dataset.eventId;
console.log('STACK CHAIN ADD: Adding', eventId, 'to chain with underlying', underlyingId, 'at stackLevel', stackLevel);
if (eventId && underlyingId) {
// Find sidste event i chain
let lastEventId = underlyingId;
while (this.stackChains.has(lastEventId) && this.stackChains.get(lastEventId)?.next) {
lastEventId = this.stackChains.get(lastEventId)!.next!;
}
console.log('STACK CHAIN ADD: Last event in chain is', lastEventId);
// Link det nye event til chain
if (!this.stackChains.has(lastEventId)) {
this.stackChains.set(lastEventId, { stackLevel: 0 });
console.log('STACK CHAIN ADD: Created chain entry for underlying event', lastEventId);
}
this.stackChains.get(lastEventId)!.next = eventId;
this.stackChains.set(eventId, { prev: lastEventId, stackLevel });
console.log('STACK CHAIN ADD: Linked', lastEventId, '->', eventId);
console.log('STACK CHAIN STATE:', Array.from(this.stackChains.entries()));
}
}
/**
* Fjern stacking styling fra event
*/
public removeStackedStyling(eventElement: HTMLElement): void {
const eventId = eventElement.dataset.eventId;
console.log('removeStackedStyling called for:', eventId);
eventElement.style.marginLeft = '';
eventElement.style.width = '';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = '';
// Fjern fra stack chain og opdater andre events
if (eventId && this.stackChains.has(eventId)) {
console.log('Removing from stack chain and updating affected events:', eventId);
const affectedEventIds = this.removeFromStackChain(eventId);
console.log('Affected events from removeFromStackChain:', affectedEventIds);
// Find den kolonne hvor eventet var placeret
const columnElement = eventElement.closest('swp-events-layer');
if (columnElement) {
console.log('Found column element, updating affected events');
// Opdater margin-left for ALLE resterende events baseret på deres index
affectedEventIds.forEach((affectedId: string, index: number) => {
const affectedElement = columnElement.querySelector(`swp-event[data-event-id="${affectedId}"]`) as HTMLElement;
console.log('Looking for affected element:', affectedId, 'found:', !!affectedElement);
if (affectedElement) {
// Index 0 = 0px margin, index 1 = 15px margin, index 2 = 30px margin, osv.
const newMarginLeft = index * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
console.log('Updating margin-left for', affectedId, 'at index', index, 'from', affectedElement.style.marginLeft, 'to', newMarginLeft + 'px');
affectedElement.style.marginLeft = `${newMarginLeft}px`;
}
});
} else {
console.log('No column element found for updating affected events');
}
}
}
/**
* Fjern event fra stack chain og re-stack resterende events
*/
private removeFromStackChain(eventId: string): string[] {
console.log('STACK CHAIN REMOVE: Removing', eventId, 'from chain');
console.log('STACK CHAIN STATE BEFORE:', Array.from(this.stackChains.entries()));
// Fjern eventet fra chain
this.stackChains.delete(eventId);
// Find ALLE resterende events i stackChains og returner dem
const allRemainingEventIds = Array.from(this.stackChains.keys());
console.log('STACK CHAIN REMOVE: All remaining events to re-stack:', allRemainingEventIds);
// Re-assign stackLevel baseret på position (0 = underlying, 1 = første stacked, osv.)
allRemainingEventIds.forEach((remainingId, index) => {
const chainInfo = this.stackChains.get(remainingId);
if (chainInfo) {
chainInfo.stackLevel = index;
console.log('STACK CHAIN REMOVE: Set stackLevel for', remainingId, 'to', index);
}
});
console.log('STACK CHAIN STATE AFTER:', Array.from(this.stackChains.entries()));
return allRemainingEventIds;
}
/**
* Re-stack events efter fjernelse af et stacked event
*/
private restackRemainingEvents(container: HTMLElement): void {
// Find alle stacked events (events med margin-left)
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => {
const element = el as HTMLElement;
return element.style.marginLeft && element.style.marginLeft !== '0px';
}) as HTMLElement[];
if (stackedEvents.length === 0) return;
// Sort events by current margin-left (ascending)
stackedEvents.sort((a, b) => {
const marginA = parseInt(a.style.marginLeft) || 0;
const marginB = parseInt(b.style.marginLeft) || 0;
return marginA - marginB;
});
// Re-assign margin-left values starting from 15px
stackedEvents.forEach((element, index) => {
const newMarginLeft = (index + 1) * EventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
element.style.marginLeft = `${newMarginLeft}px`;
});
}
/**
* Beregn position for event gruppe
*/
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
if (events.length === 0) return { top: 0, height: 0 };
// Find tidligste start og seneste slut
const startTimes = events.map(e => new Date(e.start).getTime());
const endTimes = events.map(e => new Date(e.end).getTime());
const earliestStart = Math.min(...startTimes);
const latestEnd = Math.max(...endTimes);
// Konvertér til pixel positions (dette skal matches med EventRenderer logik)
const startDate = new Date(earliestStart);
const endDate = new Date(latestEnd);
// Brug samme logik som EventRenderer.calculateEventPosition
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight;
return { top, height };
}
/**
* Get next available z-index for stacked events
*/
private getNextZIndex(): number {
return ++this.nextZIndex;
}
/**
* Reset z-index counter
*/
public resetZIndex(): void {
this.nextZIndex = 100;
}
/**
* Check if element is part of an event group
*/
public isInEventGroup(element: HTMLElement): boolean {
return element.closest('swp-event-group') !== null;
}
/**
* Check if element is a stacked event
*/
public isStackedEvent(element: HTMLElement): boolean {
const eventId = element.dataset.eventId;
const hasMarginLeft = element.style.marginLeft !== '' && element.style.marginLeft !== '0px';
const isInStackChain = eventId ? this.stackChains.has(eventId) : false;
console.log('isStackedEvent check:', eventId, 'hasMarginLeft:', hasMarginLeft, 'isInStackChain:', isInStackChain);
// Et event er stacked hvis det enten har margin-left ELLER er i en stack chain
return hasMarginLeft || isInStackChain;
}
/**
* Get event group container for an event element
*/
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
return eventElement.closest('swp-event-group') as HTMLElement;
}
}

View file

@ -0,0 +1,269 @@
/**
* 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 { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
export interface StackLink {
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 EventGroup {
events: CalendarEvent[];
containerType: 'NONE' | 'GRID' | 'STACKING';
startTime: Date;
}
export class EventStackManager {
private static readonly STACK_OFFSET_PX = 15;
// ============================================
// 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: CalendarEvent[]): EventGroup[] {
if (events.length === 0) return [];
// Get threshold from config
const gridSettings = calendarConfig.getGridSettings();
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
// Sort events by start time
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const groups: EventGroup[] = [];
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: EventGroup): '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: CalendarEvent, event2: CalendarEvent): 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: CalendarEvent[]): Map<string, StackLink> {
const stackLinks = new Map<string, StackLink>();
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: StackLink): string {
return JSON.stringify(stackLink);
}
/**
* Deserialize JSON string to stack link
*/
public deserializeStackLink(json: string): StackLink | null {
try {
return JSON.parse(json);
} catch (e) {
return null;
}
}
/**
* Apply stack link to DOM element
*/
public applyStackLinkToElement(element: HTMLElement, stackLink: StackLink): void {
element.dataset.stackLink = this.serializeStackLink(stackLink);
}
/**
* Get stack link from DOM element
*/
public getStackLinkFromElement(element: HTMLElement): StackLink | 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

@ -9,7 +9,7 @@ import { CoreEvents } from '../constants/CoreEvents';
import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes';
import { GridRenderer } from '../renderers/GridRenderer';
import { GridStyleManager } from '../renderers/GridStyleManager';
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
/**
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
@ -21,19 +21,35 @@ export class GridManager {
private currentView: CalendarView = 'week';
private gridRenderer: GridRenderer;
private styleManager: GridStyleManager;
private eventCleanup: (() => void)[] = [];
private dateService: DateService;
constructor() {
// Initialize GridRenderer and StyleManager with config
this.gridRenderer = new GridRenderer();
this.styleManager = new GridStyleManager();
this.dateService = new DateService('Europe/Copenhagen');
this.init();
}
private init(): void {
this.findElements();
this.subscribeToEvents();
}
/**
* Get the start of the ISO week (Monday) for a given date
*/
private getISOWeekStart(date: Date): Date {
const weekBounds = this.dateService.getWeekBounds(date);
return this.dateService.startOfDay(weekBounds.start);
}
/**
* Get the end of the ISO week (Sunday) for a given date
*/
private getWeekEnd(date: Date): Date {
const weekBounds = this.dateService.getWeekBounds(date);
return this.dateService.endOfDay(weekBounds.end);
}
private findElements(): void {
@ -42,26 +58,20 @@ export class GridManager {
private subscribeToEvents(): void {
// Listen for view changes
this.eventCleanup.push(
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentView = detail.currentView;
this.render();
})
);
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.currentView = detail.currentView;
this.render();
});
// Listen for config changes that affect rendering
this.eventCleanup.push(
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => {
this.render();
})
);
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => {
this.render();
});
this.eventCleanup.push(
eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => {
this.render();
})
);
eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => {
this.render();
});
}
/**
@ -98,7 +108,7 @@ export class GridManager {
this.resourceData
);
// Calculate period range using DateCalculator
// Calculate period range
const periodRange = this.getPeriodRange();
// Get layout config based on current view
@ -117,42 +127,42 @@ export class GridManager {
/**
* Get current period label using DateCalculator
* Get current period label
*/
public getCurrentPeriodLabel(): string {
switch (this.currentView) {
case 'week':
case 'day':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
const weekEnd = DateCalculator.getWeekEnd(this.currentDate);
return DateCalculator.formatDateRange(weekStart, weekEnd);
const weekStart = this.getISOWeekStart(this.currentDate);
const weekEnd = this.getWeekEnd(this.currentDate);
return this.dateService.formatDateRange(weekStart, weekEnd);
case 'month':
return this.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
return this.dateService.formatMonthYear(this.currentDate);
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
const defaultWeekEnd = DateCalculator.getWeekEnd(this.currentDate);
return DateCalculator.formatDateRange(defaultWeekStart, defaultWeekEnd);
const defaultWeekStart = this.getISOWeekStart(this.currentDate);
const defaultWeekEnd = this.getWeekEnd(this.currentDate);
return this.dateService.formatDateRange(defaultWeekStart, defaultWeekEnd);
}
}
/**
* Navigate to next period using DateCalculator
* Navigate to next period
*/
public navigateNext(): void {
let nextDate: Date;
switch (this.currentView) {
case 'week':
nextDate = DateCalculator.addWeeks(this.currentDate, 1);
nextDate = this.dateService.addWeeks(this.currentDate, 1);
break;
case 'month':
nextDate = this.addMonths(this.currentDate, 1);
nextDate = this.dateService.addMonths(this.currentDate, 1);
break;
case 'day':
nextDate = DateCalculator.addDays(this.currentDate, 1);
nextDate = this.dateService.addDays(this.currentDate, 1);
break;
default:
nextDate = DateCalculator.addWeeks(this.currentDate, 1);
nextDate = this.dateService.addWeeks(this.currentDate, 1);
}
this.currentDate = nextDate;
@ -167,23 +177,23 @@ export class GridManager {
}
/**
* Navigate to previous period using DateCalculator
* Navigate to previous period
*/
public navigatePrevious(): void {
let prevDate: Date;
switch (this.currentView) {
case 'week':
prevDate = DateCalculator.addWeeks(this.currentDate, -1);
prevDate = this.dateService.addWeeks(this.currentDate, -1);
break;
case 'month':
prevDate = this.addMonths(this.currentDate, -1);
prevDate = this.dateService.addMonths(this.currentDate, -1);
break;
case 'day':
prevDate = DateCalculator.addDays(this.currentDate, -1);
prevDate = this.dateService.addDays(this.currentDate, -1);
break;
default:
prevDate = DateCalculator.addWeeks(this.currentDate, -1);
prevDate = this.dateService.addWeeks(this.currentDate, -1);
}
this.currentDate = prevDate;
@ -212,20 +222,20 @@ export class GridManager {
}
/**
* Get current view's display dates using DateCalculator
* Get current view's display dates
*/
public getDisplayDates(): Date[] {
switch (this.currentView) {
case 'week':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
return DateCalculator.getFullWeekDates(weekStart);
const weekStart = this.getISOWeekStart(this.currentDate);
return this.dateService.getFullWeekDates(weekStart);
case 'month':
return this.getMonthDates(this.currentDate);
case 'day':
return [this.currentDate];
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
return DateCalculator.getFullWeekDates(defaultWeekStart);
const defaultWeekStart = this.getISOWeekStart(this.currentDate);
return this.dateService.getFullWeekDates(defaultWeekStart);
}
}
@ -235,8 +245,8 @@ export class GridManager {
private getPeriodRange(): { startDate: Date; endDate: Date } {
switch (this.currentView) {
case 'week':
const weekStart = DateCalculator.getISOWeekStart(this.currentDate);
const weekEnd = DateCalculator.getWeekEnd(this.currentDate);
const weekStart = this.getISOWeekStart(this.currentDate);
const weekEnd = this.getWeekEnd(this.currentDate);
return {
startDate: weekStart,
endDate: weekEnd
@ -252,8 +262,8 @@ export class GridManager {
endDate: this.currentDate
};
default:
const defaultWeekStart = DateCalculator.getISOWeekStart(this.currentDate);
const defaultWeekEnd = DateCalculator.getWeekEnd(this.currentDate);
const defaultWeekStart = this.getISOWeekStart(this.currentDate);
const defaultWeekEnd = this.getWeekEnd(this.currentDate);
return {
startDate: defaultWeekStart,
endDate: defaultWeekEnd
@ -264,7 +274,7 @@ export class GridManager {
/**
* Get layout config for current view
*/
private getLayoutConfig(): any {
private getLayoutConfig(): { columnCount: number; type: string } {
switch (this.currentView) {
case 'week':
return {
@ -289,46 +299,22 @@ export class GridManager {
}
}
/**
* Clean up all resources
*/
public destroy(): void {
// Clean up event listeners
this.eventCleanup.forEach(cleanup => cleanup());
this.eventCleanup = [];
// Clear references
this.container = null;
this.resourceData = null;
}
/**
* Helper method to add months to a date
*/
private addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
/**
* Helper method to get month start
*/
private getMonthStart(date: Date): Date {
const result = new Date(date);
result.setDate(1);
result.setHours(0, 0, 0, 0);
return result;
const year = date.getFullYear();
const month = date.getMonth();
return this.dateService.startOfDay(new Date(year, month, 1));
}
/**
* Helper method to get month end
*/
private getMonthEnd(date: Date): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + 1, 0);
result.setHours(23, 59, 59, 999);
return result;
const nextMonth = this.dateService.addMonths(date, 1);
const firstOfNextMonth = this.getMonthStart(nextMonth);
return this.dateService.endOfDay(this.dateService.addDays(firstOfNextMonth, -1));
}
/**
@ -339,8 +325,10 @@ export class GridManager {
const monthStart = this.getMonthStart(date);
const monthEnd = this.getMonthEnd(date);
for (let d = new Date(monthStart); d <= monthEnd; d.setDate(d.getDate() + 1)) {
dates.push(new Date(d));
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;

View file

@ -0,0 +1,158 @@
import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { CoreEvents } from '../constants/CoreEvents';
import { HeaderRenderContext } from '../renderers/HeaderRenderer';
import { ResourceCalendarData } from '../types/CalendarTypes';
import { DragMouseEnterHeaderEventPayload, DragMouseLeaveHeaderEventPayload, HeaderReadyEventPayload } from '../types/EventTypes';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
/**
* HeaderManager - Handles all header-related event logic
* Separates event handling from rendering concerns
*/
export class HeaderManager {
// Event listeners for drag events
private dragMouseEnterHeaderListener: ((event: Event) => void) | null = null;
private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null;
constructor() {
// Bind methods for event listeners
this.setupHeaderDragListeners = this.setupHeaderDragListeners.bind(this);
// Listen for navigation events to update header
this.setupNavigationListener();
}
/**
* Initialize header with initial date
*/
public initializeHeader(currentDate: Date, resourceData: ResourceCalendarData | null = null): void {
this.updateHeader(currentDate, resourceData);
}
/**
* Get cached calendar header element
*/
private getCalendarHeader(): HTMLElement | null {
return document.querySelector('swp-calendar-header');
}
/**
* Setup header drag event listeners - REFACTORED to listen to DragDropManager events
*/
public setupHeaderDragListeners(): void {
console.log('🎯 HeaderManager: Setting up drag event listeners');
// Create and store event listeners
this.dragMouseEnterHeaderListener = (event: Event) => {
const { targetColumn: targetDate, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).detail;
console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
targetDate,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
if (targetDate) {
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
}
};
this.dragMouseLeaveHeaderListener = (event: Event) => {
const { targetDate, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<DragMouseLeaveHeaderEventPayload>).detail;
console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
targetDate,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
eventBus.emit('header:mouseleave', {
element: this.getCalendarHeader(),
targetDate,
originalElement,
cloneElement
});
};
// Listen for drag events from DragDropManager
eventBus.on('drag:mouseenter-header', this.dragMouseEnterHeaderListener);
eventBus.on('drag:mouseleave-header', this.dragMouseLeaveHeaderListener);
console.log('✅ HeaderManager: Drag event listeners attached');
}
/**
* Setup navigation event listener
*/
private setupNavigationListener(): void {
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => {
const { currentDate, resourceData } = (event as CustomEvent).detail;
this.updateHeader(currentDate, resourceData);
});
// Also listen for date changes (including initial setup)
eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate, null);
});
// Listen for workweek header updates after grid rebuild
eventBus.on('workweek:header-update', (event) => {
const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate, null);
});
}
/**
* Update header content for navigation
*/
private updateHeader(currentDate: Date, resourceData: ResourceCalendarData | null = null): void {
const calendarHeader = this.getOrCreateCalendarHeader();
if (!calendarHeader) return;
// Clear existing content
calendarHeader.innerHTML = '';
// Render new header content
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
const context: HeaderRenderContext = {
currentWeek: currentDate,
config: calendarConfig,
resourceData: resourceData
};
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: HeaderReadyEventPayload = {
headerElements: ColumnDetectionUtils.getHeaderColumns(),
};
eventBus.emit('header:ready', payload);
}
/**
* Get calendar header element - header always exists now
*/
private getOrCreateCalendarHeader(): HTMLElement | null {
const calendarHeader = this.getCalendarHeader();
if (!calendarHeader) {
console.warn('HeaderManager: Calendar header not found - should always exist now!');
return null;
}
return calendarHeader;
}
}

View file

@ -1,9 +1,10 @@
import { IEventBus } from '../types/CalendarTypes.js';
import { EventRenderingService } from '../renderers/EventRendererManager.js';
import { DateCalculator } from '../utils/DateCalculator.js';
import { CoreEvents } from '../constants/CoreEvents.js';
import { NavigationRenderer } from '../renderers/NavigationRenderer.js';
import { calendarConfig } from '../core/CalendarConfig.js';
import { IEventBus } from '../types/CalendarTypes';
import { EventRenderingService } from '../renderers/EventRendererManager';
import { DateService } from '../utils/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { NavigationRenderer } from '../renderers/NavigationRenderer';
import { GridRenderer } from '../renderers/GridRenderer';
import { calendarConfig } from '../core/CalendarConfig';
/**
* NavigationManager handles calendar navigation (prev/next/today buttons)
@ -12,59 +13,43 @@ import { calendarConfig } from '../core/CalendarConfig.js';
export class NavigationManager {
private eventBus: IEventBus;
private navigationRenderer: NavigationRenderer;
private dateCalculator: DateCalculator;
private gridRenderer: GridRenderer;
private dateService: DateService;
private currentWeek: Date;
private targetWeek: Date;
private animationQueue: number = 0;
// Cached DOM elements to avoid redundant queries
private cachedCalendarContainer: HTMLElement | null = null;
private cachedCurrentGrid: HTMLElement | null = null;
constructor(eventBus: IEventBus, eventRenderer: EventRenderingService) {
this.eventBus = eventBus;
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
this.dateService = new DateService('Europe/Copenhagen');
this.navigationRenderer = new NavigationRenderer(eventBus, eventRenderer);
this.currentWeek = DateCalculator.getISOWeekStart(new Date());
this.gridRenderer = new GridRenderer();
this.currentWeek = this.getISOWeekStart(new Date());
this.targetWeek = new Date(this.currentWeek);
this.init();
}
private init(): void {
this.setupEventListeners();
// Don't update week info immediately - wait for DOM to be ready
}
/**
* Get cached calendar container element
* 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 getCalendarContainer(): HTMLElement | null {
if (!this.cachedCalendarContainer) {
this.cachedCalendarContainer = document.querySelector('swp-calendar-container');
}
return this.cachedCalendarContainer;
return document.querySelector('swp-calendar-container');
}
/**
* Get cached current grid element
*/
private getCurrentGrid(): HTMLElement | null {
const container = this.getCalendarContainer();
if (!container) return null;
if (!this.cachedCurrentGrid) {
this.cachedCurrentGrid = container.querySelector('swp-grid-container:not([data-prerendered])');
}
return this.cachedCurrentGrid;
}
/**
* Clear cached DOM elements (call when DOM structure changes)
*/
private clearCache(): void {
this.cachedCalendarContainer = null;
this.cachedCurrentGrid = null;
return document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])');
}
private setupEventListeners(): void {
@ -108,11 +93,16 @@ export class NavigationManager {
// Validate date before processing
if (!dateFromEvent) {
console.warn('NavigationManager: No date provided in DATE_CHANGED event');
return;
}
const targetDate = new Date(dateFromEvent);
if (isNaN(targetDate.getTime())) {
// Use DateService validation
const validation = this.dateService.validateDate(targetDate);
if (!validation.valid) {
console.warn('NavigationManager: Invalid date received:', validation.error);
return;
}
@ -137,7 +127,7 @@ export class NavigationManager {
* Navigate to specific event date and emit scroll event after navigation
*/
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
const weekStart = DateCalculator.getISOWeekStart(eventDate);
const weekStart = this.getISOWeekStart(eventDate);
this.targetWeek = new Date(weekStart);
const currentTime = this.currentWeek.getTime();
@ -168,14 +158,14 @@ export class NavigationManager {
}
private navigateToPreviousWeek(): void {
this.targetWeek.setDate(this.targetWeek.getDate() - 7);
this.targetWeek = this.dateService.addWeeks(this.targetWeek, -1);
const weekToShow = new Date(this.targetWeek);
this.animationQueue++;
this.animateTransition('prev', weekToShow);
}
private navigateToNextWeek(): void {
this.targetWeek.setDate(this.targetWeek.getDate() + 7);
this.targetWeek = this.dateService.addWeeks(this.targetWeek, 1);
const weekToShow = new Date(this.targetWeek);
this.animationQueue++;
this.animateTransition('next', weekToShow);
@ -183,7 +173,7 @@ export class NavigationManager {
private navigateToToday(): void {
const today = new Date();
const todayWeekStart = DateCalculator.getISOWeekStart(today);
const todayWeekStart = this.getISOWeekStart(today);
// Reset to today
this.targetWeek = new Date(todayWeekStart);
@ -201,7 +191,7 @@ export class NavigationManager {
}
private navigateToDate(date: Date): void {
const weekStart = DateCalculator.getISOWeekStart(date);
const weekStart = this.getISOWeekStart(date);
this.targetWeek = new Date(weekStart);
const currentTime = this.currentWeek.getTime();
@ -227,11 +217,20 @@ export class NavigationManager {
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);
// Always create a fresh container for consistent behavior
newGrid = this.navigationRenderer.renderContainer(container, targetWeek);
newGrid = this.gridRenderer.createNavigationGrid(container, targetWeek);
console.groupEnd();
// Clear any existing transforms before animation
@ -270,9 +269,6 @@ export class NavigationManager {
newGrid.style.position = 'relative';
newGrid.removeAttribute('data-prerendered');
// Clear cache since DOM structure changed
this.clearCache();
// Update state
this.currentWeek = new Date(targetWeek);
this.animationQueue--;
@ -288,16 +284,16 @@ export class NavigationManager {
// Emit period change event for ScrollManager
this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, {
direction,
weekStart: this.currentWeek
currentDate: this.currentWeek
});
});
}
private updateWeekInfo(): void {
const weekNumber = DateCalculator.getWeekNumber(this.currentWeek);
const weekEnd = DateCalculator.addDays(this.currentWeek, 6);
const dateRange = DateCalculator.formatDateRange(this.currentWeek, weekEnd);
const weekNumber = this.dateService.getWeekNumber(this.currentWeek);
const weekEnd = this.dateService.addDays(this.currentWeek, 6);
const dateRange = this.dateService.formatDateRange(this.currentWeek, weekEnd);
// Notify other managers about week info update - DOM manipulation should happen via events
this.eventBus.emit(CoreEvents.PERIOD_INFO_UPDATE, {
@ -338,5 +334,4 @@ export class NavigationManager {
this.updateWeekInfo();
}
// Rendering methods moved to NavigationRenderer for better separation of concerns
}

View file

@ -0,0 +1,259 @@
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
import { ResizeEndEventPayload } from '../types/EventTypes';
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
export class ResizeHandleManager {
private cachedEvents: SwpEventEl[] = [];
private isResizing = false;
private targetEl: SwpEventEl | null = null;
// Resize zone tracking (like DragDropManager hover tracking)
private isResizeZoneTrackingActive = false;
private currentTrackedEvent: SwpEventEl | null = null;
private startY = 0;
private startDurationMin = 0;
private direction: 'grow' | 'shrink' = 'grow';
private hourHeightPx: number;
private snapMin: number;
private minDurationMin: number;
private animationId: number | null = null;
private currentHeight = 0;
private targetHeight = 0;
// cleanup
private unsubscribers: Array<() => void> = [];
private pointerCaptured = false;
private prevZ?: string;
constructor() {
const grid = calendarConfig.getGridSettings();
this.hourHeightPx = grid.hourHeight;
this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin; // Use snap interval as minimum duration
}
public initialize(): void {
this.refreshEventCache();
this.attachHandles();
this.attachGlobalListeners();
this.subToBus();
}
public destroy(): void {
document.removeEventListener('pointerdown', this.onPointerDown, true);
document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true);
this.unsubscribers.forEach(u => u());
}
private minutesPerPx(): number {
return 60 / this.hourHeightPx;
}
private pxFromMinutes(min: number): number {
return (min / 60) * this.hourHeightPx;
}
private roundSnap(min: number, dir: 'grow' | 'shrink'): number {
const q = min / this.snapMin;
return (dir === 'grow' ? Math.ceil(q) : Math.floor(q)) * this.snapMin;
}
private refreshEventCache(): void {
this.cachedEvents = Array.from(
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
);
}
private attachHandles(): void {
// ensure a single handle per event
this.cachedEvents.forEach(el => {
if (!el.querySelector(':scope > swp-resize-handle')) {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
el.appendChild(handle);
}
});
}
private attachGlobalListeners(): void {
// Use same pattern as DragDropManager - mouseenter to activate tracking
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseenter', (e) => {
const target = e.target as HTMLElement;
const eventElement = target.closest<SwpEventEl>('swp-event');
if (eventElement && !this.isResizing) {
this.isResizeZoneTrackingActive = true;
this.currentTrackedEvent = eventElement;
}
}, true); // Capture phase
}
document.addEventListener('pointerdown', this.onPointerDown, true);
document.addEventListener('pointermove', this.onPointerMove, true);
document.addEventListener('pointerup', this.onPointerUp, true);
}
private subToBus(): void {
const sub = (ev: string, fn: () => void) => {
eventBus.on(ev, fn);
this.unsubscribers.push(() => eventBus.off(ev, fn));
};
const refresh = () => { this.refreshEventCache(); this.attachHandles(); };
[CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED,
CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED,
CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh));
}
private checkResizeZone(e: PointerEvent): void {
if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return;
const rect = this.currentTrackedEvent.getBoundingClientRect();
const mouseX = e.clientX;
const mouseY = e.clientY;
// Check if mouse is still within event bounds
const isInBounds = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
if (!isInBounds) {
// Mouse left event - deactivate tracking
this.hideResizeIndicator(this.currentTrackedEvent);
this.isResizeZoneTrackingActive = false;
this.currentTrackedEvent = null;
return;
}
// Check if in resize zone (bottom 15px)
const distanceFromBottom = rect.bottom - mouseY;
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= 15;
if (isInResizeZone) {
this.showResizeIndicator(this.currentTrackedEvent);
} else {
this.hideResizeIndicator(this.currentTrackedEvent);
}
}
private showResizeIndicator(el: SwpEventEl): void {
el.setAttribute('data-resize-hover', 'true');
}
private hideResizeIndicator(el: SwpEventEl): void {
el.removeAttribute('data-resize-hover');
}
private onPointerDown = (e: PointerEvent) => {
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
if (!handle) return;
const el = handle.parentElement as SwpEventEl;
this.targetEl = el;
this.isResizing = true;
this.startY = e.clientY;
// udled start-varighed fra højde
const startHeight = el.offsetHeight;
this.startDurationMin = Math.max(
this.minDurationMin,
Math.round(startHeight * this.minutesPerPx())
);
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex;
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000';
(e.target as Element).setPointerCapture?.(e.pointerId);
this.pointerCaptured = true;
document.documentElement.classList.add('swp--resizing');
e.preventDefault();
};
private onPointerMove = (e: PointerEvent) => {
// Check resize zone if not resizing
if (!this.isResizing) {
this.checkResizeZone(e);
return;
}
// Continue with resize logic
if (!this.targetEl) return;
const dy = e.clientY - this.startY;
this.direction = dy >= 0 ? 'grow' : 'shrink';
// Calculate raw height from pixel delta (no snapping - 100% smooth like drag & drop)
const startHeight = this.pxFromMinutes(this.startDurationMin);
const rawHeight = startHeight + dy;
const minHeight = this.pxFromMinutes(this.minDurationMin);
this.targetHeight = Math.max(minHeight, rawHeight); // Raw height, no snap
if (this.animationId == null) {
this.currentHeight = this.targetEl.offsetHeight;
this.animate();
}
};
private animate = () => {
if (!this.isResizing || !this.targetEl) { this.animationId = null; return; }
const diff = this.targetHeight - this.currentHeight;
if (Math.abs(diff) > 0.5) {
this.currentHeight += diff * 0.35;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = requestAnimationFrame(this.animate);
} else {
this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
}
};
private onPointerUp = (e: PointerEvent) => {
if (!this.isResizing || !this.targetEl) return;
if (this.animationId != null) cancelAnimationFrame(this.animationId);
this.animationId = null;
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
const currentHeight = this.targetEl.offsetHeight;
const snapDistancePx = this.pxFromMinutes(this.snapMin);
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
const minHeight = this.pxFromMinutes(this.minDurationMin);
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer
this.targetEl.updateHeight?.(finalHeight);
// Emit resize:end event for re-stacking
const eventId = this.targetEl.dataset.eventId || '';
const resizeEndPayload: ResizeEndEventPayload = {
eventId,
element: this.targetEl,
finalHeight
};
eventBus.emit('resize:end', resizeEndPayload);
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
group.style.zIndex = this.prevZ ?? '';
this.prevZ = undefined;
this.isResizing = false;
this.targetEl = null;
if (this.pointerCaptured) {
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
this.pointerCaptured = false;
}
document.documentElement.classList.remove('swp--resizing');
this.refreshEventCache();
};
}

View file

@ -3,6 +3,7 @@
import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig';
import { CoreEvents } from '../constants/CoreEvents';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Manages scrolling functionality for the calendar using native scrollbars
@ -41,6 +42,16 @@ export class ScrollManager {
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();
@ -96,13 +107,12 @@ export class ScrollManager {
}
/**
* Scroll to specific hour
* Scroll to specific hour using PositionUtils
*/
scrollToHour(hour: number): void {
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const scrollTop = (hour - dayStartHour) * hourHeight;
// Create time string for the hour
const timeString = `${hour.toString().padStart(2, '0')}:00`;
const scrollTop = PositionUtils.timeToPixels(timeString);
this.scrollTo(scrollTop);
}
@ -246,13 +256,4 @@ export class ScrollManager {
}
}
/**
* Cleanup resources
*/
destroy(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
}

View file

@ -1,558 +0,0 @@
/**
* SimpleEventOverlapManager - Clean, focused overlap management
* Eliminates complex state tracking in favor of direct DOM manipulation
*/
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
export enum OverlapType {
NONE = 'none',
COLUMN_SHARING = 'column_sharing',
STACKING = 'stacking'
}
export interface OverlapGroup {
type: OverlapType;
events: CalendarEvent[];
position: { top: number; height: number };
}
export interface StackLink {
prev?: string; // Event ID of previous event in stack
next?: string; // Event ID of next event in stack
stackLevel: number; // 0 = base event, 1 = first stacked, etc
}
export class SimpleEventOverlapManager {
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
/**
* Detect overlap type between two events - simplified logic
*/
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
if (!this.eventsOverlapInTime(event1, event2)) {
return OverlapType.NONE;
}
const timeDiffMinutes = Math.abs(
new Date(event1.start).getTime() - new Date(event2.start).getTime()
) / (1000 * 60);
return timeDiffMinutes > SimpleEventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES
? OverlapType.STACKING
: OverlapType.COLUMN_SHARING;
}
/**
* Simple time overlap check
*/
private eventsOverlapInTime(event1: CalendarEvent, event2: CalendarEvent): boolean {
const start1 = new Date(event1.start).getTime();
const end1 = new Date(event1.end).getTime();
const start2 = new Date(event2.start).getTime();
const end2 = new Date(event2.end).getTime();
return !(end1 <= start2 || end2 <= start1);
}
/**
* Group overlapping events - much cleaner algorithm
*/
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
const groups: OverlapGroup[] = [];
const processed = new Set<string>();
for (const event of events) {
if (processed.has(event.id)) continue;
// Find all events that overlap with this one
const overlapping = events.filter(other => {
if (processed.has(other.id)) return false;
return other.id === event.id || this.detectOverlap(event, other) !== OverlapType.NONE;
});
// Mark all as processed
overlapping.forEach(e => processed.add(e.id));
// Determine group type
const overlapType = overlapping.length > 1
? this.detectOverlap(overlapping[0], overlapping[1])
: OverlapType.NONE;
groups.push({
type: overlapType,
events: overlapping,
position: this.calculateGroupPosition(overlapping)
});
}
return groups;
}
/**
* Create flexbox container for column sharing - clean and simple
*/
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
const container = document.createElement('swp-event-group');
container.style.cssText = `
position: absolute;
top: ${position.top}px;
left: 2px;
right: 2px;
display: flex;
gap: 2px;
`;
return container;
}
/**
* Add event to flexbox group - simple relative positioning
*/
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
// Set duration-based height
const duration = eventElement.dataset.duration;
if (duration) {
const durationMinutes = parseInt(duration);
const gridSettings = calendarConfig.getGridSettings();
const height = (durationMinutes / 60) * gridSettings.hourHeight;
eventElement.style.height = `${height - 3}px`;
}
// Flexbox styling
eventElement.style.position = 'relative';
eventElement.style.flex = '1';
eventElement.style.minWidth = '50px';
container.appendChild(eventElement);
}
/**
* Create stacked event with data-attribute tracking
*/
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void {
const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
// Apply visual styling
eventElement.style.marginLeft = `${marginLeft}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = `${100 + stackLevel}`;
// Set up stack linking via data attributes
const eventId = eventElement.dataset.eventId;
const underlyingId = underlyingElement.dataset.eventId;
if (!eventId || !underlyingId) {
console.warn('Missing event IDs for stack linking:', eventId, underlyingId);
return;
}
// Find the last event in the stack chain
let lastElement = underlyingElement;
let lastLink = this.getStackLink(lastElement);
// If underlying doesn't have stack link yet, create it
if (!lastLink) {
this.setStackLink(lastElement, { stackLevel: 0 });
lastLink = { stackLevel: 0 };
}
// Traverse to find the end of the chain
while (lastLink?.next) {
const nextElement = this.findElementById(lastLink.next);
if (!nextElement) break;
lastElement = nextElement;
lastLink = this.getStackLink(lastElement);
}
// Link the new event to the end of the chain
const lastElementId = lastElement.dataset.eventId!;
this.setStackLink(lastElement, {
...lastLink!,
next: eventId
});
this.setStackLink(eventElement, {
prev: lastElementId,
stackLevel: stackLevel
});
}
/**
* Remove stacked styling with proper stack re-linking
*/
public removeStackedStyling(eventElement: HTMLElement): void {
// Clear visual styling
eventElement.style.marginLeft = '';
eventElement.style.zIndex = '';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
// Handle stack chain re-linking
const link = this.getStackLink(eventElement);
if (link) {
// Re-link prev and next events
if (link.prev && link.next) {
// Middle element - link prev to next
const prevElement = this.findElementById(link.prev);
const nextElement = this.findElementById(link.next);
if (prevElement && nextElement) {
const prevLink = this.getStackLink(prevElement);
const nextLink = this.getStackLink(nextElement);
// CRITICAL: Check if prev and next actually overlap without the middle element
const actuallyOverlap = this.checkPixelOverlap(prevElement, nextElement);
if (!actuallyOverlap) {
// CHAIN BREAKING: prev and next don't overlap - break the chain
console.log('Breaking stack chain - events do not overlap directly');
// Prev element: remove next link (becomes end of its own chain)
this.setStackLink(prevElement, {
...prevLink!,
next: undefined
});
// Next element: becomes standalone (remove all stack links and styling)
this.setStackLink(nextElement, null);
nextElement.style.marginLeft = '';
nextElement.style.zIndex = '';
// If next element had subsequent events, they also become standalone
if (nextLink?.next) {
let subsequentId: string | undefined = nextLink.next;
while (subsequentId) {
const subsequentElement = this.findElementById(subsequentId);
if (!subsequentElement) break;
const subsequentLink = this.getStackLink(subsequentElement);
this.setStackLink(subsequentElement, null);
subsequentElement.style.marginLeft = '';
subsequentElement.style.zIndex = '';
subsequentId = subsequentLink?.next;
}
}
} else {
// NORMAL STACKING: they overlap, maintain the chain
this.setStackLink(prevElement, {
...prevLink!,
next: link.next
});
const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1;
this.setStackLink(nextElement, {
...nextLink!,
prev: link.prev,
stackLevel: correctStackLevel
});
// Update visual styling to match new stackLevel
const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
nextElement.style.marginLeft = `${marginLeft}px`;
nextElement.style.zIndex = `${100 + correctStackLevel}`;
}
}
} else if (link.prev) {
// Last element - remove next link from prev
const prevElement = this.findElementById(link.prev);
if (prevElement) {
const prevLink = this.getStackLink(prevElement);
this.setStackLink(prevElement, {
...prevLink!,
next: undefined
});
}
} else if (link.next) {
// First element - remove prev link from next
const nextElement = this.findElementById(link.next);
if (nextElement) {
const nextLink = this.getStackLink(nextElement);
this.setStackLink(nextElement, {
...nextLink!,
prev: undefined,
stackLevel: 0 // Next becomes the base event
});
}
}
// Only update subsequent stack levels if we didn't break the chain
if (link.prev && link.next) {
const nextElement = this.findElementById(link.next);
const nextLink = nextElement ? this.getStackLink(nextElement) : null;
// If next element still has a stack link, the chain wasn't broken
if (nextLink && nextLink.next) {
this.updateSubsequentStackLevels(nextLink.next, -1);
}
// If nextLink is null, chain was broken - no subsequent updates needed
} else {
// First or last removal - update all subsequent
this.updateSubsequentStackLevels(link.next, -1);
}
// Clear this element's stack link
this.setStackLink(eventElement, null);
}
}
/**
* Update stack levels for all events following a given event ID
*/
private updateSubsequentStackLevels(startEventId: string | undefined, levelDelta: number): void {
let currentId = startEventId;
while (currentId) {
const currentElement = this.findElementById(currentId);
if (!currentElement) break;
const currentLink = this.getStackLink(currentElement);
if (!currentLink) break;
// Update stack level
const newLevel = Math.max(0, currentLink.stackLevel + levelDelta);
this.setStackLink(currentElement, {
...currentLink,
stackLevel: newLevel
});
// Update visual styling
const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
currentElement.style.marginLeft = `${marginLeft}px`;
currentElement.style.zIndex = `${100 + newLevel}`;
currentId = currentLink.next;
}
}
/**
* Check if element is stacked - check both style and data-stack-link
*/
public isStackedEvent(element: HTMLElement): boolean {
const marginLeft = element.style.marginLeft;
const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px';
const hasStackLink = this.getStackLink(element) !== null;
return hasMarginLeft || hasStackLink;
}
/**
* Remove event from group with proper cleanup
*/
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
if (!eventElement) return false;
// Calculate correct absolute position for standalone event
const startTime = eventElement.dataset.start;
if (startTime) {
const startDate = new Date(startTime);
const gridSettings = calendarConfig.getGridSettings();
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
// Convert back to absolute positioning
eventElement.style.position = 'absolute';
eventElement.style.top = `${top + 1}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.flex = '';
eventElement.style.minWidth = '';
}
eventElement.remove();
// Handle remaining events
const remainingEvents = container.querySelectorAll('swp-event');
const remainingCount = remainingEvents.length;
if (remainingCount === 0) {
container.remove();
return true;
}
if (remainingCount === 1) {
const remainingEvent = remainingEvents[0] as HTMLElement;
// Convert last event back to absolute positioning
const remainingStartTime = remainingEvent.dataset.start;
if (remainingStartTime) {
const remainingStartDate = new Date(remainingStartTime);
const gridSettings = calendarConfig.getGridSettings();
const remainingStartMinutes = remainingStartDate.getHours() * 60 + remainingStartDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const remainingTop = ((remainingStartMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = `${remainingTop + 1}px`;
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
remainingEvent.style.flex = '';
remainingEvent.style.minWidth = '';
}
container.parentElement?.insertBefore(remainingEvent, container);
container.remove();
return true;
}
return false;
}
/**
* Restack events in container - respects separate stack chains
*/
public restackEventsInContainer(container: HTMLElement): void {
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => this.isStackedEvent(el as HTMLElement)) as HTMLElement[];
if (stackedEvents.length === 0) return;
// Group events by their stack chains
const processedEventIds = new Set<string>();
const stackChains: HTMLElement[][] = [];
for (const element of stackedEvents) {
const eventId = element.dataset.eventId;
if (!eventId || processedEventIds.has(eventId)) continue;
// Find the root of this stack chain (stackLevel 0 or no prev link)
let rootElement = element;
let rootLink = this.getStackLink(rootElement);
while (rootLink?.prev) {
const prevElement = this.findElementById(rootLink.prev);
if (!prevElement) break;
rootElement = prevElement;
rootLink = this.getStackLink(rootElement);
}
// Collect all elements in this chain
const chain: HTMLElement[] = [];
let currentElement = rootElement;
while (currentElement) {
chain.push(currentElement);
processedEventIds.add(currentElement.dataset.eventId!);
const currentLink = this.getStackLink(currentElement);
if (!currentLink?.next) break;
const nextElement = this.findElementById(currentLink.next);
if (!nextElement) break;
currentElement = nextElement;
}
if (chain.length > 1) { // Only add chains with multiple events
stackChains.push(chain);
}
}
// Re-stack each chain separately
stackChains.forEach(chain => {
chain.forEach((element, index) => {
const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
element.style.marginLeft = `${marginLeft}px`;
element.style.zIndex = `${100 + index}`;
// Update the data-stack-link with correct stackLevel
const link = this.getStackLink(element);
if (link) {
this.setStackLink(element, {
...link,
stackLevel: index
});
}
});
});
}
/**
* Calculate position for group - simplified calculation
*/
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
if (events.length === 0) return { top: 0, height: 0 };
const times = events.flatMap(e => [
new Date(e.start).getTime(),
new Date(e.end).getTime()
]);
const earliestStart = Math.min(...times);
const latestEnd = Math.max(...times);
const startDate = new Date(earliestStart);
const endDate = new Date(latestEnd);
const gridSettings = calendarConfig.getGridSettings();
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight;
return { top, height };
}
/**
* Utility methods - simple DOM traversal
*/
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
return eventElement.closest('swp-event-group') as HTMLElement;
}
public isInEventGroup(element: HTMLElement): boolean {
return this.getEventGroup(element) !== null;
}
/**
* Helper methods for data-attribute based stack tracking
*/
public getStackLink(element: HTMLElement): StackLink | null {
const linkData = element.dataset.stackLink;
if (!linkData) return null;
try {
return JSON.parse(linkData);
} catch (e) {
console.warn('Failed to parse stack link data:', linkData, e);
return null;
}
}
private setStackLink(element: HTMLElement, link: StackLink | null): void {
if (link === null) {
delete element.dataset.stackLink;
} else {
element.dataset.stackLink = JSON.stringify(link);
}
}
private findElementById(eventId: string): HTMLElement | null {
return document.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
}
/**
* Check if two elements overlap in pixel space
*/
private checkPixelOverlap(element1: HTMLElement, element2: HTMLElement): boolean {
if (!element1 || !element2) return false;
const top1 = parseFloat(element1.style.top) || 0;
const height1 = parseFloat(element1.style.height) || 0;
const bottom1 = top1 + height1;
const top2 = parseFloat(element2.style.top) || 0;
const height2 = parseFloat(element2.style.height) || 0;
const bottom2 = top2 + height2;
// Add tolerance for small gaps (borders, etc)
const tolerance = 2;
return !(bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance));
}
}

View file

@ -10,7 +10,6 @@ import { CoreEvents } from '../constants/CoreEvents';
export class ViewManager {
private eventBus: IEventBus;
private currentView: CalendarView = 'week';
private eventCleanup: (() => void)[] = [];
private buttonListeners: Map<Element, EventListener> = new Map();
// Cached DOM elements for performance
@ -39,20 +38,16 @@ export class ViewManager {
* Setup event bus listeners with proper cleanup tracking
*/
private setupEventBusListeners(): void {
this.eventCleanup.push(
this.eventBus.on(CoreEvents.INITIALIZED, () => {
this.initializeView();
})
);
this.eventBus.on(CoreEvents.INITIALIZED, () => {
this.initializeView();
});
// Remove redundant VIEW_CHANGED listener that causes circular calls
// changeView is called directly from button handlers
this.eventCleanup.push(
this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
this.refreshCurrentView();
})
);
this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
this.refreshCurrentView();
});
}
/**
@ -150,8 +145,8 @@ export class ViewManager {
// Update button states using cached elements
this.updateAllButtons();
// Trigger a calendar refresh to apply the new workweek
this.eventBus.emit(CoreEvents.REFRESH_REQUESTED);
// Trigger a workweek change to apply the new workweek
this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED);
}
/**
@ -222,23 +217,4 @@ export class ViewManager {
this.refreshCurrentView();
}
/**
* Clean up all resources and cached elements
*/
public destroy(): void {
// Clean up event bus listeners
this.eventCleanup.forEach(cleanup => cleanup());
this.eventCleanup = [];
// Clean up button listeners
this.buttonListeners.forEach((handler, button) => {
button.removeEventListener('click', handler);
});
this.buttonListeners.clear();
// Clear cached elements
this.cachedViewButtons = null;
this.cachedWorkweekButtons = null;
this.lastButtonCacheTime = 0;
}
}

View file

@ -1,7 +1,8 @@
// Work hours management for per-column scheduling
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
import { calendarConfig } from '../core/CalendarConfig';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Work hours for a specific day
@ -33,12 +34,12 @@ export interface WorkScheduleConfig {
* Manages work hours scheduling with weekly defaults and date-specific overrides
*/
export class WorkHoursManager {
private dateCalculator: DateCalculator;
private dateService: DateService;
private workSchedule: WorkScheduleConfig;
constructor() {
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
// Default work schedule - will be loaded from JSON later
this.workSchedule = {
@ -63,7 +64,7 @@ export class WorkHoursManager {
* Get work hours for a specific date
*/
getWorkHoursForDate(date: Date): DayWorkHours | 'off' {
const dateString = DateCalculator.formatISODate(date);
const dateString = this.dateService.formatISODate(date);
// Check for date-specific override first
if (this.workSchedule.dateOverrides[dateString]) {
@ -82,7 +83,7 @@ export class WorkHoursManager {
const workHoursMap = new Map<string, DayWorkHours | 'off'>();
dates.forEach(date => {
const dateString = DateCalculator.formatISODate(date);
const dateString = this.dateService.formatISODate(date);
const workHours = this.getWorkHoursForDate(date);
workHoursMap.set(dateString, workHours);
});
@ -91,7 +92,7 @@ export class WorkHoursManager {
}
/**
* Calculate CSS custom properties for non-work hour overlays (before and after work)
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
*/
calculateNonWorkHoursStyle(workHours: DayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
if (workHours === 'off') {
@ -100,7 +101,6 @@ export class WorkHoursManager {
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const dayEndHour = gridSettings.dayEndHour;
const hourHeight = gridSettings.hourHeight;
// Before work: from day start to work start
@ -116,21 +116,21 @@ export class WorkHoursManager {
}
/**
* Calculate CSS custom properties for work hours overlay (legacy - for backward compatibility)
* Calculate CSS custom properties for work hours overlay using PositionUtils
*/
calculateWorkHoursStyle(workHours: DayWorkHours | 'off'): { top: number; height: number } | null {
if (workHours === 'off') {
return null;
}
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// 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`;
const top = (workHours.start - dayStartHour) * hourHeight;
const height = (workHours.end - workHours.start) * hourHeight;
// Use PositionUtils for consistent position calculation
const position = PositionUtils.calculateEventPosition(startTime, endTime);
return { top, height };
return { top: position.top, height: position.height };
}
/**

View file

@ -0,0 +1,128 @@
import { CalendarEvent } from '../types/CalendarTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import { EventLayout } from '../utils/AllDayLayoutEngine';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { EventManager } from '../managers/EventManager';
import { DragStartEventPayload } from '../types/EventTypes';
import { EventRendererStrategy } 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: DragStartEventPayload): void {
this.originalEvent = payload.draggedElement;;
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: CalendarEvent,
layout: EventLayout
) {
const container = this.getContainer();
if (!container) return null;
const dayEvent = SwpAllDayEventElement.fromCalendarEvent(event);
dayEvent.applyGridPositioning(layout.row, layout.startColumn, layout.endColumn);
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-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: EventLayout[]): 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-event').forEach(event => event.remove());
}
}
public handleViewChanged(event: CustomEvent): void {
this.clearAllDayEvents();
}
}

View file

@ -2,7 +2,7 @@
import { CalendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes';
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
import { WorkHoursManager } from '../managers/WorkHoursManager';
/**
@ -25,25 +25,26 @@ export interface ColumnRenderContext {
* Date-based column renderer (original functionality)
*/
export class DateColumnRenderer implements ColumnRenderer {
private dateCalculator!: DateCalculator;
private dateService!: DateService;
private workHoursManager!: WorkHoursManager;
render(columnContainer: HTMLElement, context: ColumnRenderContext): void {
const { currentWeek, config } = context;
// Initialize date calculator and work hours manager
DateCalculator.initialize(config);
this.dateCalculator = new DateCalculator();
// Initialize date service and work hours manager
const timezone = config.getTimezone?.() || 'Europe/Copenhagen';
this.dateService = new DateService(timezone);
this.workHoursManager = new WorkHoursManager();
const dates = DateCalculator.getWorkWeekDates(currentWeek);
const workWeekSettings = config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(currentWeek, workWeekSettings.workDays);
const dateSettings = config.getDateViewSettings();
const daysToShow = dates.slice(0, dateSettings.weekDays);
daysToShow.forEach((date) => {
const column = document.createElement('swp-day-column');
(column as any).dataset.date = DateCalculator.formatISODate(date);
(column as any).dataset.date = this.dateService.formatISODate(date);
// Apply work hours styling
this.applyWorkHoursToColumn(column, date);

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,10 @@ import { calendarConfig } from '../core/CalendarConfig';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { EventManager } from '../managers/EventManager';
import { EventRendererStrategy } from './EventRenderer';
import { SwpEventElement } from '../elements/SwpEventElement';
import { DragStartEventPayload, DragMoveEventPayload, DragEndEventPayload, DragMouseEnterHeaderEventPayload, DragMouseLeaveHeaderEventPayload, DragColumnChangeEventPayload, HeaderReadyEventPayload, ResizeEndEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
/**
* EventRenderingService - Render events i DOM med positionering using Strategy Pattern
* Håndterer event positioning og overlap detection
@ -14,6 +17,9 @@ export class EventRenderingService {
private eventBus: IEventBus;
private eventManager: EventManager;
private strategy: EventRendererStrategy;
private dateService: DateService;
private dragMouseLeaveHeaderListener: ((event: Event) => void) | null = null;
constructor(eventBus: IEventBus, eventManager: EventManager) {
this.eventBus = eventBus;
@ -23,6 +29,10 @@ export class EventRenderingService {
const calendarType = calendarConfig.getCalendarMode();
this.strategy = CalendarTypeFactory.getEventRenderer(calendarType);
// Initialize DateService
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
this.setupEventListeners();
}
@ -30,7 +40,6 @@ export class EventRenderingService {
* Render events in a specific container for a given period
*/
public renderEvents(context: RenderContext): void {
// Clear existing events in the specific container first
this.strategy.clearEvents(context.container);
@ -40,13 +49,23 @@ export class EventRenderingService {
context.endDate
);
if (events.length === 0) {
return;
}
// Use cached strategy to render events in the specific container
this.strategy.renderEvents(events, context.container);
// Filter events by type - only render timed events here
const timedEvents = events.filter(event => !event.allDay);
console.log('🎯 EventRenderingService: Event filtering', {
totalEvents: events.length,
timedEvents: timedEvents.length,
allDayEvents: events.length - timedEvents.length
});
// Render timed events using existing strategy
if (timedEvents.length > 0) {
this.strategy.renderEvents(timedEvents, context.container);
}
// Emit EVENTS_RENDERED event for filtering system
this.eventBus.emit(CoreEvents.EVENTS_RENDERED, {
@ -56,19 +75,30 @@ export class EventRenderingService {
}
private setupEventListeners(): void {
// Event-driven rendering: React to grid and container events
this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => {
this.handleGridRendered(event as CustomEvent);
});
// CONTAINER_READY_FOR_EVENTS removed - events are now pre-rendered synchronously
// this.eventBus.on(EventTypes.CONTAINER_READY_FOR_EVENTS, (event: Event) => {
// this.handleContainerReady(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();
// Listen for conversion from all-day event to time event
this.eventBus.on('drag:convert-to-time_event', (event: Event) => {
const { draggedElement, mousePosition, column } = (event as CustomEvent).detail;
console.log('🔄 EventRendererManager: Received drag:convert-to-time_event', {
draggedElement: draggedElement?.dataset.eventId,
mousePosition,
column
});
this.handleConvertToTimeEvent(draggedElement, mousePosition, column);
});
}
@ -76,12 +106,13 @@ export class EventRenderingService {
* Handle GRID_RENDERED event - render events in the current grid
*/
private handleGridRendered(event: CustomEvent): void {
const { container, startDate, endDate, currentDate } = event.detail;
const { container, startDate, endDate, currentDate, isNavigation } = event.detail;
if (!container) {
return;
}
let periodStart: Date;
let periodEnd: Date;
@ -102,22 +133,6 @@ export class EventRenderingService {
});
}
/**
* Handle CONTAINER_READY_FOR_EVENTS event - render events in pre-rendered container
*/
private handleContainerReady(event: CustomEvent): void {
const { container, startDate, endDate } = event.detail;
if (!container || !startDate || !endDate) {
return;
}
this.renderEvents({
container: container,
startDate: new Date(startDate),
endDate: new Date(endDate)
});
}
/**
* Handle VIEW_CHANGED event - clear and re-render for new view
@ -128,16 +143,285 @@ export class EventRenderingService {
// 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.eventBus.on('drag:start', (event: Event) => {
const dragStartPayload = (event as CustomEvent<DragStartEventPayload>).detail;
if (dragStartPayload.draggedElement.hasAttribute('data-allday')) {
return;
}
if (dragStartPayload.draggedElement && this.strategy.handleDragStart && dragStartPayload.columnBounds) {
this.strategy.handleDragStart(dragStartPayload);
}
});
this.eventBus.on('drag:move', (event: Event) => {
let dragEvent = (event as CustomEvent<DragMoveEventPayload>).detail;
if (dragEvent.draggedElement.hasAttribute('data-allday')) {
return;
}
if (this.strategy.handleDragMove) {
this.strategy.handleDragMove(dragEvent);
}
});
this.eventBus.on('drag:auto-scroll', (event: Event) => {
const { draggedElement, snappedY } = (event as CustomEvent).detail;
if (this.strategy.handleDragAutoScroll) {
const eventId = draggedElement.dataset.eventId || '';
this.strategy.handleDragAutoScroll(eventId, snappedY);
}
});
// Handle drag end events and delegate to appropriate renderer
this.eventBus.on('drag:end', (event: Event) => {
const { originalElement: draggedElement, sourceColumn, finalPosition, target } = (event as CustomEvent<DragEndEventPayload>).detail;
const finalColumn = finalPosition.column;
const finalY = finalPosition.snappedY;
const eventId = draggedElement.dataset.eventId || '';
// Only handle day column drops for EventRenderer
if (target === 'swp-day-column' && finalColumn) {
// Find dragged clone - use draggedElement as original
const draggedClone = document.querySelector(`swp-day-column swp-event[data-event-id="clone-${eventId}"]`) as HTMLElement;
if (draggedElement && draggedClone && this.strategy.handleDragEnd) {
this.strategy.handleDragEnd(eventId, draggedElement, draggedClone, finalColumn, finalY);
}
// Update event data in EventManager with new position from clone
if (draggedClone) {
const swpEvent = draggedClone as SwpEventElement;
const newStart = swpEvent.start;
const newEnd = swpEvent.end;
this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd
});
console.log('📝 EventRendererManager: Updated event in EventManager', {
eventId,
newStart,
newEnd
});
}
// Re-render affected columns for stacking/grouping (now with updated data)
this.reRenderAffectedColumns(sourceColumn, finalColumn);
}
// Clean up any remaining day event clones
const dayEventClone = document.querySelector(`swp-day-column swp-event[data-event-id="clone-${eventId}"]`);
if (dayEventClone) {
dayEventClone.remove();
}
});
// Handle column change
this.eventBus.on('drag:column-change', (event: Event) => {
let columnChangeEvent = (event as CustomEvent<DragColumnChangeEventPayload>).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);
}
});
this.dragMouseLeaveHeaderListener = (event: Event) => {
const { targetDate, mousePosition, originalElement, draggedClone: cloneElement } = (event as CustomEvent<DragMouseLeaveHeaderEventPayload>).detail;
if (cloneElement)
cloneElement.style.display = '';
console.log('🚪 EventRendererManager: Received drag:mouseleave-header', {
targetDate,
originalElement: originalElement,
cloneElement: cloneElement
});
};
this.eventBus.on('drag:mouseleave-header', this.dragMouseLeaveHeaderListener);
// Handle resize end events
this.eventBus.on('resize:end', (event: Event) => {
const { eventId, element } = (event as CustomEvent<ResizeEndEventPayload>).detail;
// Update event data in EventManager with new end time from resized element
const swpEvent = element as SwpEventElement;
const newStart = swpEvent.start;
const newEnd = swpEvent.end;
this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd
});
console.log('📝 EventRendererManager: Updated event after resize', {
eventId,
newStart,
newEnd
});
// Find the column for this event
const columnElement = element.closest('swp-day-column') as HTMLElement;
if (columnElement) {
const columnDate = columnElement.dataset.date;
if (columnDate) {
// Re-render the column to recalculate stacking/grouping
this.renderSingleColumn(columnDate);
}
}
});
// Handle navigation period change
this.eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
// Delegate to strategy if it handles navigation
if (this.strategy.handleNavigationCompleted) {
this.strategy.handleNavigationCompleted();
}
});
}
/**
* Handle conversion from all-day event to time event
*/
private handleConvertToTimeEvent(draggedElement: HTMLElement, mousePosition: { x: number; y: number }, column: string): void {
// Use the provided draggedElement directly
const allDayClone = draggedElement;
const draggedEventId = draggedElement?.dataset.eventId?.replace('clone-', '') || '';
// Use SwpEventElement factory to create day event from all-day event
const dayElement = SwpEventElement.fromAllDayElement(allDayClone as HTMLElement);
// Remove the all-day clone - it's no longer needed since we're converting to day event
allDayClone.remove();
// Set clone ID
dayElement.dataset.eventId = `clone-${draggedEventId}`;
// Find target column
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
if (!columnElement) {
console.warn('EventRendererManager: Target column not found', { column });
return;
}
// Find events layer in the column
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (!eventsLayer) {
console.warn('EventRendererManager: Events layer not found in column');
return;
}
// Add to events layer
eventsLayer.appendChild(dayElement);
// Position based on mouse Y coordinate
const columnRect = columnElement.getBoundingClientRect();
const relativeY = Math.max(0, mousePosition.y - columnRect.top);
dayElement.style.top = `${relativeY}px`;
// Set drag styling
dayElement.style.zIndex = '1000';
dayElement.style.cursor = 'grabbing';
dayElement.style.opacity = '';
dayElement.style.transform = '';
console.log('✅ EventRendererManager: Converted all-day event to time event', {
draggedEventId,
column,
mousePosition,
relativeY
});
}
/**
* Re-render affected columns after drag to recalculate stacking/grouping
*/
private reRenderAffectedColumns(sourceColumn: ColumnBounds | null, targetColumn: ColumnBounds | null): void {
const columnsToRender = new Set<string>();
// Add source column if exists
if (sourceColumn) {
columnsToRender.add(sourceColumn.date);
}
// Add target column if exists and different from source
if (targetColumn && targetColumn.date !== sourceColumn?.date) {
columnsToRender.add(targetColumn.date);
}
// Re-render each affected column
columnsToRender.forEach(columnDate => {
this.renderSingleColumn(columnDate);
});
}
/**
* Render events for a single column by re-rendering entire container
*/
private renderSingleColumn(columnDate: string): void {
// Find the column element
const columnElement = document.querySelector(`swp-day-column[data-date="${columnDate}"]`) as HTMLElement;
if (!columnElement) {
console.warn('EventRendererManager: Column not found', { columnDate });
return;
}
// Find the parent container (swp-day-columns)
const container = columnElement.closest('swp-day-columns') as HTMLElement;
if (!container) {
console.warn('EventRendererManager: Container not found');
return;
}
// Get all columns in container to determine date range
const allColumns = Array.from(container.querySelectorAll<HTMLElement>('swp-day-column'));
if (allColumns.length === 0) return;
// Get date range from first and last column
const firstColumnDate = allColumns[0].dataset.date;
const lastColumnDate = allColumns[allColumns.length - 1].dataset.date;
if (!firstColumnDate || !lastColumnDate) return;
const startDate = this.dateService.parseISO(`${firstColumnDate}T00:00:00`);
const endDate = this.dateService.parseISO(`${lastColumnDate}T23:59:59.999`);
// Re-render entire container (this will recalculate stacking for all columns)
this.renderEvents({
container,
startDate,
endDate
});
console.log('🔄 EventRendererManager: Re-rendered container for column', {
columnDate,
startDate: firstColumnDate,
endDate: lastColumnDate
});
}
private clearEvents(container?: HTMLElement): void {
this.strategy.clearEvents(container);
}
public refresh(container?: HTMLElement): void {
// Clear events in specific container or globally
this.clearEvents(container);
}
public destroy(): void {
this.clearEvents();
}
}

View file

@ -1,27 +1,26 @@
import { calendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData, CalendarView } from '../types/CalendarTypes';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { HeaderRenderContext } from './HeaderRenderer';
import { ColumnRenderContext } from './ColumnRenderer';
import { eventBus } from '../core/EventBus';
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
import { CoreEvents } from '../constants/CoreEvents';
import { TimeFormatter } from '../utils/TimeFormatter';
/**
* GridRenderer - Centralized DOM rendering for calendar grid
* Optimized to reduce redundant DOM operations and improve performance
*/
export class GridRenderer {
private headerEventListener: ((event: Event) => void) | null = null;
private cachedGridContainer: HTMLElement | null = null;
private cachedCalendarHeader: HTMLElement | null = null;
private cachedTimeAxis: HTMLElement | null = null;
private dateService: DateService;
constructor() {
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
}
/**
* Render the complete grid structure with view-aware optimization
*/
public renderGrid(
grid: HTMLElement,
currentDate: Date,
@ -39,6 +38,8 @@ export class GridRenderer {
// Only clear and rebuild if grid is empty (first render)
if (grid.children.length === 0) {
this.createCompleteGridStructure(grid, currentDate, resourceData, view);
// Setup grid-related event listeners on first render
// this.setupGridEventListeners();
} else {
// Optimized update - only refresh dynamic content
this.updateGridContent(grid, currentDate, resourceData, view);
@ -75,9 +76,6 @@ export class GridRenderer {
grid.appendChild(fragment);
}
/**
* Create optimized time axis with caching
*/
private createOptimizedTimeAxis(): HTMLElement {
const timeAxis = document.createElement('swp-time-axis');
const timeAxisContent = document.createElement('swp-time-axis-content');
@ -85,24 +83,20 @@ export class GridRenderer {
const startHour = gridSettings.dayStartHour;
const endHour = gridSettings.dayEndHour;
// Create all hour markers in memory first
const fragment = document.createDocumentFragment();
for (let hour = startHour; hour < endHour; hour++) {
const marker = document.createElement('swp-hour-marker');
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
marker.textContent = `${displayHour} ${period}`;
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;
}
/**
* Create optimized grid container with header and scrollable content
*/
private createOptimizedGridContainer(
currentDate: Date,
resourceData: ResourceCalendarData | null,
@ -110,10 +104,8 @@ export class GridRenderer {
): HTMLElement {
const gridContainer = document.createElement('swp-grid-container');
// Create calendar header with caching
// Create calendar header as first child - always exists now!
const calendarHeader = document.createElement('swp-calendar-header');
this.renderCalendarHeader(calendarHeader, currentDate, resourceData, view);
this.cachedCalendarHeader = calendarHeader;
gridContainer.appendChild(calendarHeader);
// Create scrollable content structure
@ -132,35 +124,11 @@ export class GridRenderer {
scrollableContent.appendChild(timeGrid);
gridContainer.appendChild(scrollableContent);
console.log('✅ GridRenderer: Created grid container with header');
return gridContainer;
}
/**
* Render calendar header with view awareness
*/
private renderCalendarHeader(
calendarHeader: HTMLElement,
currentDate: Date,
resourceData: ResourceCalendarData | null,
view: CalendarView
): void {
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
const context: HeaderRenderContext = {
currentWeek: currentDate, // HeaderRenderer expects currentWeek property
config: calendarConfig,
resourceData: resourceData
};
headerRenderer.render(calendarHeader, context);
// Always ensure all-day containers exist for all days
headerRenderer.ensureAllDayContainers(calendarHeader);
// Setup optimized event listener
this.setupOptimizedHeaderEventListener(calendarHeader);
}
/**
* Render column container with view awareness
@ -192,14 +160,6 @@ export class GridRenderer {
resourceData: ResourceCalendarData | null,
view: CalendarView
): void {
// Use cached elements if available
const calendarHeader = this.cachedCalendarHeader || grid.querySelector('swp-calendar-header');
if (calendarHeader) {
// Clear and re-render header content
calendarHeader.innerHTML = '';
this.renderCalendarHeader(calendarHeader as HTMLElement, currentDate, resourceData, view);
}
// Update column container if needed
const columnContainer = grid.querySelector('swp-day-columns');
if (columnContainer) {
@ -207,85 +167,43 @@ export class GridRenderer {
this.renderColumnContainer(columnContainer as HTMLElement, currentDate, resourceData, view);
}
}
/**
* Setup optimized event delegation listener with better performance
* Create navigation grid container for slide animations
* Now uses same implementation as initial load for consistency
*/
private setupOptimizedHeaderEventListener(calendarHeader: HTMLElement): void {
// Remove existing listener if any
if (this.headerEventListener) {
calendarHeader.removeEventListener('mouseover', this.headerEventListener);
}
public createNavigationGrid(parentContainer: HTMLElement, weekStart: Date): HTMLElement {
console.group('🔧 GridRenderer.createNavigationGrid');
console.log('Week start:', weekStart);
console.log('Parent container:', parentContainer);
console.log('Using same grid creation as initial load');
// Create optimized listener with throttling
let lastEmitTime = 0;
const throttleDelay = 16; // ~60fps
const weekEnd = this.dateService.addDays(weekStart, 6);
this.headerEventListener = (event) => {
const now = Date.now();
if (now - lastEmitTime < throttleDelay) {
return; // Throttle events for better performance
}
lastEmitTime = now;
// Use SAME method as initial load - respects workweek and resource settings
const newGrid = this.createOptimizedGridContainer(weekStart, null, 'week');
const target = event.target as HTMLElement;
// 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%';
// Optimized element detection
const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container');
// Add to parent container
parentContainer.appendChild(newGrid);
if (dayHeader || allDayContainer) {
let hoveredElement: HTMLElement;
let targetDate: string | undefined;
console.log('Grid created using createOptimizedGridContainer:', newGrid);
console.log('Emitting GRID_RENDERED');
if (dayHeader) {
hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) {
hoveredElement = allDayContainer as HTMLElement;
eventBus.emit(CoreEvents.GRID_RENDERED, {
container: newGrid, // Specific grid container, not parent
currentDate: weekStart,
startDate: weekStart,
endDate: weekEnd,
isNavigation: true // Flag to indicate this is navigation rendering
});
// Optimized day calculation using cached header rect
const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth)));
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date;
} else {
return;
}
// Get header renderer once and cache
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
eventBus.emit('header:mouseover', {
element: hoveredElement,
targetDate,
headerRenderer
});
}
};
// Add the optimized listener
calendarHeader.addEventListener('mouseover', this.headerEventListener);
}
/**
* Clean up cached elements and event listeners
*/
public destroy(): void {
// Clean up event listeners
if (this.headerEventListener && this.cachedCalendarHeader) {
this.cachedCalendarHeader.removeEventListener('mouseover', this.headerEventListener);
}
// Clear cached references
this.cachedGridContainer = null;
this.cachedCalendarHeader = null;
this.cachedTimeAxis = null;
this.headerEventListener = null;
console.groupEnd();
return newGrid;
}
}

View file

@ -1,6 +1,16 @@
import { calendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes';
interface GridSettings {
hourHeight: number;
snapInterval: number;
dayStartHour: number;
dayEndHour: number;
workStartHour: number;
workEndHour: number;
fitToWidth?: boolean;
}
/**
* GridStyleManager - Manages CSS variables and styling for the grid
* Separated from GridManager to follow Single Responsibility Principle
@ -38,7 +48,8 @@ export class GridStyleManager {
/**
* Set time-related CSS variables
*/
private setTimeVariables(root: HTMLElement, gridSettings: any): void {
private setTimeVariables(root: HTMLElement, gridSettings: GridSettings): void {
root.style.setProperty('--header-height', '80px'); // Fixed header height
root.style.setProperty('--hour-height', `${gridSettings.hourHeight}px`);
root.style.setProperty('--minute-height', `${gridSettings.hourHeight / 60}px`);
root.style.setProperty('--snap-interval', gridSettings.snapInterval.toString());
@ -76,7 +87,7 @@ export class GridStyleManager {
/**
* Set column width based on fitToWidth setting
*/
private setColumnWidth(root: HTMLElement, gridSettings: any): void {
private setColumnWidth(root: HTMLElement, gridSettings: GridSettings): void {
if (gridSettings.fitToWidth) {
root.style.setProperty('--day-column-min-width', '50px'); // Small min-width allows columns to fit available space
} else {

View file

@ -1,241 +1,16 @@
// Header rendering strategy interface and implementations
import { CalendarConfig, ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { eventBus } from '../core/EventBus';
import { CalendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes';
import { DateCalculator } from '../utils/DateCalculator';
import { DateService } from '../utils/DateService';
/**
* Interface for header rendering strategies
*/
export interface HeaderRenderer {
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
addToAllDay(dayHeader: HTMLElement): void;
ensureAllDayContainers(calendarHeader: HTMLElement): void;
checkAndAnimateAllDayHeight(): void;
}
/**
* Base class with shared addToAllDay implementation
*/
export abstract class BaseHeaderRenderer implements HeaderRenderer {
// Cached DOM elements to avoid redundant queries
private cachedCalendarHeader: HTMLElement | null = null;
private cachedAllDayContainer: HTMLElement | null = null;
private cachedHeaderSpacer: HTMLElement | null = null;
abstract render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
/**
* Get cached calendar header element
*/
private getCalendarHeader(): HTMLElement | null {
if (!this.cachedCalendarHeader) {
this.cachedCalendarHeader = document.querySelector('swp-calendar-header');
}
return this.cachedCalendarHeader;
}
/**
* Get cached all-day container element
*/
private getAllDayContainer(): HTMLElement | null {
if (!this.cachedAllDayContainer) {
const calendarHeader = this.getCalendarHeader();
if (calendarHeader) {
this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container');
}
}
return this.cachedAllDayContainer;
}
/**
* Get cached header spacer element
*/
private getHeaderSpacer(): HTMLElement | null {
if (!this.cachedHeaderSpacer) {
this.cachedHeaderSpacer = document.querySelector('swp-header-spacer');
}
return this.cachedHeaderSpacer;
}
/**
* 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;
const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0');
const heightDifference = targetHeight - currentHeight;
return { targetHeight, currentHeight, heightDifference };
}
/**
* Clear cached DOM elements (call when DOM structure changes)
*/
private clearCache(): void {
this.cachedCalendarHeader = null;
this.cachedAllDayContainer = null;
this.cachedHeaderSpacer = null;
}
/**
* Expand header to show all-day row
*/
addToAllDay(dayHeader: HTMLElement): void {
const { currentHeight } = this.calculateAllDayHeight(0);
if (currentHeight === 0) {
// Find the calendar header element to animate
const calendarHeader = dayHeader.closest('swp-calendar-header') as HTMLElement;
if (calendarHeader) {
// Ensure container exists BEFORE animation
this.createAllDayMainStructure(calendarHeader);
this.checkAndAnimateAllDayHeight();
}
}
}
/**
* Ensure all-day containers exist - always create them during header rendering
*/
ensureAllDayContainers(calendarHeader: HTMLElement): void {
this.createAllDayMainStructure(calendarHeader);
}
checkAndAnimateAllDayHeight(): void {
const container = this.getAllDayContainer();
if (!container) return;
const allDayEvents = container.querySelectorAll('swp-allday-event');
// Calculate required rows - 0 if no events (will collapse)
let maxRows = 0;
if (allDayEvents.length > 0) {
// Expand events to all dates they span and group by date
const expandedEventsByDate: Record<string, string[]> = {};
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
const startISO = event.dataset.start || '';
const endISO = event.dataset.end || startISO;
const eventId = event.dataset.eventId || '';
// Extract dates from ISO strings
const startDate = startISO.split('T')[0]; // YYYY-MM-DD
const endDate = endISO.split('T')[0]; // YYYY-MM-DD
// Loop through all dates from start to end
let current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format
if (!expandedEventsByDate[dateStr]) {
expandedEventsByDate[dateStr] = [];
}
expandedEventsByDate[dateStr].push(eventId);
// Move to next day
current.setDate(current.getDate() + 1);
}
});
// Find max rows needed
maxRows = Math.max(
...Object.values(expandedEventsByDate).map(ids => ids?.length || 0),
0
);
}
// Animate to required rows (0 = collapse, >0 = expand)
this.animateToRows(maxRows);
}
/**
* Animate all-day container to specific number of rows
*/
animateToRows(targetRows: number): void {
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
if (targetHeight === currentHeight) return; // No animation needed
console.log(`🎬 All-day height animation starting: ${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: 300,
easing: 'ease-out',
fill: 'forwards'
})
];
// Add spacer animation if spacer exists
if (headerSpacer) {
const root = document.documentElement;
const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight;
const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight;
animations.push(
headerSpacer.animate([
{ height: `${currentSpacerHeight}px` },
{ height: `${targetSpacerHeight}px` }
], {
duration: 300,
easing: 'ease-out',
fill: 'forwards'
})
);
}
// 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');
});
}
private createAllDayMainStructure(calendarHeader: HTMLElement): void {
// Check if container already exists
let container = calendarHeader.querySelector('swp-allday-container');
if (!container) {
// Create simple all-day container (initially hidden)
container = document.createElement('swp-allday-container');
calendarHeader.appendChild(container);
// Clear cache since DOM structure changed
this.clearCache();
}
}
/**
* Public cleanup method for cached elements
*/
public destroy(): void {
this.clearCache();
}
}
/**
* Context for header rendering
@ -249,46 +24,48 @@ export interface HeaderRenderContext {
/**
* Date-based header renderer (original functionality)
*/
export class DateHeaderRenderer extends BaseHeaderRenderer {
private dateCalculator!: DateCalculator;
export class DateHeaderRenderer implements HeaderRenderer {
private dateService!: DateService;
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
const { currentWeek, config } = context;
// Initialize date calculator with config
DateCalculator.initialize(config);
this.dateCalculator = new DateCalculator();
// FIRST: Always create all-day container as part of standard header structure
const allDayContainer = document.createElement('swp-allday-container');
calendarHeader.appendChild(allDayContainer);
const dates = DateCalculator.getWorkWeekDates(currentWeek);
// Initialize date service with config
const timezone = config.getTimezone?.() || 'Europe/Copenhagen';
this.dateService = new DateService(timezone);
const workWeekSettings = config.getWorkWeekSettings();
const dates = this.dateService.getWorkWeekDates(currentWeek, workWeekSettings.workDays);
const weekDays = config.getDateViewSettings().weekDays;
const daysToShow = dates.slice(0, weekDays);
daysToShow.forEach((date, index) => {
const header = document.createElement('swp-day-header');
if (DateCalculator.isToday(date)) {
if (this.dateService.isSameDay(date, new Date())) {
(header as any).dataset.today = 'true';
}
const dayName = DateCalculator.getDayName(date, 'short');
const dayName = this.dateService.getDayName(date, 'short');
header.innerHTML = `
<swp-day-name>${dayName}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
(header as any).dataset.date = DateCalculator.formatISODate(date);
(header as any).dataset.date = this.dateService.formatISODate(date);
calendarHeader.appendChild(header);
});
// Always create all-day container after rendering headers
this.ensureAllDayContainers(calendarHeader);
}
}
/**
* Resource-based header renderer
*/
export class ResourceHeaderRenderer extends BaseHeaderRenderer {
export class ResourceHeaderRenderer implements HeaderRenderer {
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
const { resourceData } = context;
@ -310,8 +87,5 @@ export class ResourceHeaderRenderer extends BaseHeaderRenderer {
calendarHeader.appendChild(header);
});
// Always create all-day container after rendering headers
this.ensureAllDayContainers(calendarHeader);
}
}

View file

@ -1,10 +1,6 @@
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { EventRenderingService } from './EventRendererManager';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { eventBus } from '../core/EventBus';
/**
* NavigationRenderer - Handles DOM rendering for navigation containers
@ -12,8 +8,6 @@ import { eventBus } from '../core/EventBus';
*/
export class NavigationRenderer {
private eventBus: IEventBus;
private dateCalculator: DateCalculator;
private eventRenderer: EventRenderingService;
// Cached DOM elements to avoid redundant queries
private cachedWeekNumberElement: HTMLElement | null = null;
@ -21,9 +15,6 @@ export class NavigationRenderer {
constructor(eventBus: IEventBus, eventRenderer: EventRenderingService) {
this.eventBus = eventBus;
this.eventRenderer = eventRenderer;
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
this.setupEventListeners();
}
@ -119,158 +110,6 @@ export class NavigationRenderer {
}
});
});
}
/**
* Render a complete container with content and events
*/
public renderContainer(parentContainer: HTMLElement, weekStart: Date): HTMLElement {
const weekEnd = DateCalculator.addDays(weekStart, 6);
// Create new grid container
const newGrid = document.createElement('swp-grid-container');
newGrid.innerHTML = `
<swp-calendar-header></swp-calendar-header>
<swp-scrollable-content>
<swp-time-grid>
<swp-grid-lines></swp-grid-lines>
<swp-day-columns></swp-day-columns>
</swp-time-grid>
</swp-scrollable-content>
`;
// Position new grid - 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);
this.renderWeekContentInContainer(newGrid, weekStart);
this.eventBus.emit(CoreEvents.GRID_RENDERED, {
container: newGrid, // Specific grid container, not parent
currentDate: weekStart,
startDate: weekStart,
endDate: weekEnd,
isNavigation: true // Flag to indicate this is navigation rendering
});
return newGrid;
}
/**
* Render week content in specific container
*/
private renderWeekContentInContainer(gridContainer: HTMLElement, weekStart: Date): void {
const header = gridContainer.querySelector('swp-calendar-header');
const dayColumns = gridContainer.querySelector('swp-day-columns');
if (!header || !dayColumns) return;
// Clear existing content
header.innerHTML = '';
dayColumns.innerHTML = '';
// Get dates using DateCalculator
const dates = DateCalculator.getWorkWeekDates(weekStart);
// Render headers for target week
dates.forEach((date, i) => {
const headerElement = document.createElement('swp-day-header');
if (DateCalculator.isToday(date)) {
headerElement.dataset.today = 'true';
}
const dayName = DateCalculator.getDayName(date, 'short');
headerElement.innerHTML = `
<swp-day-name>${dayName}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
headerElement.dataset.date = DateCalculator.formatISODate(date);
header.appendChild(headerElement);
});
// Always ensure all-day containers exist for all days
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarConfig.getCalendarMode());
headerRenderer.ensureAllDayContainers(header as HTMLElement);
// Add event delegation listener for drag & drop functionality
this.setupHeaderEventListener(header as HTMLElement);
// Render day columns for target week
dates.forEach(date => {
const column = document.createElement('swp-day-column');
column.dataset.date = DateCalculator.formatISODate(date);
const eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
dayColumns.appendChild(column);
});
}
/**
* Setup event delegation listener for header mouseover (same logic as GridRenderer)
*/
private setupHeaderEventListener(calendarHeader: HTMLElement): void {
calendarHeader.addEventListener('mouseover', (event) => {
const target = event.target as HTMLElement;
// Check what was hovered - could be day-header OR all-day-container
const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container');
if (dayHeader || allDayContainer) {
let hoveredElement: HTMLElement;
let targetDate: string | undefined;
if (dayHeader) {
hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) {
// For all-day areas, we need to determine which day column we're over
hoveredElement = allDayContainer as HTMLElement;
// Calculate which day we're hovering over based on mouse position
const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.floor(mouseX / dayWidth);
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date;
} else {
return; // No valid element found
}
// Get the header renderer for addToAllDay functionality
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
eventBus.emit('header:mouseover', {
element: hoveredElement,
targetDate,
headerRenderer
});
}
});
}
/**
* Public cleanup method for cached elements
*/
public destroy(): void {
this.clearCache();
}
}

View file

@ -1,158 +0,0 @@
/**
* MonthViewStrategy - Strategy for month view rendering
* Completely different from week view - no time axis, cell-based events
*/
import { ViewStrategy, ViewContext, ViewLayoutConfig } from './ViewStrategy';
import { DateCalculator } from '../utils/DateCalculator';
import { calendarConfig } from '../core/CalendarConfig';
export class MonthViewStrategy implements ViewStrategy {
private dateCalculator: DateCalculator;
constructor() {
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
}
getLayoutConfig(): ViewLayoutConfig {
return {
needsTimeAxis: false, // No time axis in month view!
columnCount: 7, // Always 7 days (Mon-Sun)
scrollable: false, // Month fits in viewport
eventPositioning: 'cell-based' // Events go in day cells
};
}
renderGrid(context: ViewContext): void {
// Clear existing content
context.container.innerHTML = '';
// Create month grid (completely different from week!)
this.createMonthGrid(context);
}
private createMonthGrid(context: ViewContext): void {
const monthGrid = document.createElement('div');
monthGrid.className = 'month-grid';
monthGrid.style.display = 'grid';
monthGrid.style.gridTemplateColumns = 'repeat(7, 1fr)';
monthGrid.style.gridTemplateRows = 'auto repeat(6, 1fr)';
monthGrid.style.height = '100%';
// Add day headers (Mon, Tue, Wed, etc.)
this.createDayHeaders(monthGrid);
// Add 6 weeks of day cells
this.createDayCells(monthGrid, context.currentDate);
// Render events in day cells (will be handled by EventRendererManager)
// this.renderMonthEvents(monthGrid, context.allDayEvents);
context.container.appendChild(monthGrid);
}
private createDayHeaders(container: HTMLElement): void {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
dayNames.forEach(dayName => {
const header = document.createElement('div');
header.className = 'month-day-header';
header.textContent = dayName;
header.style.padding = '8px';
header.style.fontWeight = 'bold';
header.style.textAlign = 'center';
header.style.borderBottom = '1px solid #e0e0e0';
container.appendChild(header);
});
}
private createDayCells(container: HTMLElement, monthDate: Date): void {
const dates = this.getMonthDates(monthDate);
dates.forEach(date => {
const cell = document.createElement('div');
cell.className = 'month-day-cell';
cell.dataset.date = DateCalculator.formatISODate(date);
cell.style.border = '1px solid #e0e0e0';
cell.style.minHeight = '100px';
cell.style.padding = '4px';
cell.style.position = 'relative';
// Day number
const dayNumber = document.createElement('div');
dayNumber.className = 'month-day-number';
dayNumber.textContent = date.getDate().toString();
dayNumber.style.fontWeight = 'bold';
dayNumber.style.marginBottom = '4px';
// Check if today
if (DateCalculator.isToday(date)) {
dayNumber.style.color = '#1976d2';
cell.style.backgroundColor = '#f5f5f5';
}
cell.appendChild(dayNumber);
container.appendChild(cell);
});
}
private getMonthDates(monthDate: Date): Date[] {
// Get first day of month
const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
// Get Monday of the week containing first day
const startDate = DateCalculator.getISOWeekStart(firstOfMonth);
// Generate 42 days (6 weeks)
const dates: Date[] = [];
for (let i = 0; i < 42; i++) {
dates.push(DateCalculator.addDays(startDate, i));
}
return dates;
}
private renderMonthEvents(container: HTMLElement, events: any[]): void {
// TODO: Implement month event rendering
// Events will be small blocks in day cells
}
getNextPeriod(currentDate: Date): Date {
return new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
}
getPreviousPeriod(currentDate: Date): Date {
return new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
}
getPeriodLabel(date: Date): string {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
return `${monthNames[date.getMonth()]} ${date.getFullYear()}`;
}
getDisplayDates(baseDate: Date): Date[] {
return this.getMonthDates(baseDate);
}
getPeriodRange(baseDate: Date): { startDate: Date; endDate: Date } {
// Month view shows events for the entire month grid (including partial weeks)
const firstOfMonth = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1);
// Get Monday of the week containing first day
const startDate = DateCalculator.getISOWeekStart(firstOfMonth);
// End date is 41 days after start (42 total days)
const endDate = DateCalculator.addDays(startDate, 41);
return {
startDate,
endDate
};
}
destroy(): void {
}
}

View file

@ -62,9 +62,4 @@ export interface ViewStrategy {
* Get the period start and end dates for event filtering
*/
getPeriodRange(baseDate: Date): { startDate: Date; endDate: Date };
/**
* Clean up any view-specific resources
*/
destroy(): void;
}

View file

@ -1,78 +0,0 @@
/**
* WeekViewStrategy - Strategy for week/day view rendering
* Extracts the time-based grid logic from GridManager
*/
import { ViewStrategy, ViewContext, ViewLayoutConfig } from './ViewStrategy';
import { DateCalculator } from '../utils/DateCalculator';
import { calendarConfig } from '../core/CalendarConfig';
import { GridRenderer } from '../renderers/GridRenderer';
import { GridStyleManager } from '../renderers/GridStyleManager';
export class WeekViewStrategy implements ViewStrategy {
private dateCalculator: DateCalculator;
private gridRenderer: GridRenderer;
private styleManager: GridStyleManager;
constructor() {
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
this.gridRenderer = new GridRenderer();
this.styleManager = new GridStyleManager();
}
getLayoutConfig(): ViewLayoutConfig {
return {
needsTimeAxis: true,
columnCount: calendarConfig.getWorkWeekSettings().totalDays,
scrollable: true,
eventPositioning: 'time-based'
};
}
renderGrid(context: ViewContext): void {
// Update grid styles
this.styleManager.updateGridStyles(context.resourceData);
// Render the grid structure (time axis + day columns)
this.gridRenderer.renderGrid(
context.container,
context.currentDate,
context.resourceData
);
}
getNextPeriod(currentDate: Date): Date {
return DateCalculator.addWeeks(currentDate, 1);
}
getPreviousPeriod(currentDate: Date): Date {
return DateCalculator.addWeeks(currentDate, -1);
}
getPeriodLabel(date: Date): string {
const weekStart = DateCalculator.getISOWeekStart(date);
const weekEnd = DateCalculator.addDays(weekStart, 6);
const weekNumber = DateCalculator.getWeekNumber(date);
return `Week ${weekNumber}: ${DateCalculator.formatDateRange(weekStart, weekEnd)}`;
}
getDisplayDates(baseDate: Date): Date[] {
return DateCalculator.getWorkWeekDates(baseDate);
}
getPeriodRange(baseDate: Date): { startDate: Date; endDate: Date } {
const weekStart = DateCalculator.getISOWeekStart(baseDate);
const weekEnd = DateCalculator.addDays(weekStart, 6);
return {
startDate: weekStart,
endDate: weekEnd
};
}
destroy(): void {
// Clean up any week-specific resources
}
}

View file

@ -33,8 +33,8 @@ export interface RenderContext {
export interface CalendarEvent {
id: string;
title: string;
start: string; // ISO 8601
end: string; // ISO 8601
start: Date;
end: Date;
type: string; // Flexible event type - can be any string value
allDay: boolean;
syncStatus: SyncStatus;
@ -80,7 +80,7 @@ export interface CalendarConfig {
export interface EventLogEntry {
type: string;
detail: any;
detail: unknown;
timestamp: number;
}
@ -94,53 +94,7 @@ export interface IEventBus {
on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void;
once(eventType: string, handler: EventListener): () => void;
off(eventType: string, handler: EventListener): void;
emit(eventType: string, detail?: any): boolean;
emit(eventType: string, detail?: unknown): boolean;
getEventLog(eventType?: string): EventLogEntry[];
setDebug(enabled: boolean): void;
destroy(): void;
}
export interface GridPosition {
minutes: number;
time: string;
y: number;
}
export interface Period {
start: string;
end: string;
mode?: CalendarMode; // Optional: which calendar mode this period is for
}
export interface EventData {
events: CalendarEvent[];
meta: {
start: string;
end: string;
total: number;
mode?: CalendarMode; // Which calendar mode this data is for
};
}
/**
* Context interfaces for different calendar modes
*/
export interface DateModeContext {
mode: 'date';
currentWeek: Date;
period: ViewPeriod;
weekDays: number;
firstDayOfWeek: number;
}
export interface ResourceModeContext {
mode: 'resource';
selectedDate: Date;
resources: Resource[];
maxResources: number;
}
/**
* Union type for type-safe mode contexts
*/
export type CalendarModeContext = DateModeContext | ResourceModeContext;

View file

@ -0,0 +1,47 @@
/**
* Type definitions for drag and drop functionality
*/
export interface MousePosition {
x: number;
y: number;
clientX?: number;
clientY?: number;
}
export interface DragOffset {
x: number;
y: number;
offsetX?: number;
offsetY?: number;
}
export interface DragState {
isDragging: boolean;
draggedElement: HTMLElement | null;
draggedClone: HTMLElement | null;
eventId: string | null;
startColumn: string | null;
currentColumn: string | null;
mouseOffset: DragOffset;
}
export interface DragEndPosition {
column: string;
y: number;
snappedY: number;
time?: Date;
}
export interface StackLinkData {
prev?: string;
next?: string;
isFirst?: boolean;
isLast?: boolean;
}
export interface DragEventHandlers {
handleDragStart?(originalElement: HTMLElement, eventId: string, mouseOffset: DragOffset, column: string): void;
handleDragMove?(eventId: string, snappedY: number, column: string, mouseOffset: DragOffset): void;
handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: string, finalY: number): void;
}

View file

@ -1,33 +1,90 @@
/**
* Type definitions for calendar events
* Type definitions for calendar events and drag operations
*/
export interface AllDayEvent {
id: string;
title: string;
start: Date | string;
end: Date | string;
allDay: true;
color?: string;
metadata?: {
color?: string;
category?: string;
location?: string;
};
import { ColumnBounds } from "../utils/ColumnDetectionUtils";
import { CalendarEvent } from "./CalendarTypes";
/**
* Drag Event Payload Interfaces
* Type-safe interfaces for drag and drop events
*/
// Common position interface
export interface MousePosition {
x: number;
y: number;
}
export interface TimeEvent {
id: string;
title: string;
start: Date | string;
end: Date | string;
allDay?: false;
color?: string;
metadata?: {
color?: string;
category?: string;
location?: string;
};
// Drag start event payload
export interface DragStartEventPayload {
draggedElement: HTMLElement;
draggedClone: HTMLElement | null;
mousePosition: MousePosition;
mouseOffset: MousePosition;
columnBounds: ColumnBounds | null;
}
export type CalendarEventData = AllDayEvent | TimeEvent;
// Drag move event payload
export interface DragMoveEventPayload {
draggedElement: HTMLElement;
draggedClone: HTMLElement;
mousePosition: MousePosition;
mouseOffset: MousePosition;
columnBounds: ColumnBounds | null;
snappedY: number;
}
// Drag end event payload
export interface DragEndEventPayload {
originalElement: HTMLElement;
draggedClone: HTMLElement | null;
sourceColumn: ColumnBounds | null; // Where drag started
mousePosition: MousePosition;
finalPosition: {
column: ColumnBounds | null; // Where drag ended
snappedY: number;
};
target: 'swp-day-column' | 'swp-day-header' | null;
}
// Drag mouse enter header event payload
export interface DragMouseEnterHeaderEventPayload {
targetColumn: ColumnBounds;
mousePosition: MousePosition;
originalElement: HTMLElement | null;
draggedClone: HTMLElement;
calendarEvent: CalendarEvent;
// Delegate pattern - allows subscriber to replace the dragged clone
replaceClone: (newClone: HTMLElement) => void;
}
// Drag mouse leave header event payload
export interface DragMouseLeaveHeaderEventPayload {
targetDate: string | null;
mousePosition: MousePosition;
originalElement: HTMLElement| null;
draggedClone: HTMLElement| null;
}
// Drag column change event payload
export interface DragColumnChangeEventPayload {
originalElement: HTMLElement;
draggedClone: HTMLElement | null;
previousColumn: ColumnBounds | null;
newColumn: ColumnBounds;
mousePosition: MousePosition;
}
// Header ready event payload
export interface HeaderReadyEventPayload {
headerElements: ColumnBounds[];
}
// Resize end event payload
export interface ResizeEndEventPayload {
eventId: string;
element: HTMLElement;
finalHeight: number;
}

104
src/types/ManagerTypes.ts Normal file
View file

@ -0,0 +1,104 @@
import { IEventBus, CalendarEvent, CalendarView } from './CalendarTypes';
/**
* Complete type definition for all managers returned by ManagerFactory
*/
export interface CalendarManagers {
eventManager: EventManager;
eventRenderer: EventRenderingService;
gridManager: GridManager;
scrollManager: ScrollManager;
navigationManager: unknown; // Avoid interface conflicts
viewManager: ViewManager;
calendarManager: CalendarManager;
dragDropManager: unknown; // Avoid interface conflicts
allDayManager: unknown; // Avoid interface conflicts
resizeHandleManager: ResizeHandleManager;
}
/**
* Base interface for managers with optional initialization and refresh
*/
interface IManager {
initialize?(): Promise<void> | void;
refresh?(): void;
}
export interface EventManager extends IManager {
loadData(): Promise<void>;
getEvents(): CalendarEvent[];
getEventsForPeriod(startDate: Date, endDate: Date): CalendarEvent[];
getResourceData(): ResourceData | null;
navigateToEvent(eventId: string): boolean;
}
export interface EventRenderingService extends IManager {
// EventRenderingService doesn't have a render method in current implementation
}
export interface GridManager extends IManager {
render(): Promise<void>;
getDisplayDates(): Date[];
setResourceData(resourceData: import('./CalendarTypes').ResourceCalendarData | null): void;
}
export interface ScrollManager extends IManager {
scrollTo(scrollTop: number): void;
scrollToHour(hour: number): void;
}
// Use a more flexible interface that matches actual implementation
export interface NavigationManager extends IManager {
[key: string]: unknown; // Allow any properties from actual implementation
}
export interface ViewManager extends IManager {
// ViewManager doesn't have setView in current implementation
getCurrentView?(): CalendarView;
}
export interface CalendarManager extends IManager {
setView(view: CalendarView): void;
setCurrentDate(date: Date): void;
getInitializationReport(): InitializationReport;
}
export interface DragDropManager extends IManager {
// DragDropManager has different interface in current implementation
}
export interface AllDayManager extends IManager {
[key: string]: unknown; // Allow any properties from actual implementation
}
export interface ResizeHandleManager extends IManager {
// ResizeHandleManager handles hover effects for resize handles
}
export interface ResourceData {
resources: Resource[];
assignments?: ResourceAssignment[];
}
export interface Resource {
id: string;
name: string;
type?: string;
color?: string;
}
export interface ResourceAssignment {
resourceId: string;
eventId: string;
}
export interface InitializationReport {
initialized: boolean;
timestamp: number;
managers: {
[key: string]: {
initialized: boolean;
error?: string;
};
};
}

View file

@ -0,0 +1,142 @@
import { CalendarEvent } from '../types/CalendarTypes';
export interface EventLayout {
calenderEvent: CalendarEvent;
gridArea: string; // "row-start / col-start / row-end / col-end"
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
}
export class AllDayLayoutEngine {
private weekDates: string[];
private tracks: boolean[][];
constructor(weekDates: string[]) {
this.weekDates = weekDates;
this.tracks = [];
}
/**
* Calculate layout for all events using clean day-based logic
*/
public calculateLayout(events: CalendarEvent[]): EventLayout[] {
let layouts: EventLayout[] = [];
// Reset tracks for new calculation
this.tracks = [new Array(this.weekDates.length).fill(false)];
// Filter to only visible events
const visibleEvents = events.filter(event => this.isEventVisible(event));
// Process events in input order (no sorting)
for (const event of visibleEvents) {
const startDay = this.getEventStartDay(event);
const endDay = this.getEventEndDay(event);
if (startDay > 0 && endDay > 0) {
const track = this.findAvailableTrack(startDay - 1, endDay - 1); // Convert to 0-based for tracks
// Mark days as occupied
for (let day = startDay - 1; day <= endDay - 1; day++) {
this.tracks[track][day] = true;
}
const layout: EventLayout = {
calenderEvent: event,
gridArea: `${track + 1} / ${startDay} / ${track + 2} / ${endDay + 1}`,
startColumn: startDay,
endColumn: endDay,
row: track + 1,
columnSpan: endDay - startDay + 1
};
layouts.push(layout);
}
}
return layouts;
}
/**
* Find available track for event spanning from startDay to endDay (0-based indices)
*/
private findAvailableTrack(startDay: number, endDay: number): number {
for (let trackIndex = 0; trackIndex < this.tracks.length; trackIndex++) {
if (this.isTrackAvailable(trackIndex, startDay, endDay)) {
return trackIndex;
}
}
// Create new track if none available
this.tracks.push(new Array(this.weekDates.length).fill(false));
return this.tracks.length - 1;
}
/**
* Check if track is available for the given day range (0-based indices)
*/
private isTrackAvailable(trackIndex: number, startDay: number, endDay: number): boolean {
for (let day = startDay; day <= endDay; day++) {
if (this.tracks[trackIndex][day]) {
return false;
}
}
return true;
}
/**
* Get start day index for event (1-based, 0 if not visible)
*/
private getEventStartDay(event: CalendarEvent): number {
const eventStartDate = this.formatDate(event.start);
const firstVisibleDate = this.weekDates[0];
// If event starts before visible range, clip to first visible day
const clippedStartDate = eventStartDate < firstVisibleDate ? firstVisibleDate : eventStartDate;
const dayIndex = this.weekDates.indexOf(clippedStartDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
}
/**
* Get end day index for event (1-based, 0 if not visible)
*/
private getEventEndDay(event: CalendarEvent): number {
const eventEndDate = this.formatDate(event.end);
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
// If event ends after visible range, clip to last visible day
const clippedEndDate = eventEndDate > lastVisibleDate ? lastVisibleDate : eventEndDate;
const dayIndex = this.weekDates.indexOf(clippedEndDate);
return dayIndex >= 0 ? dayIndex + 1 : 0;
}
/**
* Check if event is visible in the current date range
*/
private isEventVisible(event: CalendarEvent): boolean {
if (this.weekDates.length === 0) return false;
const eventStartDate = this.formatDate(event.start);
const eventEndDate = this.formatDate(event.end);
const firstVisibleDate = this.weekDates[0];
const lastVisibleDate = this.weekDates[this.weekDates.length - 1];
// Event overlaps if it doesn't end before visible range starts
// AND doesn't start after visible range ends
return !(eventEndDate < firstVisibleDate || eventStartDate > lastVisibleDate);
}
/**
* Format date to YYYY-MM-DD string using local date
*/
private formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}

View file

@ -0,0 +1,118 @@
/**
* ColumnDetectionUtils - Shared utility for column detection and caching
* Used by both DragDropManager and AllDayManager for consistent column detection
*/
import { MousePosition } from "../types/DragDropTypes";
export interface ColumnBounds {
date: string;
left: number;
right: number;
boundingClientRect: DOMRect,
element : HTMLElement,
index: number
}
export class ColumnDetectionUtils {
private static columnBoundsCache: ColumnBounds[] = [];
/**
* Update column bounds cache for coordinate-based column detection
*/
public static updateColumnBoundsCache(): void {
// Reset cache
this.columnBoundsCache = [];
// Find alle kolonner
const columns = document.querySelectorAll('swp-day-column');
let index = 1;
// Cache hver kolonnes x-grænser
columns.forEach(column => {
const rect = column.getBoundingClientRect();
const date = (column as HTMLElement).dataset.date;
if (date) {
this.columnBoundsCache.push({
boundingClientRect : rect,
element: column as HTMLElement,
date,
left: rect.left,
right: rect.right,
index: index++
});
}
});
// Sorter efter x-position (fra venstre til højre)
this.columnBoundsCache.sort((a, b) => a.left - b.left);
}
/**
* Get column date from X coordinate using cached bounds
*/
public static getColumnBounds(position: MousePosition): ColumnBounds | null{
if (this.columnBoundsCache.length === 0) {
this.updateColumnBoundsCache();
}
// Find den kolonne hvor x-koordinaten er indenfor grænserne
let column = this.columnBoundsCache.find(col =>
position.x >= col.left && position.x <= col.right
);
if (column)
return column;
return null;
}
/**
* Get column bounds by Date
*/
public static getColumnBoundsByDate(date: Date): ColumnBounds | null {
if (this.columnBoundsCache.length === 0) {
this.updateColumnBoundsCache();
}
// Convert Date to YYYY-MM-DD format
let dateString = date.toISOString().split('T')[0];
// Find column that matches the date
let column = this.columnBoundsCache.find(col => col.date === dateString);
return column || null;
}
public static getColumns(): ColumnBounds[] {
return [...this.columnBoundsCache];
}
public static getHeaderColumns(): ColumnBounds[] {
let dayHeaders: ColumnBounds[] = [];
const dayColumns = document.querySelectorAll('swp-calendar-header swp-day-header');
let index = 1;
// Cache hver kolonnes x-grænser
dayColumns.forEach(column => {
const rect = column.getBoundingClientRect();
const date = (column as HTMLElement).dataset.date;
if (date) {
dayHeaders.push({
boundingClientRect : rect,
element: column as HTMLElement,
date,
left: rect.left,
right: rect.right,
index: index++
});
}
});
// Sorter efter x-position (fra venstre til højre)
dayHeaders.sort((a, b) => a.left - b.left);
return dayHeaders;
}
}

View file

@ -1,308 +0,0 @@
/**
* DateCalculator - Centralized date calculation logic for calendar
* Handles all date computations with proper week start handling
*/
import { CalendarConfig } from '../core/CalendarConfig';
export class DateCalculator {
private static config: CalendarConfig;
/**
* Initialize DateCalculator with configuration
* @param config - Calendar configuration
*/
static initialize(config: CalendarConfig): void {
DateCalculator.config = config;
}
/**
* Validate that a date is valid
* @param date - Date to validate
* @param methodName - Name of calling method for error messages
* @throws Error if date is invalid
*/
private static validateDate(date: Date, methodName: string): void {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
throw new Error(`${methodName}: Invalid date provided - ${date}`);
}
}
/**
* Get dates for work week using ISO 8601 day numbering (Monday=1, Sunday=7)
* @param weekStart - Any date in the week
* @returns Array of dates for the configured work days
*/
static getWorkWeekDates(weekStart: Date): Date[] {
DateCalculator.validateDate(weekStart, 'getWorkWeekDates');
const dates: Date[] = [];
const workWeekSettings = DateCalculator.config.getWorkWeekSettings();
// Always use ISO week start (Monday)
const mondayOfWeek = DateCalculator.getISOWeekStart(weekStart);
// Calculate dates for each work day using ISO numbering
workWeekSettings.workDays.forEach(isoDay => {
const date = new Date(mondayOfWeek);
// ISO day 1=Monday is +0 days, ISO day 7=Sunday is +6 days
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
date.setDate(mondayOfWeek.getDate() + daysFromMonday);
dates.push(date);
});
return dates;
}
/**
* 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
*/
static getISOWeekStart(date: Date): Date {
DateCalculator.validateDate(date, 'getISOWeekStart');
const monday = new Date(date);
const currentDay = monday.getDay();
const daysToSubtract = currentDay === 0 ? 6 : currentDay - 1;
monday.setDate(monday.getDate() - daysToSubtract);
monday.setHours(0, 0, 0, 0);
return monday;
}
/**
* Get the end of the ISO week for a given date
* @param date - Any date in the week
* @returns The end date of the ISO week (Sunday)
*/
static getWeekEnd(date: Date): Date {
DateCalculator.validateDate(date, 'getWeekEnd');
const weekStart = DateCalculator.getISOWeekStart(date);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return weekEnd;
}
/**
* Get week number for a date (ISO 8601)
* @param date - The date to get week number for
* @returns Week number (1-53)
*/
static getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1)/7);
}
/**
* Format a date range with customizable options
* @param start - Start date
* @param end - End date
* @param options - Formatting options
* @returns Formatted date range string
*/
static formatDateRange(
start: Date,
end: Date,
options: {
locale?: string;
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
day?: 'numeric' | '2-digit';
year?: 'numeric' | '2-digit';
} = {}
): string {
const { locale = 'en-US', month = 'short', day = 'numeric' } = options;
const startYear = start.getFullYear();
const endYear = end.getFullYear();
const formatter = new Intl.DateTimeFormat(locale, {
month,
day,
year: startYear !== endYear ? 'numeric' : undefined
});
// @ts-ignore
if (typeof formatter.formatRange === 'function') {
// @ts-ignore
return formatter.formatRange(start, end);
}
return `${formatter.format(start)} - ${formatter.format(end)}`;
}
/**
* Format a date to ISO date string (YYYY-MM-DD)
* @param date - Date to format
* @returns ISO date string
*/
static formatISODate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/**
* Check if a date is today
* @param date - Date to check
* @returns True if the date is today
*/
static isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
/**
* Add days to a date
* @param date - Base date
* @param days - Number of days to add (can be negative)
* @returns New date
*/
static addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Add weeks to a date
* @param date - Base date
* @param weeks - Number of weeks to add (can be negative)
* @returns New date
*/
static addWeeks(date: Date, weeks: number): Date {
return DateCalculator.addDays(date, weeks * 7);
}
/**
* Get all dates in a week
* @param weekStart - Start of the week
* @returns Array of 7 dates for the full week
*/
static getFullWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(DateCalculator.addDays(weekStart, i));
}
return dates;
}
/**
* Get the day name for a date using Intl.DateTimeFormat
* @param date - Date to get day name for
* @param format - 'short' or 'long'
* @returns Day name
*/
static getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
const formatter = new Intl.DateTimeFormat('en-US', {
weekday: format
});
return formatter.format(date);
}
/**
* Format time to HH:MM
* @param date - Date to format
* @returns Time string
*/
static formatTime(date: Date): string {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
/**
* Format time to 12-hour format
* @param date - Date to format
* @returns 12-hour time string
*/
static formatTime12(date: Date): string {
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${String(minutes).padStart(2, '0')} ${period}`;
}
/**
* Convert minutes since midnight to time string
* @param minutes - Minutes since midnight
* @returns Time string
*/
static minutesToTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${String(mins).padStart(2, '0')} ${period}`;
}
/**
* Convert time string to minutes since midnight
* @param timeStr - Time string
* @returns Minutes since midnight
*/
static timeToMinutes(timeStr: string): number {
const [time] = timeStr.split('T').pop()!.split('.');
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
/**
* Get minutes since start of day
* @param date - Date or ISO string
* @returns Minutes since midnight
*/
static getMinutesSinceMidnight(date: Date | string): number {
const d = typeof date === 'string' ? new Date(date) : date;
return d.getHours() * 60 + d.getMinutes();
}
/**
* Calculate duration in minutes between two dates
* @param start - Start date or ISO string
* @param end - End date or ISO string
* @returns Duration in minutes
*/
static getDurationMinutes(start: Date | string, end: Date | string): number {
const startDate = typeof start === 'string' ? new Date(start) : start;
const endDate = typeof end === 'string' ? new Date(end) : end;
return Math.floor((endDate.getTime() - startDate.getTime()) / 60000);
}
/**
* Check if two dates are on the same day
* @param date1 - First date
* @param date2 - Second date
* @returns True if same day
*/
static isSameDay(date1: Date, date2: Date): boolean {
return date1.toDateString() === date2.toDateString();
}
/**
* Check if event spans multiple days
* @param start - Start date or ISO string
* @param end - End date or ISO string
* @returns True if spans multiple days
*/
static isMultiDay(start: Date | string, end: Date | string): boolean {
const startDate = typeof start === 'string' ? new Date(start) : start;
const endDate = typeof end === 'string' ? new Date(end) : end;
return !DateCalculator.isSameDay(startDate, endDate);
}
// Legacy constructor for backward compatibility
constructor() {
// Empty constructor - all methods are now static
}
}
// Legacy factory function - deprecated, use static methods instead
export function createDateCalculator(config: CalendarConfig): DateCalculator {
DateCalculator.initialize(config);
return new DateCalculator();
}

508
src/utils/DateService.ts Normal file
View file

@ -0,0 +1,508 @@
/**
* DateService - Unified date/time service using date-fns
* Handles all date operations, timezone conversions, and formatting
*/
import {
format,
parse,
addMinutes,
differenceInMinutes,
startOfDay,
endOfDay,
setHours,
setMinutes as setMins,
getHours,
getMinutes,
parseISO,
isValid,
addDays,
startOfWeek,
endOfWeek,
addWeeks,
addMonths,
isSameDay,
getISOWeek
} from 'date-fns';
import {
toZonedTime,
fromZonedTime,
formatInTimeZone
} from 'date-fns-tz';
export class DateService {
private timezone: string;
constructor(timezone: string = 'Europe/Copenhagen') {
this.timezone = timezone;
}
// ============================================
// CORE CONVERSIONS
// ============================================
/**
* Convert local date to UTC ISO string
* @param localDate - Date in local timezone
* @returns ISO string in UTC (with 'Z' suffix)
*/
public toUTC(localDate: Date): string {
return fromZonedTime(localDate, this.timezone).toISOString();
}
/**
* Convert UTC ISO string to local date
* @param utcString - ISO string in UTC
* @returns Date in local timezone
*/
public fromUTC(utcString: string): Date {
return toZonedTime(parseISO(utcString), this.timezone);
}
// ============================================
// FORMATTING
// ============================================
/**
* Format time as HH:mm or HH:mm:ss
* @param date - Date to format
* @param showSeconds - Include seconds in output
* @returns Formatted time string
*/
public formatTime(date: Date, showSeconds = false): string {
const pattern = showSeconds ? 'HH:mm:ss' : 'HH:mm';
return format(date, pattern);
}
/**
* Format time range as "HH:mm - HH:mm"
* @param start - Start date
* @param end - End date
* @returns Formatted time range
*/
public formatTimeRange(start: Date, end: Date): string {
return `${this.formatTime(start)} - ${this.formatTime(end)}`;
}
/**
* Format date and time in technical format: yyyy-MM-dd HH:mm:ss
* @param date - Date to format
* @returns Technical datetime string
*/
public formatTechnicalDateTime(date: Date): string {
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
/**
* Format date as yyyy-MM-dd
* @param date - Date to format
* @returns ISO date string
*/
public formatDate(date: Date): string {
return format(date, 'yyyy-MM-dd');
}
/**
* Format date as "Month Year" (e.g., "January 2025")
* @param date - Date to format
* @param locale - Locale for month name (default: 'en-US')
* @returns Formatted month and year
*/
public formatMonthYear(date: Date, locale: string = 'en-US'): string {
return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' });
}
/**
* Format date as ISO string (same as formatDate for compatibility)
* @param date - Date to format
* @returns ISO date string
*/
public formatISODate(date: Date): string {
return this.formatDate(date);
}
/**
* Format time in 12-hour format with AM/PM
* @param date - Date to format
* @returns Time string in 12-hour format (e.g., "2:30 PM")
*/
public formatTime12(date: Date): string {
const hours = getHours(date);
const minutes = getMinutes(date);
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${String(minutes).padStart(2, '0')} ${period}`;
}
/**
* Get day name for a date
* @param date - Date to get day name for
* @param format - 'short' (e.g., 'Mon') or 'long' (e.g., 'Monday')
* @returns Day name
*/
public getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
const formatter = new Intl.DateTimeFormat('en-US', {
weekday: format
});
return formatter.format(date);
}
/**
* Format a date range with customizable options
* @param start - Start date
* @param end - End date
* @param options - Formatting options
* @returns Formatted date range string
*/
public formatDateRange(
start: Date,
end: Date,
options: {
locale?: string;
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
day?: 'numeric' | '2-digit';
year?: 'numeric' | '2-digit';
} = {}
): string {
const { locale = 'en-US', month = 'short', day = 'numeric' } = options;
const startYear = start.getFullYear();
const endYear = end.getFullYear();
const formatter = new Intl.DateTimeFormat(locale, {
month,
day,
year: startYear !== endYear ? 'numeric' : undefined
});
// @ts-ignore - formatRange is available in modern browsers
if (typeof formatter.formatRange === 'function') {
// @ts-ignore
return formatter.formatRange(start, end);
}
return `${formatter.format(start)} - ${formatter.format(end)}`;
}
// ============================================
// TIME CALCULATIONS
// ============================================
/**
* Convert time string (HH:mm or HH:mm:ss) to total minutes since midnight
* @param timeString - Time in format HH:mm or HH:mm:ss
* @returns Total minutes since midnight
*/
public timeToMinutes(timeString: string): number {
const parts = timeString.split(':').map(Number);
const hours = parts[0] || 0;
const minutes = parts[1] || 0;
return hours * 60 + minutes;
}
/**
* Convert total minutes since midnight to time string HH:mm
* @param totalMinutes - Minutes since midnight
* @returns Time string in format HH:mm
*/
public minutesToTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const date = setMins(setHours(new Date(), hours), minutes);
return format(date, 'HH:mm');
}
/**
* Format time from total minutes (alias for minutesToTime)
* @param totalMinutes - Minutes since midnight
* @returns Time string in format HH:mm
*/
public formatTimeFromMinutes(totalMinutes: number): string {
return this.minutesToTime(totalMinutes);
}
/**
* Get minutes since midnight for a given date
* @param date - Date to calculate from
* @returns Minutes since midnight
*/
public getMinutesSinceMidnight(date: Date): number {
return getHours(date) * 60 + getMinutes(date);
}
/**
* Calculate duration in minutes between two dates
* @param start - Start date or ISO string
* @param end - End date or ISO string
* @returns Duration in minutes
*/
public getDurationMinutes(start: Date | string, end: Date | string): number {
const startDate = typeof start === 'string' ? parseISO(start) : start;
const endDate = typeof end === 'string' ? parseISO(end) : end;
return differenceInMinutes(endDate, startDate);
}
// ============================================
// WEEK OPERATIONS
// ============================================
/**
* Get start and end of week (Monday to Sunday)
* @param date - Reference date
* @returns Object with start and end dates
*/
public getWeekBounds(date: Date): { start: Date; end: Date } {
return {
start: startOfWeek(date, { weekStartsOn: 1 }), // Monday
end: endOfWeek(date, { weekStartsOn: 1 }) // Sunday
};
}
/**
* Add weeks to a date
* @param date - Base date
* @param weeks - Number of weeks to add (can be negative)
* @returns New date
*/
public addWeeks(date: Date, weeks: number): Date {
return addWeeks(date, weeks);
}
/**
* Add months to a date
* @param date - Base date
* @param months - Number of months to add (can be negative)
* @returns New date
*/
public addMonths(date: Date, months: number): Date {
return addMonths(date, months);
}
/**
* Get ISO week number (1-53)
* @param date - Date to get week number for
* @returns ISO week number
*/
public getWeekNumber(date: Date): number {
return getISOWeek(date);
}
/**
* Get all dates in a full week (7 days starting from given date)
* @param weekStart - Start date of the week
* @returns Array of 7 dates
*/
public getFullWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(this.addDays(weekStart, i));
}
return dates;
}
/**
* Get dates for work week using ISO 8601 day numbering (Monday=1, Sunday=7)
* @param weekStart - Any date in the week
* @param workDays - Array of ISO day numbers (1=Monday, 7=Sunday)
* @returns Array of dates for the specified work days
*/
public getWorkWeekDates(weekStart: Date, workDays: number[]): Date[] {
const dates: Date[] = [];
// Get Monday of the week
const weekBounds = this.getWeekBounds(weekStart);
const mondayOfWeek = this.startOfDay(weekBounds.start);
// Calculate dates for each work day using ISO numbering
workDays.forEach(isoDay => {
const date = new Date(mondayOfWeek);
// ISO day 1=Monday is +0 days, ISO day 7=Sunday is +6 days
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
date.setDate(mondayOfWeek.getDate() + daysFromMonday);
dates.push(date);
});
return dates;
}
// ============================================
// GRID HELPERS
// ============================================
/**
* Create a date at a specific time (minutes since midnight)
* @param baseDate - Base date (date component)
* @param totalMinutes - Minutes since midnight
* @returns New date with specified time
*/
public createDateAtTime(baseDate: Date, totalMinutes: number): Date {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return setMins(setHours(startOfDay(baseDate), hours), minutes);
}
/**
* Snap date to nearest interval
* @param date - Date to snap
* @param intervalMinutes - Snap interval in minutes
* @returns Snapped date
*/
public snapToInterval(date: Date, intervalMinutes: number): Date {
const minutes = this.getMinutesSinceMidnight(date);
const snappedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes;
return this.createDateAtTime(date, snappedMinutes);
}
// ============================================
// UTILITY METHODS
// ============================================
/**
* Check if two dates are the same day
* @param date1 - First date
* @param date2 - Second date
* @returns True if same day
*/
public isSameDay(date1: Date, date2: Date): boolean {
return isSameDay(date1, date2);
}
/**
* Get start of day
* @param date - Date
* @returns Start of day (00:00:00)
*/
public startOfDay(date: Date): Date {
return startOfDay(date);
}
/**
* Get end of day
* @param date - Date
* @returns End of day (23:59:59.999)
*/
public endOfDay(date: Date): Date {
return endOfDay(date);
}
/**
* Add days to a date
* @param date - Base date
* @param days - Number of days to add (can be negative)
* @returns New date
*/
public addDays(date: Date, days: number): Date {
return addDays(date, days);
}
/**
* Add minutes to a date
* @param date - Base date
* @param minutes - Number of minutes to add (can be negative)
* @returns New date
*/
public addMinutes(date: Date, minutes: number): Date {
return addMinutes(date, minutes);
}
/**
* Parse ISO string to date
* @param isoString - ISO date string
* @returns Parsed date
*/
public parseISO(isoString: string): Date {
return parseISO(isoString);
}
/**
* Check if date is valid
* @param date - Date to check
* @returns True if valid
*/
public isValid(date: Date): boolean {
return isValid(date);
}
/**
* Validate date range (start must be before or equal to end)
* @param start - Start date
* @param end - End date
* @returns True if valid range
*/
public isValidRange(start: Date, end: Date): boolean {
if (!this.isValid(start) || !this.isValid(end)) {
return false;
}
return start.getTime() <= end.getTime();
}
/**
* Check if date is within reasonable bounds (1900-2100)
* @param date - Date to check
* @returns True if within bounds
*/
public isWithinBounds(date: Date): boolean {
if (!this.isValid(date)) {
return false;
}
const year = date.getFullYear();
return year >= 1900 && year <= 2100;
}
/**
* Validate date with comprehensive checks
* @param date - Date to validate
* @param options - Validation options
* @returns Validation result with error message
*/
public validateDate(
date: Date,
options: {
requireFuture?: boolean;
requirePast?: boolean;
minDate?: Date;
maxDate?: Date;
} = {}
): { valid: boolean; error?: string } {
if (!this.isValid(date)) {
return { valid: false, error: 'Invalid date' };
}
if (!this.isWithinBounds(date)) {
return { valid: false, error: 'Date out of bounds (1900-2100)' };
}
const now = new Date();
if (options.requireFuture && date <= now) {
return { valid: false, error: 'Date must be in the future' };
}
if (options.requirePast && date >= now) {
return { valid: false, error: 'Date must be in the past' };
}
if (options.minDate && date < options.minDate) {
return { valid: false, error: `Date must be after ${this.formatDate(options.minDate)}` };
}
if (options.maxDate && date > options.maxDate) {
return { valid: false, error: `Date must be before ${this.formatDate(options.maxDate)}` };
}
return { valid: true };
}
/**
* Check if event spans multiple days
* @param start - Start date or ISO string
* @param end - End date or ISO string
* @returns True if spans multiple days
*/
public isMultiDay(start: Date | string, end: Date | string): boolean {
const startDate = typeof start === 'string' ? this.parseISO(start) : start;
const endDate = typeof end === 'string' ? this.parseISO(end) : end;
return !this.isSameDay(startDate, endDate);
}
}

View file

@ -1,11 +1,17 @@
import { calendarConfig } from '../core/CalendarConfig.js';
import { DateCalculator } from './DateCalculator.js';
import { calendarConfig } from '../core/CalendarConfig';
import { ColumnBounds } from './ColumnDetectionUtils';
import { DateService } from './DateService';
import { TimeFormatter } from './TimeFormatter';
/**
* PositionUtils - Static positioning utilities using singleton calendarConfig
* Focuses on pixel/position calculations while delegating date operations
*
* Note: Uses DateService with date-fns for all date/time operations
*/
export class PositionUtils {
private static dateService = new DateService('Europe/Copenhagen');
/**
* Convert minutes to pixels
*/
@ -25,10 +31,10 @@ export class PositionUtils {
}
/**
* Convert time (HH:MM) to pixels from day start using DateCalculator
* Convert time (HH:MM) to pixels from day start using DateService
*/
public static timeToPixels(timeString: string): number {
const totalMinutes = DateCalculator.timeToMinutes(timeString);
const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString);
const gridSettings = calendarConfig.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const minutesFromDayStart = totalMinutes - dayStartMinutes;
@ -37,10 +43,10 @@ export class PositionUtils {
}
/**
* Convert Date object to pixels from day start using DateCalculator
* Convert Date object to pixels from day start using DateService
*/
public static dateToPixels(date: Date): number {
const totalMinutes = DateCalculator.getMinutesSinceMidnight(date);
const totalMinutes = PositionUtils.dateService.getMinutesSinceMidnight(date);
const gridSettings = calendarConfig.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60;
const minutesFromDayStart = totalMinutes - dayStartMinutes;
@ -49,7 +55,7 @@ export class PositionUtils {
}
/**
* Convert pixels to time using DateCalculator
* Convert pixels to time using DateService
*/
public static pixelsToTime(pixels: number): string {
const minutes = PositionUtils.pixelsToMinutes(pixels);
@ -57,7 +63,7 @@ export class PositionUtils {
const dayStartMinutes = gridSettings.dayStartHour * 60;
const totalMinutes = dayStartMinutes + minutes;
return DateCalculator.minutesToTime(totalMinutes);
return PositionUtils.dateService.minutesToTime(totalMinutes);
}
/**
@ -105,15 +111,15 @@ export class PositionUtils {
}
/**
* Snap time to interval using DateCalculator
* Snap time to interval using DateService
*/
public static snapTimeToInterval(timeString: string): string {
const totalMinutes = DateCalculator.timeToMinutes(timeString);
const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString);
const gridSettings = calendarConfig.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval;
return DateCalculator.minutesToTime(snappedMinutes);
return PositionUtils.dateService.minutesToTime(snappedMinutes);
}
/**
@ -157,22 +163,14 @@ export class PositionUtils {
/**
* Beregn Y position fra mouse/touch koordinat
*/
public static getPositionFromCoordinate(clientY: number, containerElement: HTMLElement): number {
const rect = containerElement.getBoundingClientRect();
const relativeY = clientY - rect.top;
public static getPositionFromCoordinate(clientY: number, column: ColumnBounds): number {
const relativeY = clientY - column.boundingClientRect.top;
// Snap til grid
return PositionUtils.snapToGrid(relativeY);
}
/**
* Beregn tid fra mouse/touch koordinat
*/
public static getTimeFromCoordinate(clientY: number, containerElement: HTMLElement): string {
const position = PositionUtils.getPositionFromCoordinate(clientY, containerElement);
return PositionUtils.pixelsToTime(position);
}
/**
* Valider at tid er inden for arbejdstimer
*/
@ -216,32 +214,27 @@ export class PositionUtils {
}
/**
* Convert ISO datetime to time string using DateCalculator
* Convert ISO datetime to time string with UTC-to-local conversion
*/
public static isoToTimeString(isoString: string): string {
const date = new Date(isoString);
return DateCalculator.formatTime(date);
return TimeFormatter.formatTime(date);
}
/**
* Convert time string to ISO datetime using DateCalculator
* Convert time string to ISO datetime using DateService with timezone handling
*/
public static timeStringToIso(timeString: string, date: Date = new Date()): string {
const totalMinutes = DateCalculator.timeToMinutes(timeString);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const newDate = new Date(date);
newDate.setHours(hours, minutes, 0, 0);
return newDate.toISOString();
const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString);
const newDate = PositionUtils.dateService.createDateAtTime(date, totalMinutes);
return PositionUtils.dateService.toUTC(newDate);
}
/**
* Calculate event duration using DateCalculator
* Calculate event duration using DateService
*/
public static calculateDuration(startTime: string | Date, endTime: string | Date): number {
return DateCalculator.getDurationMinutes(startTime, endTime);
return PositionUtils.dateService.getDurationMinutes(startTime, endTime);
}
/**

222
src/utils/TimeFormatter.ts Normal file
View file

@ -0,0 +1,222 @@
/**
* TimeFormatter - Centralized time formatting with timezone support
* Now uses DateService internally for all date/time operations
*
* Handles conversion from UTC/Zulu time to configured timezone (default: Europe/Copenhagen)
* Supports both 12-hour and 24-hour format configuration
*
* All events in the system are stored in UTC and must be converted to local timezone
*/
import { DateService } from './DateService';
export interface TimeFormatSettings {
timezone: string;
use24HourFormat: boolean;
locale: string;
dateFormat: 'locale' | 'technical';
showSeconds: boolean;
}
export class TimeFormatter {
private static settings: TimeFormatSettings = {
timezone: 'Europe/Copenhagen', // Default to Denmark
use24HourFormat: true, // 24-hour format standard in Denmark
locale: 'da-DK', // Danish locale
dateFormat: 'technical', // Use technical format yyyy-mm-dd hh:mm:ss
showSeconds: false // Don't show seconds by default
};
private static dateService: DateService = new DateService('Europe/Copenhagen');
/**
* Configure time formatting settings
*/
static configure(settings: Partial<TimeFormatSettings>): void {
TimeFormatter.settings = { ...TimeFormatter.settings, ...settings };
// Update DateService with new timezone
TimeFormatter.dateService = new DateService(TimeFormatter.settings.timezone);
}
/**
* Get current time format settings
*/
static getSettings(): TimeFormatSettings {
return { ...TimeFormatter.settings };
}
/**
* Convert UTC date to configured timezone
* @param utcDate - Date in UTC (or ISO string)
* @returns Date object adjusted to configured timezone
*/
static convertToLocalTime(utcDate: Date | string): Date {
if (typeof utcDate === 'string') {
return TimeFormatter.dateService.fromUTC(utcDate);
}
// If it's already a Date object, convert to UTC string first, then back to local
const utcString = utcDate.toISOString();
return TimeFormatter.dateService.fromUTC(utcString);
}
/**
* Get timezone offset for configured timezone
* @param date - Reference date for calculating offset (handles DST)
* @returns Offset in minutes
*/
static getTimezoneOffset(date: Date = new Date()): number {
const utc = new Date(date.getTime() + (date.getTimezoneOffset() * 60000));
const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: TimeFormatter.settings.timezone }));
return (targetTime.getTime() - utc.getTime()) / 60000;
}
/**
* Format time in 12-hour format
* @param date - Date to format
* @returns Formatted time string (e.g., "9:00 AM")
*/
static format12Hour(date: Date): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return localDate.toLocaleTimeString(TimeFormatter.settings.locale, {
timeZone: TimeFormatter.settings.timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format time in 24-hour format using DateService
* @param date - Date to format
* @returns Formatted time string (e.g., "09:00")
*/
static format24Hour(date: Date): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return TimeFormatter.dateService.formatTime(localDate, TimeFormatter.settings.showSeconds);
}
/**
* Format time according to current configuration
* @param date - Date to format
* @returns Formatted time string
*/
static formatTime(date: Date): string {
return TimeFormatter.settings.use24HourFormat
? TimeFormatter.format24Hour(date)
: TimeFormatter.format12Hour(date);
}
/**
* Format time from total minutes since midnight using DateService
* @param totalMinutes - Minutes since midnight (e.g., 540 for 9:00 AM)
* @returns Formatted time string
*/
static formatTimeFromMinutes(totalMinutes: number): string {
return TimeFormatter.dateService.formatTimeFromMinutes(totalMinutes);
}
/**
* Format date and time together
* @param date - Date to format
* @returns Formatted date and time string
*/
static formatDateTime(date: Date): string {
const localDate = TimeFormatter.convertToLocalTime(date);
const dateStr = localDate.toLocaleDateString(TimeFormatter.settings.locale, {
timeZone: TimeFormatter.settings.timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const timeStr = TimeFormatter.formatTime(date);
return `${dateStr} ${timeStr}`;
}
/**
* Format time range (start - end) using DateService
* @param startDate - Start date
* @param endDate - End date
* @returns Formatted time range string (e.g., "09:00 - 10:30")
*/
static formatTimeRange(startDate: Date, endDate: Date): string {
const localStart = TimeFormatter.convertToLocalTime(startDate);
const localEnd = TimeFormatter.convertToLocalTime(endDate);
return TimeFormatter.dateService.formatTimeRange(localStart, localEnd);
}
/**
* Check if current timezone observes daylight saving time
* @param date - Reference date
* @returns True if DST is active
*/
static isDaylightSavingTime(date: Date = new Date()): boolean {
const january = new Date(date.getFullYear(), 0, 1);
const july = new Date(date.getFullYear(), 6, 1);
const janOffset = TimeFormatter.getTimezoneOffset(january);
const julOffset = TimeFormatter.getTimezoneOffset(july);
return Math.max(janOffset, julOffset) !== TimeFormatter.getTimezoneOffset(date);
}
/**
* Get timezone abbreviation (e.g., "CET", "CEST")
* @param date - Reference date
* @returns Timezone abbreviation
*/
static getTimezoneAbbreviation(date: Date = new Date()): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return localDate.toLocaleTimeString('en-US', {
timeZone: TimeFormatter.settings.timezone,
timeZoneName: 'short'
}).split(' ').pop() || '';
}
/**
* Format date in technical format: yyyy-mm-dd using DateService
*/
static formatDateTechnical(date: Date): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return TimeFormatter.dateService.formatDate(localDate);
}
/**
* Format time in technical format: hh:mm or hh:mm:ss using DateService
*/
static formatTimeTechnical(date: Date, includeSeconds: boolean = false): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return TimeFormatter.dateService.formatTime(localDate, includeSeconds);
}
/**
* Format date and time in technical format: yyyy-mm-dd hh:mm:ss using DateService
*/
static formatDateTimeTechnical(date: Date): string {
const localDate = TimeFormatter.convertToLocalTime(date);
return TimeFormatter.dateService.formatTechnicalDateTime(localDate);
}
/**
* Convert local date to UTC ISO string using DateService
* @param localDate - Date in local timezone
* @returns ISO string in UTC (with 'Z' suffix)
*/
static toUTC(localDate: Date): string {
return TimeFormatter.dateService.toUTC(localDate);
}
/**
* Convert UTC ISO string to local date using DateService
* @param utcString - ISO string in UTC
* @returns Date in local timezone
*/
static fromUTC(utcString: string): Date {
return TimeFormatter.dateService.fromUTC(utcString);
}
}

View file

@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Stacking Scenarios - Test Suite</title>
<link rel="stylesheet" href="scenarios/scenario-styles.css">
<style>
.scenario-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.scenario-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.scenario-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.scenario-card h3 {
margin-top: 0;
color: #b53f7a;
}
.scenario-card p {
color: #666;
line-height: 1.6;
margin-bottom: 15px;
}
.scenario-link {
display: inline-block;
padding: 10px 20px;
background: #b53f7a;
color: white;
text-decoration: none;
border-radius: 4px;
font-weight: 500;
transition: background 0.2s;
}
.scenario-link:hover {
background: #8e3260;
}
.intro {
background: #f8f9fa;
padding: 20px;
border-left: 4px solid #b53f7a;
margin-bottom: 30px;
border-radius: 4px;
}
.intro h2 {
margin-top: 0;
}
</style>
</head>
<body>
<div class="scenario-container">
<h1>Event Stacking & Grid Layout - Test Scenarios</h1>
<div class="intro">
<h2>About This Test Suite</h2>
<p>
This test suite validates the event layout algorithm for the Calendar Plantempus application.
The algorithm determines how overlapping events should be rendered using two strategies:
</p>
<ul>
<li><strong>GRID Layout:</strong> Events that start within a threshold (±30 minutes) are placed in a grid container where they can share columns</li>
<li><strong>STACKED Layout:</strong> Events are stacked with horizontal offsets (15px per level)</li>
</ul>
<p>
Each scenario tests a specific edge case or layout pattern. Click on a scenario below to view the visual representation and test results.
</p>
</div>
<div class="scenario-grid">
<div class="scenario-card">
<h3>Scenario 1: No Overlap</h3>
<p>Three sequential events with no time overlap. All should have stack level 0.</p>
<a href="scenarios/scenario-1.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 2: Column Sharing (Grid)</h3>
<p>Two events starting at same time (10:00) - should share columns in a grid with 2 columns.</p>
<a href="scenarios/scenario-2.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 3: Nested Stacking</h3>
<p>Events with progressive nesting: A contains B, B contains C, C and D overlap. Tests stack level calculation.</p>
<a href="scenarios/scenario-3.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 4: Complex Stacking</h3>
<p>Long event (A) with multiple shorter events (B, C, D) nested inside at different times.</p>
<a href="scenarios/scenario-4.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 5: Three Column Share</h3>
<p>Three events all starting at 10:00 - should create a 3-column grid layout.</p>
<a href="scenarios/scenario-5.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 6: Overlapping Pairs</h3>
<p>Two separate pairs of overlapping events. Each pair should be independent.</p>
<a href="scenarios/scenario-6.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 7: Long Event Container</h3>
<p>One long event (A) containing two shorter events (B, C) that don't overlap each other.</p>
<a href="scenarios/scenario-7.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 8: Edge-Adjacent Events</h3>
<p>Events that touch but don't overlap (A ends when B starts). Should not stack.</p>
<a href="scenarios/scenario-8.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 9: End-to-Start Chain</h3>
<p>Events linked by end-to-start conflicts within threshold. Tests conflict chain detection.</p>
<a href="scenarios/scenario-9.html" class="scenario-link">View Test →</a>
</div>
<div class="scenario-card">
<h3>Scenario 10: Four Column Grid</h3>
<p>Four events all starting at same time - maximum column sharing test.</p>
<a href="scenarios/scenario-10.html" class="scenario-link">View Test →</a>
</div>
</div>
</div>
</body>
</html>

1836
stacking-visualization.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
import { CalendarEvent } from '../../src/types/CalendarTypes';
/**
* Setup mock DOM for testing
*/
export function setupMockDOM(): void {
// Create basic DOM structure for testing
document.body.innerHTML = `
<div class="swp-calendar-grid" data-current="true">
<div class="swp-day-header" data-date="2024-09-22"></div>
<div class="swp-day-header" data-date="2024-09-23"></div>
<div class="swp-day-header" data-date="2024-09-24"></div>
<div class="swp-day-header" data-date="2024-09-25"></div>
<div class="swp-day-header" data-date="2024-09-26"></div>
</div>
<swp-calendar-header>
<swp-allday-container></swp-allday-container>
</swp-calendar-header>
`;
}
/**
* Create mock CalendarEvent for testing
*/
export function createMockEvent(
id: string,
title: string,
startDate: string,
endDate: string
): CalendarEvent {
return {
id,
title,
start: new Date(startDate),
end: new Date(endDate),
type: 'work',
allDay: true,
syncStatus: 'synced'
};
}

View file

@ -0,0 +1,270 @@
import { describe, it, expect } from 'vitest';
import { AllDayLayoutEngine } from '../../src/utils/AllDayLayoutEngine';
import { CalendarEvent } from '../../src/types/CalendarTypes';
describe('AllDay Layout Engine - Pure Data Tests', () => {
const weekDates = [
'2025-09-22', '2025-09-23', '2025-09-24', '2025-09-25',
'2025-09-26', '2025-09-27', '2025-09-28'
];
const layoutEngine = new AllDayLayoutEngine(weekDates);
// Test data: events med start/end datoer og forventet grid-area
const testCases = [
{
name: 'Single day events - no overlap',
events: [
{ id: '1', title: 'Event 1', start: new Date('2025-09-22'), end: new Date('2025-09-22'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '2', title: 'Event 2', start: new Date('2025-09-24'), end: new Date('2025-09-24'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: '1', gridArea: '1 / 1 / 2 / 2' }, // row 1, column 1 (Sept 22)
{ id: '2', gridArea: '1 / 3 / 2 / 4' } // row 1, column 3 (Sept 24)
]
},
{
name: 'Overlapping multi-day events - Autumn Equinox vs Teknisk Workshop',
events: [
{ id: 'autumn', title: 'Autumn Equinox', start: new Date('2025-09-22'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: 'workshop', title: 'Teknisk Workshop', start: new Date('2025-09-23'), end: new Date('2025-09-26'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: 'autumn', gridArea: '1 / 1 / 2 / 3' }, // row 1, columns 1-2 (2 dage, processed first)
{ id: 'workshop', gridArea: '2 / 2 / 3 / 6' } // row 2, columns 2-5 (4 dage, processed second)
]
},
{
name: 'Multiple events same column',
events: [
{ id: '1', title: 'Event 1', start: new Date('2025-09-23'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '2', title: 'Event 2', start: new Date('2025-09-23'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '3', title: 'Event 3', start: new Date('2025-09-23'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: '1', gridArea: '1 / 2 / 2 / 3' }, // row 1, column 2 (Sept 23)
{ id: '2', gridArea: '2 / 2 / 3 / 3' }, // row 2, column 2 (Sept 23)
{ id: '3', gridArea: '3 / 2 / 4 / 3' } // row 3, column 2 (Sept 23)
]
},
{
name: 'Partial overlaps',
events: [
{ id: '1', title: 'Event 1', start: new Date('2025-09-22'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '2', title: 'Event 2', start: new Date('2025-09-23'), end: new Date('2025-09-24'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '3', title: 'Event 3', start: new Date('2025-09-25'), end: new Date('2025-09-26'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: '1', gridArea: '1 / 1 / 2 / 3' }, // row 1, columns 1-2 (Sept 22-23)
{ id: '2', gridArea: '2 / 2 / 3 / 4' }, // row 2, columns 2-3 (Sept 23-24, overlap på column 2)
{ id: '3', gridArea: '1 / 4 / 2 / 6' } // row 1, columns 4-5 (Sept 25-26, no overlap)
]
},
{
name: 'Complex overlapping pattern',
events: [
{ id: '1', title: 'Long Event', start: new Date('2025-09-22'), end: new Date('2025-09-25'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '2', title: 'Short Event', start: new Date('2025-09-23'), end: new Date('2025-09-24'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '3', title: 'Another Event', start: new Date('2025-09-24'), end: new Date('2025-09-26'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: '1', gridArea: '1 / 1 / 2 / 5' }, // row 1, columns 1-4 (4 dage, processed first)
{ id: '2', gridArea: '2 / 2 / 3 / 4' }, // row 2, columns 2-3 (2 dage, processed second)
{ id: '3', gridArea: '3 / 3 / 4 / 6' } // row 3, columns 3-5 (3 dage, processed third)
]
},
{
name: 'Real-world bug scenario - Multiple overlapping events (Sept 21-28)',
events: [
{ id: '112', title: 'Autumn Equinox', start: new Date('2025-09-22'), end: new Date('2025-09-23'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '122', title: 'Multi-Day Conference', start: new Date('2025-09-21'), end: new Date('2025-09-24'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '123', title: 'Project Sprint', start: new Date('2025-09-22'), end: new Date('2025-09-25'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '143', title: 'Weekend Hackathon', start: new Date('2025-09-26'), end: new Date('2025-09-28'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent,
{ id: '161', title: 'Teknisk Workshop', start: new Date('2025-09-23'), end: new Date('2025-09-26'), type: 'work', allDay: true, syncStatus: 'synced' } as CalendarEvent
],
expected: [
{ id: '112', gridArea: '1 / 1 / 2 / 3' }, // Autumn Equinox: row 1, columns 1-2 (2 dage, processed first)
{ id: '122', gridArea: '2 / 1 / 3 / 4' }, // Multi-Day Conference: row 2, columns 1-3 (4 dage, starts 21/9, processed second)
{ id: '123', gridArea: '3 / 1 / 4 / 5' }, // Project Sprint: row 3, columns 1-4 (4 dage, starts 22/9, processed third)
{ id: '143', gridArea: '1 / 5 / 2 / 8' }, // Weekend Hackathon: row 1, columns 5-7 (3 dage, no overlap, reuse row 1)
{ id: '161', gridArea: '4 / 2 / 5 / 6' } // Teknisk Workshop: row 4, columns 2-5 (4 dage, starts 23/9, processed fourth)
]
}
];
testCases.forEach(testCase => {
it(testCase.name, () => {
// Calculate actual layouts using AllDayLayoutEngine
const layouts = layoutEngine.calculateLayout(testCase.events);
// Verify we got layouts for all events
expect(layouts.length).toBe(testCase.events.length);
// Check each expected result
testCase.expected.forEach(expected => {
const actualLayout = layouts.find(layout => layout.calenderEvent.id === expected.id);
expect(actualLayout).toBeDefined();
expect(actualLayout!.gridArea).toBe(expected.gridArea);
});
});
});
it('Grid-area format validation', () => {
// Test at grid-area format er korrekt
const gridArea = '2 / 3 / 3 / 5'; // row 2, columns 3-4
const parts = gridArea.split(' / ');
const rowStart = parseInt(parts[0]); // 2
const colStart = parseInt(parts[1]); // 3
const rowEnd = parseInt(parts[2]); // 3
const colEnd = parseInt(parts[3]); // 5
expect(rowStart).toBe(2);
expect(colStart).toBe(3);
expect(rowEnd).toBe(3);
expect(colEnd).toBe(5);
// Verify spans
const rowSpan = rowEnd - rowStart; // 1 row
const colSpan = colEnd - colStart; // 2 columns
expect(rowSpan).toBe(1);
expect(colSpan).toBe(2);
});
});
describe('AllDay Layout Engine - Partial Week Views', () => {
describe('Date Range Filtering', () => {
it('should filter out events that do not overlap with visible dates', () => {
// 3-day workweek: Wed-Fri (like user's scenario)
const weekDates = ['2025-09-24', '2025-09-25', '2025-09-26']; // Wed, Thu, Fri
const engine = new AllDayLayoutEngine(weekDates);
const events: CalendarEvent[] = [
{
id: '112',
title: 'Autumn Equinox',
start: new Date('2025-09-22T00:00:00'), // Monday - OUTSIDE visible range
end: new Date('2025-09-24T00:00:00'), // Wednesday - OUTSIDE visible range
type: 'milestone',
allDay: true,
syncStatus: 'synced'
},
{
id: '113',
title: 'Visible Event',
start: new Date('2025-09-25T00:00:00'), // Thursday - INSIDE visible range
end: new Date('2025-09-26T00:00:00'), // Friday - INSIDE visible range
type: 'work',
allDay: true,
syncStatus: 'synced'
}
];
const layouts = engine.calculateLayout(events);
// Both events are now visible since '112' ends on Wednesday (visible range start)
expect(layouts.length).toBe(2);
expect(layouts.some(layout => layout.calenderEvent.id === '112')).toBe(true); // Now visible since it ends on Wed
expect(layouts.some(layout => layout.calenderEvent.id === '113')).toBe(true); // Still visible
const layout112 = layouts.find(layout => layout.calenderEvent.id === '112')!;
expect(layout112.startColumn).toBe(1); // Clipped to Wed (first visible day)
expect(layout112.endColumn).toBe(1); // Wed only
expect(layout112.row).toBe(1);
const layout113 = layouts.find(layout => layout.calenderEvent.id === '113')!;
expect(layout113.startColumn).toBe(2); // Thursday = column 2 in Wed-Fri view
expect(layout113.endColumn).toBe(3); // Friday = column 3
expect(layout113.row).toBe(1);
});
it('should clip events that partially overlap with visible dates', () => {
// 3-day workweek: Wed-Fri
const weekDates = ['2025-09-24', '2025-09-25', '2025-09-26']; // Wed, Thu, Fri
const engine = new AllDayLayoutEngine(weekDates);
const events: CalendarEvent[] = [
{
id: '114',
title: 'Spans Before and Into Week',
start: new Date('2025-09-22T00:00:00'), // Monday - before visible range
end: new Date('2025-09-26T00:00:00'), // Friday - inside visible range
type: 'work',
allDay: true,
syncStatus: 'synced'
},
{
id: '115',
title: 'Spans From Week and After',
start: new Date('2025-09-25T00:00:00'), // Thursday - inside visible range
end: new Date('2025-09-29T00:00:00'), // Monday - after visible range
type: 'work',
allDay: true,
syncStatus: 'synced'
}
];
const layouts = engine.calculateLayout(events);
expect(layouts.length).toBe(2);
// First event should be clipped to start at Wed (column 1) and end at Fri (column 3)
const firstLayout = layouts.find(layout => layout.calenderEvent.id === '114')!;
expect(firstLayout.startColumn).toBe(1); // Clipped to Wed (first visible day)
expect(firstLayout.endColumn).toBe(3); // Fri (now ends on Friday due to 2025-09-26T00:00:00)
expect(firstLayout.columnSpan).toBe(3);
expect(firstLayout.gridArea).toBe('1 / 1 / 2 / 4');
// Second event should span Thu-Fri, but clipped beyond visible range
const secondLayout = layouts.find(layout => layout.calenderEvent.id === '115')!;
expect(secondLayout.startColumn).toBe(2); // Thu (actual start date) = column 2 in Wed-Fri view
expect(secondLayout.endColumn).toBe(3); // Clipped to Fri (last visible day) = column 3
expect(secondLayout.columnSpan).toBe(2);
expect(secondLayout.gridArea).toBe('2 / 2 / 3 / 4'); // Row 2 due to overlap
});
it('should handle 5-day workweek correctly', () => {
// 5-day workweek: Mon-Fri
const weekDates = ['2025-09-22', '2025-09-23', '2025-09-24', '2025-09-25', '2025-09-26']; // Mon-Fri
const engine = new AllDayLayoutEngine(weekDates);
const events: CalendarEvent[] = [
{
id: '116',
title: 'Monday Event',
start: new Date('2025-09-22T00:00:00'), // Monday
end: new Date('2025-09-23T00:00:00'), // Tuesday
type: 'work',
allDay: true,
syncStatus: 'synced'
},
{
id: '117',
title: 'Weekend Event',
start: new Date('2025-09-27T00:00:00'), // Saturday - OUTSIDE visible range
end: new Date('2025-09-29T00:00:00'), // Monday - OUTSIDE visible range
type: 'personal',
allDay: true,
syncStatus: 'synced'
}
];
const layouts = engine.calculateLayout(events);
expect(layouts.length).toBe(1); // Only Monday event should be included - weekend event should be filtered out
expect(layouts.some(layout => layout.calenderEvent.id === '116')).toBe(true); // Monday event should be included
expect(layouts.some(layout => layout.calenderEvent.id === '117')).toBe(false); // Weekend event should be filtered out
const mondayLayout = layouts.find(layout => layout.calenderEvent.id === '116')!;
expect(mondayLayout.startColumn).toBe(1); // Monday = column 1
expect(mondayLayout.endColumn).toBe(2); // Now ends on Tuesday due to 2025-09-23T00:00:00
expect(mondayLayout.row).toBe(1);
expect(mondayLayout.gridArea).toBe('1 / 1 / 2 / 3');
});
});
});

View file

@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { AllDayLayoutEngine } from '../../src/utils/AllDayLayoutEngine';
import { setupMockDOM, createMockEvent } from '../helpers/dom-helpers';
describe('AllDayManager - Layout Engine Integration', () => {
let layoutEngine: AllDayLayoutEngine;
beforeEach(() => {
setupMockDOM();
});
describe('Layout Calculation Integration', () => {
it('should delegate layout calculation to AllDayLayoutEngine', () => {
// Test AllDayLayoutEngine directly since calculateAllDayEventsLayout is private
const event = createMockEvent('test', 'Test Event', '2024-09-24', '2024-09-24');
const weekDates = ['2024-09-22', '2024-09-23', '2024-09-24', '2024-09-25', '2024-09-26'];
layoutEngine = new AllDayLayoutEngine(weekDates);
const layouts = layoutEngine.calculateLayout([event]);
expect(layouts.length).toBe(1);
expect(layouts[0].calenderEvent.id).toBe('test');
expect(layouts[0].startColumn).toBe(3); // Sept 24 is column 3
expect(layouts[0].row).toBe(1);
});
it('should handle empty event list', () => {
const weekDates = ['2024-09-22', '2024-09-23', '2024-09-24', '2024-09-25', '2024-09-26'];
layoutEngine = new AllDayLayoutEngine(weekDates);
const layouts = layoutEngine.calculateLayout([]);
expect(layouts.length).toBe(0);
});
it('should handle empty week dates', () => {
const event = createMockEvent('test', 'Test Event', '2024-09-24', '2024-09-24');
layoutEngine = new AllDayLayoutEngine([]);
const layouts = layoutEngine.calculateLayout([event]);
expect(layouts.length).toBe(0);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,656 @@
/**
* TDD Test Suite for EventStackManager
*
* This test suite follows Test-Driven Development principles:
* 1. Write a failing test (RED)
* 2. Write minimal code to make it pass (GREEN)
* 3. Refactor if needed (REFACTOR)
*
* @see STACKING_CONCEPT.md for concept documentation
*
* NOTE: This test file is SKIPPED as it tests removed methods (createStackLinks, findOverlappingEvents)
* See EventStackManager.flexbox.test.ts for current implementation tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { EventStackManager, StackLink } from '../../src/managers/EventStackManager';
describe.skip('EventStackManager - TDD Suite (DEPRECATED - uses removed methods)', () => {
let manager: EventStackManager;
beforeEach(() => {
manager = new EventStackManager();
});
describe('Overlap Detection', () => {
it('should detect overlap when event A starts before event B ends and event A ends after event B starts', () => {
// RED - This test will fail initially
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00')
};
// Expected: true (events overlap from 10:00 to 11:00)
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
});
it('should return false when events do not overlap', () => {
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00')
};
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
});
it('should detect overlap when one event completely contains another', () => {
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T13:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
});
it('should return false when events touch but do not overlap', () => {
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'), // Exactly when A ends
end: new Date('2025-01-01T11:00:00')
};
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
});
});
describe('Find Overlapping Events', () => {
it('should find all events that overlap with a given event', () => {
const targetEvent = {
id: 'target',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const columnEvents = [
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:30:00') // Overlaps
},
{
id: 'event-b',
start: new Date('2025-01-01T12:00:00'),
end: new Date('2025-01-01T13:00:00') // Does not overlap
},
{
id: 'event-c',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:30:00') // Overlaps
}
];
const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents);
expect(overlapping).toHaveLength(2);
expect(overlapping.map(e => e.id)).toContain('event-a');
expect(overlapping.map(e => e.id)).toContain('event-c');
expect(overlapping.map(e => e.id)).not.toContain('event-b');
});
it('should return empty array when no events overlap', () => {
const targetEvent = {
id: 'target',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const columnEvents = [
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T09:30:00')
},
{
id: 'event-b',
start: new Date('2025-01-01T12:00:00'),
end: new Date('2025-01-01T13:00:00')
}
];
const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents);
expect(overlapping).toHaveLength(0);
});
});
describe('Create Stack Links', () => {
it('should create stack links for overlapping events sorted by start time', () => {
const events = [
{
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00')
},
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T11:00:00')
},
{
id: 'event-c',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T13:00:00')
}
];
const stackLinks = manager.createStackLinks(events);
// Should be sorted by start time: event-a, event-b, event-c
expect(stackLinks.size).toBe(3);
const linkA = stackLinks.get('event-a');
expect(linkA).toEqual({
stackLevel: 0,
next: 'event-b'
// no prev
});
const linkB = stackLinks.get('event-b');
expect(linkB).toEqual({
stackLevel: 1,
prev: 'event-a',
next: 'event-c'
});
const linkC = stackLinks.get('event-c');
expect(linkC).toEqual({
stackLevel: 2,
prev: 'event-b'
// no next
});
});
it('should return empty map for empty event array', () => {
const stackLinks = manager.createStackLinks([]);
expect(stackLinks.size).toBe(0);
});
it('should create single stack link for single event', () => {
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00')
}
];
const stackLinks = manager.createStackLinks(events);
expect(stackLinks.size).toBe(1);
const link = stackLinks.get('event-a');
expect(link).toEqual({
stackLevel: 0
// no prev, no next
});
});
it('should handle events with same start time by sorting by end time', () => {
const events = [
{
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00') // Longer event
},
{
id: 'event-a',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00') // Shorter event (should come first)
}
];
const stackLinks = manager.createStackLinks(events);
// Shorter event should have lower stack level
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
});
});
describe('Calculate Visual Styling', () => {
it('should calculate marginLeft based on stack level', () => {
const stackLevel = 0;
expect(manager.calculateMarginLeft(stackLevel)).toBe(0);
const stackLevel1 = 1;
expect(manager.calculateMarginLeft(stackLevel1)).toBe(15);
const stackLevel2 = 2;
expect(manager.calculateMarginLeft(stackLevel2)).toBe(30);
const stackLevel5 = 5;
expect(manager.calculateMarginLeft(stackLevel5)).toBe(75);
});
it('should calculate zIndex based on stack level', () => {
const stackLevel = 0;
expect(manager.calculateZIndex(stackLevel)).toBe(100);
const stackLevel1 = 1;
expect(manager.calculateZIndex(stackLevel1)).toBe(101);
const stackLevel2 = 2;
expect(manager.calculateZIndex(stackLevel2)).toBe(102);
});
});
describe('Stack Link Serialization', () => {
it('should serialize stack link to JSON string', () => {
const stackLink: StackLink = {
stackLevel: 1,
prev: 'event-a',
next: 'event-c'
};
const serialized = manager.serializeStackLink(stackLink);
expect(serialized).toBe('{"stackLevel":1,"prev":"event-a","next":"event-c"}');
});
it('should deserialize JSON string to stack link', () => {
const json = '{"stackLevel":1,"prev":"event-a","next":"event-c"}';
const stackLink = manager.deserializeStackLink(json);
expect(stackLink).toEqual({
stackLevel: 1,
prev: 'event-a',
next: 'event-c'
});
});
it('should handle stack link without prev/next', () => {
const stackLink: StackLink = {
stackLevel: 0
};
const serialized = manager.serializeStackLink(stackLink);
const deserialized = manager.deserializeStackLink(serialized);
expect(deserialized).toEqual({
stackLevel: 0
});
});
it('should return null when deserializing invalid JSON', () => {
const invalid = 'not-valid-json';
const result = manager.deserializeStackLink(invalid);
expect(result).toBeNull();
});
});
describe('DOM Integration', () => {
it('should apply stack link to DOM element', () => {
const element = document.createElement('div');
element.dataset.eventId = 'event-a';
const stackLink: StackLink = {
stackLevel: 1,
prev: 'event-b',
next: 'event-c'
};
manager.applyStackLinkToElement(element, stackLink);
expect(element.dataset.stackLink).toBe('{"stackLevel":1,"prev":"event-b","next":"event-c"}');
});
it('should read stack link from DOM element', () => {
const element = document.createElement('div');
element.dataset.stackLink = '{"stackLevel":2,"prev":"event-a"}';
const stackLink = manager.getStackLinkFromElement(element);
expect(stackLink).toEqual({
stackLevel: 2,
prev: 'event-a'
});
});
it('should return null when element has no stack link', () => {
const element = document.createElement('div');
const stackLink = manager.getStackLinkFromElement(element);
expect(stackLink).toBeNull();
});
it('should apply visual styling to element based on stack level', () => {
const element = document.createElement('div');
manager.applyVisualStyling(element, 2);
expect(element.style.marginLeft).toBe('30px');
expect(element.style.zIndex).toBe('102');
});
it('should clear stack link from element', () => {
const element = document.createElement('div');
element.dataset.stackLink = '{"stackLevel":1}';
manager.clearStackLinkFromElement(element);
expect(element.dataset.stackLink).toBeUndefined();
});
it('should clear visual styling from element', () => {
const element = document.createElement('div');
element.style.marginLeft = '30px';
element.style.zIndex = '102';
manager.clearVisualStyling(element);
expect(element.style.marginLeft).toBe('');
expect(element.style.zIndex).toBe('');
});
});
describe('Edge Cases', () => {
it('should optimize stack levels when events do not overlap each other but both overlap a parent event', () => {
// Visual representation:
// Event A: 09:00 ════════════════════════════ 14:00
// Event B: 10:00 ═════ 12:00
// Event C: 12:30 ═══ 13:00
//
// Expected stacking:
// Event A: stackLevel 0 (base)
// Event B: stackLevel 1 (conflicts with A)
// Event C: stackLevel 1 (conflicts with A, but NOT with B - can share same level!)
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T14:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00')
};
const eventC = {
id: 'event-c',
start: new Date('2025-01-01T12:30:00'),
end: new Date('2025-01-01T13:00:00')
};
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC]);
expect(stackLinks.size).toBe(3);
// Event A is the base (contains both B and C)
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
// Event B and C should both be at stackLevel 1 (they don't overlap each other)
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
expect(stackLinks.get('event-c')?.stackLevel).toBe(1);
// Verify they are NOT linked to each other (no prev/next between B and C)
expect(stackLinks.get('event-b')?.next).toBeUndefined();
expect(stackLinks.get('event-c')?.prev).toBeUndefined();
});
it('should create multiple parallel tracks when events at same level do not overlap', () => {
// Complex scenario with multiple parallel tracks:
// Event A: 09:00 ════════════════════════════════════ 15:00
// Event B: 10:00 ═══ 11:00
// Event C: 11:30 ═══ 12:30
// Event D: 13:00 ═══ 14:00
//
// Expected:
// - A at level 0 (base)
// - B, C, D all at level 1 (they don't overlap each other, only with A)
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T15:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const eventC = {
id: 'event-c',
start: new Date('2025-01-01T11:30:00'),
end: new Date('2025-01-01T12:30:00')
};
const eventD = {
id: 'event-d',
start: new Date('2025-01-01T13:00:00'),
end: new Date('2025-01-01T14:00:00')
};
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]);
expect(stackLinks.size).toBe(4);
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
expect(stackLinks.get('event-c')?.stackLevel).toBe(1);
expect(stackLinks.get('event-d')?.stackLevel).toBe(1);
});
it('should handle nested overlaps with optimal stacking', () => {
// Scenario:
// Event A: 09:00 ════════════════════════════════════ 15:00
// Event B: 10:00 ════════════════════ 13:00
// Event C: 11:00 ═══ 12:00
// Event D: 12:30 ═══ 13:30
//
// Expected:
// - A at level 0 (base, contains all)
// - B at level 1 (overlaps with A)
// - C at level 2 (overlaps with A and B)
// - D at level 2 (overlaps with A and B, but NOT with C - can share level with C)
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T15:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T13:00:00')
};
const eventC = {
id: 'event-c',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00')
};
const eventD = {
id: 'event-d',
start: new Date('2025-01-01T12:30:00'),
end: new Date('2025-01-01T13:30:00')
};
const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]);
expect(stackLinks.size).toBe(4);
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
expect(stackLinks.get('event-b')?.stackLevel).toBe(1);
expect(stackLinks.get('event-c')?.stackLevel).toBe(2);
expect(stackLinks.get('event-d')?.stackLevel).toBe(2); // Can share level with C
});
it('should handle events with identical start and end times', () => {
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
expect(manager.doEventsOverlap(eventA, eventB)).toBe(true);
const stackLinks = manager.createStackLinks([eventA, eventB]);
expect(stackLinks.size).toBe(2);
});
it('should handle events with zero duration', () => {
const eventA = {
id: 'event-a',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T10:00:00') // Zero duration
};
const eventB = {
id: 'event-b',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
// Zero-duration event should not overlap
expect(manager.doEventsOverlap(eventA, eventB)).toBe(false);
});
it('should handle large number of overlapping events', () => {
const events = Array.from({ length: 100 }, (_, i) => ({
id: `event-${i}`,
start: new Date('2025-01-01T09:00:00'),
end: new Date(`2025-01-01T${10 + i}:00:00`)
}));
const stackLinks = manager.createStackLinks(events);
expect(stackLinks.size).toBe(100);
expect(stackLinks.get('event-0')?.stackLevel).toBe(0);
expect(stackLinks.get('event-99')?.stackLevel).toBe(99);
});
});
describe('Integration Tests', () => {
it('should create complete stack for new event with overlapping events', () => {
// Scenario: Adding new event that overlaps with existing events
const newEvent = {
id: 'new-event',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const existingEvents = [
{
id: 'existing-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:30:00')
},
{
id: 'existing-b',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T12:00:00')
}
];
// Find overlapping
const overlapping = manager.findOverlappingEvents(newEvent, existingEvents);
// Create stack links for all events
const allEvents = [...overlapping, newEvent];
const stackLinks = manager.createStackLinks(allEvents);
// Verify complete stack
expect(stackLinks.size).toBe(3);
expect(stackLinks.get('existing-a')?.stackLevel).toBe(0);
expect(stackLinks.get('new-event')?.stackLevel).toBe(1);
expect(stackLinks.get('existing-b')?.stackLevel).toBe(2);
});
it('should handle complete workflow: detect, create, apply to DOM', () => {
const newEvent = {
id: 'new-event',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00')
};
const existingEvents = [
{
id: 'existing-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:30:00')
}
];
// Step 1: Find overlapping
const overlapping = manager.findOverlappingEvents(newEvent, existingEvents);
expect(overlapping).toHaveLength(1);
// Step 2: Create stack links
const allEvents = [...overlapping, newEvent];
const stackLinks = manager.createStackLinks(allEvents);
expect(stackLinks.size).toBe(2);
// Step 3: Apply to DOM
const elementA = document.createElement('div');
elementA.dataset.eventId = 'existing-a';
const elementNew = document.createElement('div');
elementNew.dataset.eventId = 'new-event';
manager.applyStackLinkToElement(elementA, stackLinks.get('existing-a')!);
manager.applyStackLinkToElement(elementNew, stackLinks.get('new-event')!);
manager.applyVisualStyling(elementA, stackLinks.get('existing-a')!.stackLevel);
manager.applyVisualStyling(elementNew, stackLinks.get('new-event')!.stackLevel);
// Verify DOM state
expect(elementA.dataset.stackLink).toContain('"stackLevel":0');
expect(elementA.style.marginLeft).toBe('0px');
expect(elementNew.dataset.stackLink).toContain('"stackLevel":1');
expect(elementNew.style.marginLeft).toBe('15px');
});
});
});

View file

@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NavigationManager } from '../../src/managers/NavigationManager';
import { EventBus } from '../../src/core/EventBus';
import { EventRenderingService } from '../../src/renderers/EventRendererManager';
import { DateService } from '../../src/utils/DateService';
describe('NavigationManager - Edge Cases', () => {
let navigationManager: NavigationManager;
let eventBus: EventBus;
let dateService: DateService;
beforeEach(() => {
eventBus = new EventBus();
const mockEventRenderer = {} as EventRenderingService;
navigationManager = new NavigationManager(eventBus, mockEventRenderer);
dateService = new DateService('Europe/Copenhagen');
});
describe('Week 53 Navigation', () => {
it('should correctly navigate to week 53 (year 2020)', () => {
// Dec 28, 2020 is start of week 53
const week53Start = new Date(2020, 11, 28);
const weekNum = dateService.getWeekNumber(week53Start);
expect(weekNum).toBe(53);
const weekBounds = dateService.getWeekBounds(week53Start);
expect(weekBounds.start.getDate()).toBe(28);
expect(weekBounds.start.getMonth()).toBe(11); // December
expect(weekBounds.start.getFullYear()).toBe(2020);
});
it('should navigate from week 53 to week 1 of next year', () => {
const week53 = new Date(2020, 11, 28); // Week 53, 2020
// Add 1 week should go to week 1 of 2021
const nextWeek = dateService.addWeeks(week53, 1);
const nextWeekNum = dateService.getWeekNumber(nextWeek);
expect(nextWeek.getFullYear()).toBe(2021);
expect(nextWeekNum).toBe(1);
});
it('should navigate from week 1 back to week 53 of previous year', () => {
const week1_2021 = new Date(2021, 0, 4); // Monday Jan 4, 2021 (week 1)
// Subtract 1 week should go to week 53 of 2020
const prevWeek = dateService.addWeeks(week1_2021, -1);
const prevWeekNum = dateService.getWeekNumber(prevWeek);
expect(prevWeek.getFullYear()).toBe(2020);
expect(prevWeekNum).toBe(53);
});
it('should handle years without week 53 (2021)', () => {
const dec27_2021 = new Date(2021, 11, 27); // Monday Dec 27, 2021
const weekNum = dateService.getWeekNumber(dec27_2021);
expect(weekNum).toBe(52); // No week 53 in 2021
const nextWeek = dateService.addWeeks(dec27_2021, 1);
const nextWeekNum = dateService.getWeekNumber(nextWeek);
// ISO week logic: Adding 1 week from Dec 27 gives Jan 3, which is week 1 of 2022
expect(nextWeekNum).toBe(1); // Week 1 of 2022
// Jan 3, 2022 is indeed week 1
const jan3_2022 = new Date(2022, 0, 3);
expect(dateService.getWeekNumber(jan3_2022)).toBe(1);
});
it('should correctly identify week 53 in 2026', () => {
const dec28_2026 = new Date(2026, 11, 28); // Monday Dec 28, 2026
const weekNum = dateService.getWeekNumber(dec28_2026);
expect(weekNum).toBe(53);
});
});
describe('Year Boundary Navigation', () => {
it('should navigate across year boundary (Dec -> Jan)', () => {
const lastWeekDec = new Date(2024, 11, 23); // Dec 23, 2024
const firstWeekJan = dateService.addWeeks(lastWeekDec, 1);
// Adding 1 week gives Dec 30, which is in week 1 of 2025 (ISO week logic)
expect(firstWeekJan.getMonth()).toBe(11); // Still December
const weekNum = dateService.getWeekNumber(firstWeekJan);
// Week number can be 1 (of next year) or 52 depending on ISO week rules
expect(weekNum).toBeGreaterThanOrEqual(1);
});
it('should navigate across year boundary (Jan -> Dec)', () => {
const firstWeekJan = new Date(2024, 0, 1);
const lastWeekDec = dateService.addWeeks(firstWeekJan, -1);
expect(lastWeekDec.getFullYear()).toBe(2023);
const weekNum = dateService.getWeekNumber(lastWeekDec);
expect(weekNum).toBeGreaterThanOrEqual(52);
});
it('should get correct week bounds at year start', () => {
const jan1_2024 = new Date(2024, 0, 1); // Monday Jan 1, 2024
const weekBounds = dateService.getWeekBounds(jan1_2024);
// Week should start on Monday
const startDayOfWeek = weekBounds.start.getDay();
expect(startDayOfWeek).toBe(1); // Monday = 1
expect(weekBounds.start.getDate()).toBe(1);
expect(weekBounds.start.getMonth()).toBe(0); // January
});
it('should get correct week bounds at year end', () => {
const dec31_2024 = new Date(2024, 11, 31); // Tuesday Dec 31, 2024
const weekBounds = dateService.getWeekBounds(dec31_2024);
// Week should start on Monday (Dec 30, 2024)
expect(weekBounds.start.getDate()).toBe(30);
expect(weekBounds.start.getMonth()).toBe(11);
expect(weekBounds.start.getFullYear()).toBe(2024);
// Week should end on Sunday (Jan 5, 2025)
expect(weekBounds.end.getDate()).toBe(5);
expect(weekBounds.end.getMonth()).toBe(0); // January
expect(weekBounds.end.getFullYear()).toBe(2025);
});
});
describe('DST Transition Navigation', () => {
it('should navigate across spring DST transition (March 2024)', () => {
// Spring DST: March 31, 2024, 02:00 -> 03:00
const beforeDST = new Date(2024, 2, 25); // Week before DST
const duringDST = dateService.addWeeks(beforeDST, 1);
expect(duringDST.getMonth()).toBe(3); // April
expect(dateService.isValid(duringDST)).toBe(true);
const weekBounds = dateService.getWeekBounds(duringDST);
expect(weekBounds.start.getMonth()).toBeGreaterThanOrEqual(2); // March or April
});
it('should navigate across fall DST transition (October 2024)', () => {
// Fall DST: October 27, 2024, 03:00 -> 02:00
const beforeDST = new Date(2024, 9, 21); // Week before DST
const duringDST = dateService.addWeeks(beforeDST, 1);
expect(duringDST.getMonth()).toBe(9); // October
expect(dateService.isValid(duringDST)).toBe(true);
const weekBounds = dateService.getWeekBounds(duringDST);
expect(weekBounds.end.getMonth()).toBeLessThanOrEqual(10); // October or November
});
it('should maintain week integrity across DST', () => {
const beforeDST = new Date(2024, 2, 25, 12, 0);
const afterDST = dateService.addWeeks(beforeDST, 1);
// Week bounds should still give 7-day span
const weekBounds = dateService.getWeekBounds(afterDST);
const daysDiff = (weekBounds.end.getTime() - weekBounds.start.getTime()) / (1000 * 60 * 60 * 24);
// Should be close to 7 days (accounting for DST hour change)
expect(daysDiff).toBeGreaterThanOrEqual(6.9);
expect(daysDiff).toBeLessThanOrEqual(7.1);
});
});
describe('Month Boundary Week Navigation', () => {
it('should handle week spanning month boundary', () => {
const endOfMonth = new Date(2024, 0, 29); // Jan 29, 2024 (Monday)
const weekBounds = dateService.getWeekBounds(endOfMonth);
// Week should span into February
expect(weekBounds.end.getMonth()).toBe(1); // February
expect(weekBounds.end.getDate()).toBe(4);
});
it('should navigate to next week across month boundary', () => {
const lastWeekJan = new Date(2024, 0, 29);
const firstWeekFeb = dateService.addWeeks(lastWeekJan, 1);
expect(firstWeekFeb.getMonth()).toBe(1); // February
expect(firstWeekFeb.getDate()).toBe(5);
});
it('should handle February-March boundary in leap year', () => {
const lastWeekFeb = new Date(2024, 1, 26); // Feb 26, 2024 (leap year)
const weekBounds = dateService.getWeekBounds(lastWeekFeb);
// Week should span from Feb into March
expect(weekBounds.start.getMonth()).toBe(1); // February
expect(weekBounds.end.getMonth()).toBe(2); // March
});
});
describe('Invalid Date Navigation', () => {
it('should reject navigation to invalid date', () => {
const invalidDate = new Date('invalid');
const validation = dateService.validateDate(invalidDate);
expect(validation.valid).toBe(false);
expect(validation.error).toBeDefined();
});
it('should reject navigation to out-of-bounds date', () => {
const outOfBounds = new Date(2150, 0, 1);
const validation = dateService.validateDate(outOfBounds);
expect(validation.valid).toBe(false);
expect(validation.error).toContain('bounds');
});
it('should accept valid date within bounds', () => {
const validDate = new Date(2024, 6, 15);
const validation = dateService.validateDate(validDate);
expect(validation.valid).toBe(true);
expect(validation.error).toBeUndefined();
});
});
describe('Week Number Edge Cases', () => {
it('should handle first day of year in previous year\'s week', () => {
// Jan 1, 2023 is a Sunday, part of week 52 of 2022
const jan1_2023 = new Date(2023, 0, 1);
const weekNum = dateService.getWeekNumber(jan1_2023);
expect(weekNum).toBe(52); // Part of 2022's last week
});
it('should handle last day of year in next year\'s week', () => {
// Dec 31, 2023 is a Sunday, part of week 52 of 2023
const dec31_2023 = new Date(2023, 11, 31);
const weekNum = dateService.getWeekNumber(dec31_2023);
expect(weekNum).toBe(52);
});
it('should correctly number weeks in leap year', () => {
const dates2024 = [
new Date(2024, 0, 1), // Week 1
new Date(2024, 6, 1), // Mid-year
new Date(2024, 11, 31) // Last week
];
dates2024.forEach(date => {
const weekNum = dateService.getWeekNumber(date);
expect(weekNum).toBeGreaterThanOrEqual(1);
expect(weekNum).toBeLessThanOrEqual(53);
});
});
});
describe('Navigation Continuity', () => {
it('should maintain continuity over multiple forward navigations', () => {
let currentWeek = new Date(2024, 0, 1);
for (let i = 0; i < 60; i++) { // Navigate 60 weeks forward
currentWeek = dateService.addWeeks(currentWeek, 1);
expect(dateService.isValid(currentWeek)).toBe(true);
}
// Should be in 2025
expect(currentWeek.getFullYear()).toBe(2025);
});
it('should maintain continuity over multiple backward navigations', () => {
let currentWeek = new Date(2024, 11, 31);
for (let i = 0; i < 60; i++) { // Navigate 60 weeks backward
currentWeek = dateService.addWeeks(currentWeek, -1);
expect(dateService.isValid(currentWeek)).toBe(true);
}
// Should be in 2023
expect(currentWeek.getFullYear()).toBe(2023);
});
it('should return to same week after forward+backward navigation', () => {
const originalWeek = new Date(2024, 6, 15);
const weekBoundsOriginal = dateService.getWeekBounds(originalWeek);
// Navigate 10 weeks forward, then 10 weeks back
const forward = dateService.addWeeks(originalWeek, 10);
const backAgain = dateService.addWeeks(forward, -10);
const weekBoundsBack = dateService.getWeekBounds(backAgain);
expect(weekBoundsBack.start.getTime()).toBe(weekBoundsOriginal.start.getTime());
expect(weekBoundsBack.end.getTime()).toBe(weekBoundsOriginal.end.getTime());
});
});
});

13
test/setup.ts Normal file
View file

@ -0,0 +1,13 @@
import { beforeEach } from 'vitest';
// Global test setup
beforeEach(() => {
// Clear DOM before each test
document.body.innerHTML = '';
document.head.innerHTML = '';
// Reset any global state
if (typeof window !== 'undefined') {
// Clear any event listeners or global variables if needed
}
});

View file

@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { DateService } from '../../src/utils/DateService';
describe('DateService - Edge Cases', () => {
const dateService = new DateService('Europe/Copenhagen');
describe('Leap Year Handling', () => {
it('should handle February 29 in leap year (2024)', () => {
const leapDate = new Date(2024, 1, 29); // Feb 29, 2024
expect(dateService.isValid(leapDate)).toBe(true);
expect(leapDate.getMonth()).toBe(1); // February
expect(leapDate.getDate()).toBe(29);
});
it('should reject February 29 in non-leap year (2023)', () => {
const invalidDate = new Date(2023, 1, 29); // Tries Feb 29, 2023
// JavaScript auto-corrects to March 1
expect(invalidDate.getMonth()).toBe(2); // March
expect(invalidDate.getDate()).toBe(1);
});
it('should handle February 28 in non-leap year', () => {
const validDate = new Date(2023, 1, 28); // Feb 28, 2023
expect(dateService.isValid(validDate)).toBe(true);
expect(validDate.getMonth()).toBe(1); // February
expect(validDate.getDate()).toBe(28);
});
it('should correctly add 1 year to Feb 29 (leap year)', () => {
const leapDate = new Date(2024, 1, 29);
const nextYear = dateService.addDays(leapDate, 365); // 2025 is not leap year
// Should be Feb 28, 2025 (or March 1 depending on implementation)
expect(nextYear.getFullYear()).toBe(2025);
expect(nextYear.getMonth()).toBeGreaterThanOrEqual(1); // Feb or March
});
it('should validate leap year dates with isWithinBounds', () => {
const leapDate2024 = new Date(2024, 1, 29);
const leapDate2000 = new Date(2000, 1, 29);
expect(dateService.isWithinBounds(leapDate2024)).toBe(true);
expect(dateService.isWithinBounds(leapDate2000)).toBe(true);
});
});
describe('ISO Week 53 Handling', () => {
it('should correctly identify week 53 in 2020 (has week 53)', () => {
const dec31_2020 = new Date(2020, 11, 31); // Dec 31, 2020
const weekNum = dateService.getWeekNumber(dec31_2020);
expect(weekNum).toBe(53);
});
it('should correctly identify week 53 in 2026 (has week 53)', () => {
const dec31_2026 = new Date(2026, 11, 31); // Dec 31, 2026
const weekNum = dateService.getWeekNumber(dec31_2026);
expect(weekNum).toBe(53);
});
it('should NOT have week 53 in 2021 (goes to week 52)', () => {
const dec31_2021 = new Date(2021, 11, 31); // Dec 31, 2021
const weekNum = dateService.getWeekNumber(dec31_2021);
expect(weekNum).toBe(52);
});
it('should handle transition from week 53 to week 1', () => {
const lastDayOf2020 = new Date(2020, 11, 31); // Week 53
const firstDayOf2021 = dateService.addDays(lastDayOf2020, 1);
expect(dateService.getWeekNumber(lastDayOf2020)).toBe(53);
expect(dateService.getWeekNumber(firstDayOf2021)).toBe(53); // Still week 53!
// Monday after should be week 1
const firstMonday2021 = new Date(2021, 0, 4);
expect(dateService.getWeekNumber(firstMonday2021)).toBe(1);
});
it('should get correct week bounds for week 53', () => {
const dec31_2020 = new Date(2020, 11, 31);
const weekBounds = dateService.getWeekBounds(dec31_2020);
// Week 53 of 2020 starts on Monday Dec 28, 2020
expect(weekBounds.start.getDate()).toBe(28);
expect(weekBounds.start.getMonth()).toBe(11); // December
// Ends on Sunday Jan 3, 2021
expect(weekBounds.end.getDate()).toBe(3);
expect(weekBounds.end.getMonth()).toBe(0); // January
expect(weekBounds.end.getFullYear()).toBe(2021);
});
});
describe('Month Boundary Edge Cases', () => {
it('should correctly add months across year boundary', () => {
const nov2024 = new Date(2024, 10, 15); // Nov 15, 2024
const feb2025 = dateService.addMonths(nov2024, 3);
expect(feb2025.getFullYear()).toBe(2025);
expect(feb2025.getMonth()).toBe(1); // February
expect(feb2025.getDate()).toBe(15);
});
it('should handle month-end overflow (Jan 31 + 1 month)', () => {
const jan31 = new Date(2024, 0, 31);
const result = dateService.addMonths(jan31, 1);
// date-fns addMonths handles this gracefully
expect(result.getMonth()).toBe(1); // February
expect(result.getFullYear()).toBe(2024);
// Will be Feb 29 (leap year) or last day of Feb
});
it('should handle adding negative months', () => {
const mar2024 = new Date(2024, 2, 15); // March 15, 2024
const dec2023 = dateService.addMonths(mar2024, -3);
expect(dec2023.getFullYear()).toBe(2023);
expect(dec2023.getMonth()).toBe(11); // December
expect(dec2023.getDate()).toBe(15);
});
});
describe('Year Boundary Edge Cases', () => {
it('should handle year transition (Dec 31 -> Jan 1)', () => {
const dec31 = new Date(2024, 11, 31);
const jan1 = dateService.addDays(dec31, 1);
expect(jan1.getFullYear()).toBe(2025);
expect(jan1.getMonth()).toBe(0); // January
expect(jan1.getDate()).toBe(1);
});
it('should handle reverse year transition (Jan 1 -> Dec 31)', () => {
const jan1 = new Date(2024, 0, 1);
const dec31 = dateService.addDays(jan1, -1);
expect(dec31.getFullYear()).toBe(2023);
expect(dec31.getMonth()).toBe(11); // December
expect(dec31.getDate()).toBe(31);
});
it('should correctly calculate week bounds at year boundary', () => {
const jan1_2024 = new Date(2024, 0, 1);
const weekBounds = dateService.getWeekBounds(jan1_2024);
// Jan 1, 2024 is a Monday (week 1)
expect(weekBounds.start.getDate()).toBe(1);
expect(weekBounds.start.getMonth()).toBe(0);
expect(weekBounds.start.getFullYear()).toBe(2024);
});
});
describe('DST Transition Edge Cases', () => {
it('should handle spring DST transition (CET -> CEST)', () => {
// Last Sunday of March 2024: March 31, 02:00 -> 03:00
const beforeDST = new Date(2024, 2, 31, 1, 30); // 01:30 CET
const afterDST = new Date(2024, 2, 31, 3, 30); // 03:30 CEST
expect(dateService.isValid(beforeDST)).toBe(true);
expect(dateService.isValid(afterDST)).toBe(true);
// The hour 02:00-03:00 doesn't exist!
const nonExistentTime = new Date(2024, 2, 31, 2, 30);
// JavaScript auto-adjusts this
expect(nonExistentTime.getHours()).not.toBe(2);
});
it('should handle fall DST transition (CEST -> CET)', () => {
// Last Sunday of October 2024: October 27, 03:00 -> 02:00
const beforeDST = new Date(2024, 9, 27, 2, 30, 0, 0);
const afterDST = new Date(2024, 9, 27, 3, 30, 0, 0);
expect(dateService.isValid(beforeDST)).toBe(true);
expect(dateService.isValid(afterDST)).toBe(true);
// 02:00-03:00 exists TWICE (ambiguous hour)
// This is handled by timezone-aware libraries
});
it('should calculate duration correctly across DST', () => {
// Event spanning DST transition
const start = new Date(2024, 2, 31, 1, 0); // Before DST
const end = new Date(2024, 2, 31, 4, 0); // After DST
const duration = dateService.getDurationMinutes(start, end);
// Clock time: 3 hours, but actual duration: 2 hours (due to DST)
// date-fns should handle this correctly
expect(duration).toBeGreaterThan(0);
});
});
describe('Extreme Date Values', () => {
it('should reject dates before 1900', () => {
const oldDate = new Date(1899, 11, 31);
expect(dateService.isWithinBounds(oldDate)).toBe(false);
});
it('should reject dates after 2100', () => {
const futureDate = new Date(2101, 0, 1);
expect(dateService.isWithinBounds(futureDate)).toBe(false);
});
it('should accept boundary dates (1900 and 2100)', () => {
const minDate = new Date(1900, 0, 1);
const maxDate = new Date(2100, 11, 31);
expect(dateService.isWithinBounds(minDate)).toBe(true);
expect(dateService.isWithinBounds(maxDate)).toBe(true);
});
it('should validate invalid Date objects', () => {
const invalidDate = new Date('invalid');
expect(dateService.isValid(invalidDate)).toBe(false);
expect(dateService.isWithinBounds(invalidDate)).toBe(false);
});
});
});

View file

@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
import { DateService } from '../../src/utils/DateService';
describe('DateService - Midnight Crossing & Multi-Day Events', () => {
const dateService = new DateService('Europe/Copenhagen');
describe('Midnight Crossing Events', () => {
it('should handle event starting before midnight and ending after', () => {
const start = new Date(2024, 0, 15, 23, 30); // Jan 15, 23:30
const end = new Date(2024, 0, 16, 1, 30); // Jan 16, 01:30
expect(dateService.isMultiDay(start, end)).toBe(true);
expect(dateService.isSameDay(start, end)).toBe(false);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(120); // 2 hours
});
it('should calculate duration correctly across midnight', () => {
const start = new Date(2024, 0, 15, 22, 0); // 22:00
const end = new Date(2024, 0, 16, 2, 0); // 02:00 next day
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(240); // 4 hours
});
it('should handle event ending exactly at midnight', () => {
const start = new Date(2024, 0, 15, 20, 0); // 20:00
const end = new Date(2024, 0, 16, 0, 0); // 00:00 (midnight)
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(240); // 4 hours
});
it('should handle event starting exactly at midnight', () => {
const start = new Date(2024, 0, 15, 0, 0); // 00:00 (midnight)
const end = new Date(2024, 0, 15, 3, 0); // 03:00 same day
expect(dateService.isMultiDay(start, end)).toBe(false);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(180); // 3 hours
});
it('should create date at specific time correctly across midnight', () => {
const baseDate = new Date(2024, 0, 15);
// 1440 minutes = 24:00 = midnight next day
const midnightNextDay = dateService.createDateAtTime(baseDate, 1440);
expect(midnightNextDay.getDate()).toBe(16);
expect(midnightNextDay.getHours()).toBe(0);
expect(midnightNextDay.getMinutes()).toBe(0);
// 1500 minutes = 25:00 = 01:00 next day
const oneAmNextDay = dateService.createDateAtTime(baseDate, 1500);
expect(oneAmNextDay.getDate()).toBe(16);
expect(oneAmNextDay.getHours()).toBe(1);
expect(oneAmNextDay.getMinutes()).toBe(0);
});
});
describe('Multi-Day Events', () => {
it('should detect 2-day event', () => {
const start = new Date(2024, 0, 15, 10, 0);
const end = new Date(2024, 0, 16, 14, 0);
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(28 * 60); // 28 hours
});
it('should detect 3-day event', () => {
const start = new Date(2024, 0, 15, 9, 0);
const end = new Date(2024, 0, 17, 17, 0);
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(56 * 60); // 56 hours
});
it('should detect week-long event', () => {
const start = new Date(2024, 0, 15, 0, 0);
const end = new Date(2024, 0, 22, 0, 0);
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(7 * 24 * 60); // 7 days
});
it('should handle month-spanning multi-day event', () => {
const start = new Date(2024, 0, 30, 12, 0); // Jan 30
const end = new Date(2024, 1, 2, 12, 0); // Feb 2
expect(dateService.isMultiDay(start, end)).toBe(true);
expect(start.getMonth()).toBe(0); // January
expect(end.getMonth()).toBe(1); // February
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(3 * 24 * 60); // 3 days
});
it('should handle year-spanning multi-day event', () => {
const start = new Date(2024, 11, 30, 10, 0); // Dec 30, 2024
const end = new Date(2025, 0, 2, 10, 0); // Jan 2, 2025
expect(dateService.isMultiDay(start, end)).toBe(true);
expect(start.getFullYear()).toBe(2024);
expect(end.getFullYear()).toBe(2025);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(3 * 24 * 60); // 3 days
});
});
describe('Timezone Boundary Events', () => {
it('should handle UTC to local timezone conversion across midnight', () => {
// Event in UTC that crosses date boundary in local timezone
const utcStart = '2024-01-15T23:00:00Z'; // 23:00 UTC
const utcEnd = '2024-01-16T01:00:00Z'; // 01:00 UTC next day
const localStart = dateService.fromUTC(utcStart);
const localEnd = dateService.fromUTC(utcEnd);
// Copenhagen is UTC+1 (or UTC+2 in summer)
// So 23:00 UTC = 00:00 or 01:00 local (midnight crossing)
expect(localStart.getDate()).toBeGreaterThanOrEqual(15);
expect(localEnd.getDate()).toBeGreaterThanOrEqual(16);
const duration = dateService.getDurationMinutes(localStart, localEnd);
expect(duration).toBe(120); // 2 hours
});
it('should preserve duration when converting UTC to local', () => {
const utcStart = '2024-06-15T10:00:00Z';
const utcEnd = '2024-06-15T18:00:00Z';
const localStart = dateService.fromUTC(utcStart);
const localEnd = dateService.fromUTC(utcEnd);
const utcDuration = 8 * 60; // 8 hours
const localDuration = dateService.getDurationMinutes(localStart, localEnd);
expect(localDuration).toBe(utcDuration);
});
it('should handle all-day events (00:00 to 00:00 next day)', () => {
const start = new Date(2024, 0, 15, 0, 0, 0);
const end = new Date(2024, 0, 16, 0, 0, 0);
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(24 * 60); // 24 hours
});
it('should handle multi-day all-day events', () => {
const start = new Date(2024, 0, 15, 0, 0, 0);
const end = new Date(2024, 0, 18, 0, 0, 0); // 3-day event
expect(dateService.isMultiDay(start, end)).toBe(true);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(3 * 24 * 60); // 72 hours
});
});
describe('Edge Cases with Minutes Since Midnight', () => {
it('should calculate minutes since midnight correctly at day boundary', () => {
const midnight = new Date(2024, 0, 15, 0, 0);
const beforeMidnight = new Date(2024, 0, 14, 23, 59);
const afterMidnight = new Date(2024, 0, 15, 0, 1);
expect(dateService.getMinutesSinceMidnight(midnight)).toBe(0);
expect(dateService.getMinutesSinceMidnight(beforeMidnight)).toBe(23 * 60 + 59);
expect(dateService.getMinutesSinceMidnight(afterMidnight)).toBe(1);
});
it('should handle createDateAtTime with overflow minutes (>1440)', () => {
const baseDate = new Date(2024, 0, 15);
// 1500 minutes = 25 hours = next day at 01:00
const result = dateService.createDateAtTime(baseDate, 1500);
expect(result.getDate()).toBe(16); // Next day
expect(result.getHours()).toBe(1);
expect(result.getMinutes()).toBe(0);
});
it('should handle createDateAtTime with large overflow (48+ hours)', () => {
const baseDate = new Date(2024, 0, 15);
// 2880 minutes = 48 hours = 2 days later
const result = dateService.createDateAtTime(baseDate, 2880);
expect(result.getDate()).toBe(17); // 2 days later
expect(result.getHours()).toBe(0);
expect(result.getMinutes()).toBe(0);
});
});
describe('Same Day vs Multi-Day Detection', () => {
it('should correctly identify same-day events', () => {
const start = new Date(2024, 0, 15, 8, 0);
const end = new Date(2024, 0, 15, 17, 0);
expect(dateService.isSameDay(start, end)).toBe(true);
expect(dateService.isMultiDay(start, end)).toBe(false);
});
it('should correctly identify multi-day events', () => {
const start = new Date(2024, 0, 15, 23, 0);
const end = new Date(2024, 0, 16, 1, 0);
expect(dateService.isSameDay(start, end)).toBe(false);
expect(dateService.isMultiDay(start, end)).toBe(true);
});
it('should handle ISO string inputs for multi-day detection', () => {
const startISO = '2024-01-15T23:00:00Z';
const endISO = '2024-01-16T01:00:00Z';
// Convert UTC strings to local timezone first
const startLocal = dateService.fromUTC(startISO);
const endLocal = dateService.fromUTC(endISO);
const result = dateService.isMultiDay(startLocal, endLocal);
// 23:00 UTC = 00:00 CET (next day) in Copenhagen
// So this IS a multi-day event in local time
expect(result).toBe(true);
});
it('should handle mixed Date and string inputs', () => {
const startDate = new Date(2024, 0, 15, 10, 0);
const endISO = '2024-01-16T10:00:00Z';
const result = dateService.isMultiDay(startDate, endISO);
expect(typeof result).toBe('boolean');
});
});
});

View file

@ -0,0 +1,259 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DateService } from '../../src/utils/DateService';
describe('DateService', () => {
let dateService: DateService;
beforeEach(() => {
dateService = new DateService('Europe/Copenhagen');
});
describe('Core Conversions', () => {
it('should convert local date to UTC', () => {
// 2024-01-15 10:00:00 Copenhagen (UTC+1 in winter)
const localDate = new Date(2024, 0, 15, 10, 0, 0);
const utcString = dateService.toUTC(localDate);
// Should be 09:00:00 UTC
expect(utcString).toContain('2024-01-15T09:00:00');
expect(utcString).toContain('Z');
});
it('should convert UTC to local date', () => {
const utcString = '2024-01-15T09:00:00.000Z';
const localDate = dateService.fromUTC(utcString);
// Should be 10:00 in Copenhagen (UTC+1)
expect(localDate.getHours()).toBe(10);
expect(localDate.getMinutes()).toBe(0);
});
it('should handle summer time (DST)', () => {
// 2024-07-15 10:00:00 Copenhagen (UTC+2 in summer)
const localDate = new Date(2024, 6, 15, 10, 0, 0);
const utcString = dateService.toUTC(localDate);
// Should be 08:00:00 UTC
expect(utcString).toContain('2024-07-15T08:00:00');
});
});
describe('Time Formatting', () => {
it('should format time without seconds', () => {
const date = new Date(2024, 0, 15, 14, 30, 45);
const formatted = dateService.formatTime(date);
expect(formatted).toBe('14:30');
});
it('should format time with seconds', () => {
const date = new Date(2024, 0, 15, 14, 30, 45);
const formatted = dateService.formatTime(date, true);
expect(formatted).toBe('14:30:45');
});
it('should format time range', () => {
const start = new Date(2024, 0, 15, 9, 0, 0);
const end = new Date(2024, 0, 15, 10, 30, 0);
const formatted = dateService.formatTimeRange(start, end);
expect(formatted).toBe('09:00 - 10:30');
});
it('should format technical datetime', () => {
const date = new Date(2024, 0, 15, 14, 30, 45);
const formatted = dateService.formatTechnicalDateTime(date);
expect(formatted).toBe('2024-01-15 14:30:45');
});
it('should format date as ISO', () => {
const date = new Date(2024, 0, 15, 14, 30, 0);
const formatted = dateService.formatDate(date);
expect(formatted).toBe('2024-01-15');
});
});
describe('Time Calculations', () => {
it('should convert time string to minutes', () => {
expect(dateService.timeToMinutes('09:00')).toBe(540);
expect(dateService.timeToMinutes('14:30')).toBe(870);
expect(dateService.timeToMinutes('00:00')).toBe(0);
expect(dateService.timeToMinutes('23:59')).toBe(1439);
});
it('should convert minutes to time string', () => {
expect(dateService.minutesToTime(540)).toBe('09:00');
expect(dateService.minutesToTime(870)).toBe('14:30');
expect(dateService.minutesToTime(0)).toBe('00:00');
expect(dateService.minutesToTime(1439)).toBe('23:59');
});
it('should get minutes since midnight', () => {
const date = new Date(2024, 0, 15, 14, 30, 0);
const minutes = dateService.getMinutesSinceMidnight(date);
expect(minutes).toBe(870); // 14*60 + 30
});
it('should calculate duration in minutes', () => {
const start = new Date(2024, 0, 15, 9, 0, 0);
const end = new Date(2024, 0, 15, 10, 30, 0);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(90);
});
it('should calculate duration from ISO strings', () => {
const start = '2024-01-15T09:00:00.000Z';
const end = '2024-01-15T10:30:00.000Z';
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(90);
});
});
describe('Week Operations', () => {
it('should get week bounds (Monday to Sunday)', () => {
// Wednesday, January 17, 2024
const date = new Date(2024, 0, 17);
const bounds = dateService.getWeekBounds(date);
// Should start on Monday, January 15
expect(bounds.start.getDate()).toBe(15);
expect(bounds.start.getDay()).toBe(1); // Monday
// Should end on Sunday, January 21
expect(bounds.end.getDate()).toBe(21);
expect(bounds.end.getDay()).toBe(0); // Sunday
});
it('should add weeks', () => {
const date = new Date(2024, 0, 15);
const newDate = dateService.addWeeks(date, 2);
expect(newDate.getDate()).toBe(29);
});
it('should subtract weeks', () => {
const date = new Date(2024, 0, 15);
const newDate = dateService.addWeeks(date, -1);
expect(newDate.getDate()).toBe(8);
});
});
describe('Grid Helpers', () => {
it('should create date at specific time', () => {
const baseDate = new Date(2024, 0, 15);
const date = dateService.createDateAtTime(baseDate, 870); // 14:30
expect(date.getHours()).toBe(14);
expect(date.getMinutes()).toBe(30);
expect(date.getDate()).toBe(15);
});
it('should snap to 15-minute interval', () => {
const date = new Date(2024, 0, 15, 14, 37, 0); // 14:37
const snapped = dateService.snapToInterval(date, 15);
// 14:37 is closer to 14:30 than 14:45, so should snap to 14:30
expect(snapped.getHours()).toBe(14);
expect(snapped.getMinutes()).toBe(30);
});
it('should snap to 30-minute interval', () => {
const date = new Date(2024, 0, 15, 14, 20, 0); // 14:20
const snapped = dateService.snapToInterval(date, 30);
// Should snap to 14:30
expect(snapped.getHours()).toBe(14);
expect(snapped.getMinutes()).toBe(30);
});
});
describe('Utility Methods', () => {
it('should check if same day', () => {
const date1 = new Date(2024, 0, 15, 10, 0, 0);
const date2 = new Date(2024, 0, 15, 14, 30, 0);
const date3 = new Date(2024, 0, 16, 10, 0, 0);
expect(dateService.isSameDay(date1, date2)).toBe(true);
expect(dateService.isSameDay(date1, date3)).toBe(false);
});
it('should get start of day', () => {
const date = new Date(2024, 0, 15, 14, 30, 45);
const start = dateService.startOfDay(date);
expect(start.getHours()).toBe(0);
expect(start.getMinutes()).toBe(0);
expect(start.getSeconds()).toBe(0);
});
it('should get end of day', () => {
const date = new Date(2024, 0, 15, 14, 30, 45);
const end = dateService.endOfDay(date);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
expect(end.getSeconds()).toBe(59);
});
it('should add days', () => {
const date = new Date(2024, 0, 15);
const newDate = dateService.addDays(date, 5);
expect(newDate.getDate()).toBe(20);
});
it('should add minutes', () => {
const date = new Date(2024, 0, 15, 10, 0, 0);
const newDate = dateService.addMinutes(date, 90);
expect(newDate.getHours()).toBe(11);
expect(newDate.getMinutes()).toBe(30);
});
it('should parse ISO string', () => {
const isoString = '2024-01-15T10:30:00.000Z';
const date = dateService.parseISO(isoString);
expect(date.toISOString()).toBe(isoString);
});
it('should validate dates', () => {
const validDate = new Date(2024, 0, 15);
const invalidDate = new Date('invalid');
expect(dateService.isValid(validDate)).toBe(true);
expect(dateService.isValid(invalidDate)).toBe(false);
});
});
describe('Edge Cases', () => {
it('should handle midnight', () => {
const date = new Date(2024, 0, 15, 0, 0, 0);
const minutes = dateService.getMinutesSinceMidnight(date);
expect(minutes).toBe(0);
});
it('should handle end of day', () => {
const date = new Date(2024, 0, 15, 23, 59, 0);
const minutes = dateService.getMinutesSinceMidnight(date);
expect(minutes).toBe(1439);
});
it('should handle cross-midnight duration', () => {
const start = new Date(2024, 0, 15, 23, 0, 0);
const end = new Date(2024, 0, 16, 1, 0, 0);
const duration = dateService.getDurationMinutes(start, end);
expect(duration).toBe(120); // 2 hours
});
});
});

View file

@ -0,0 +1,376 @@
import { describe, it, expect } from 'vitest';
import { DateService } from '../../src/utils/DateService';
describe('DateService - Validation', () => {
const dateService = new DateService('Europe/Copenhagen');
describe('isValid() - Basic Date Validation', () => {
it('should validate normal dates', () => {
const validDate = new Date(2024, 5, 15);
expect(dateService.isValid(validDate)).toBe(true);
});
it('should reject invalid date strings', () => {
const invalidDate = new Date('not a date');
expect(dateService.isValid(invalidDate)).toBe(false);
});
it('should reject NaN dates', () => {
const nanDate = new Date(NaN);
expect(dateService.isValid(nanDate)).toBe(false);
});
it('should reject dates created from invalid input', () => {
const invalidDate = new Date('2024-13-45'); // Invalid month and day
expect(dateService.isValid(invalidDate)).toBe(false);
});
it('should validate leap year dates', () => {
const leapDay = new Date(2024, 1, 29); // Feb 29, 2024
expect(dateService.isValid(leapDay)).toBe(true);
});
it('should detect invalid leap year dates', () => {
const invalidLeapDay = new Date(2023, 1, 29); // Feb 29, 2023 (not leap year)
// JavaScript auto-corrects this to March 1
expect(invalidLeapDay.getMonth()).toBe(2); // March
expect(invalidLeapDay.getDate()).toBe(1);
});
});
describe('isWithinBounds() - Date Range Validation', () => {
it('should accept dates within bounds (1900-2100)', () => {
const dates = [
new Date(1900, 0, 1), // Min bound
new Date(1950, 6, 15),
new Date(2000, 0, 1),
new Date(2024, 5, 15),
new Date(2100, 11, 31) // Max bound
];
dates.forEach(date => {
expect(dateService.isWithinBounds(date)).toBe(true);
});
});
it('should reject dates before 1900', () => {
const tooEarly = [
new Date(1899, 11, 31),
new Date(1800, 0, 1),
new Date(1000, 6, 15)
];
tooEarly.forEach(date => {
expect(dateService.isWithinBounds(date)).toBe(false);
});
});
it('should reject dates after 2100', () => {
const tooLate = [
new Date(2101, 0, 1),
new Date(2200, 6, 15),
new Date(3000, 0, 1)
];
tooLate.forEach(date => {
expect(dateService.isWithinBounds(date)).toBe(false);
});
});
it('should reject invalid dates', () => {
const invalidDate = new Date('invalid');
expect(dateService.isWithinBounds(invalidDate)).toBe(false);
});
it('should handle boundary dates exactly', () => {
const minDate = new Date(1900, 0, 1, 0, 0, 0);
const maxDate = new Date(2100, 11, 31, 23, 59, 59);
expect(dateService.isWithinBounds(minDate)).toBe(true);
expect(dateService.isWithinBounds(maxDate)).toBe(true);
});
});
describe('isValidRange() - Date Range Validation', () => {
it('should validate correct date ranges', () => {
const start = new Date(2024, 0, 15);
const end = new Date(2024, 0, 20);
expect(dateService.isValidRange(start, end)).toBe(true);
});
it('should accept equal start and end dates', () => {
const date = new Date(2024, 0, 15, 10, 0);
expect(dateService.isValidRange(date, date)).toBe(true);
});
it('should reject reversed date ranges', () => {
const start = new Date(2024, 0, 20);
const end = new Date(2024, 0, 15);
expect(dateService.isValidRange(start, end)).toBe(false);
});
it('should reject ranges with invalid start date', () => {
const invalidStart = new Date('invalid');
const validEnd = new Date(2024, 0, 20);
expect(dateService.isValidRange(invalidStart, validEnd)).toBe(false);
});
it('should reject ranges with invalid end date', () => {
const validStart = new Date(2024, 0, 15);
const invalidEnd = new Date('invalid');
expect(dateService.isValidRange(validStart, invalidEnd)).toBe(false);
});
it('should validate ranges across year boundaries', () => {
const start = new Date(2024, 11, 30);
const end = new Date(2025, 0, 5);
expect(dateService.isValidRange(start, end)).toBe(true);
});
it('should validate multi-year ranges', () => {
const start = new Date(2020, 0, 1);
const end = new Date(2024, 11, 31);
expect(dateService.isValidRange(start, end)).toBe(true);
});
});
describe('validateDate() - Comprehensive Validation', () => {
it('should validate normal dates without options', () => {
const date = new Date(2024, 5, 15);
const result = dateService.validateDate(date);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should reject invalid dates', () => {
const invalidDate = new Date('invalid');
const result = dateService.validateDate(invalidDate);
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid date');
});
it('should reject out-of-bounds dates', () => {
const tooEarly = new Date(1899, 0, 1);
const result = dateService.validateDate(tooEarly);
expect(result.valid).toBe(false);
expect(result.error).toContain('out of bounds');
});
it('should validate future dates with requireFuture option', () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const result = dateService.validateDate(futureDate, { requireFuture: true });
expect(result.valid).toBe(true);
});
it('should reject past dates with requireFuture option', () => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 10);
const result = dateService.validateDate(pastDate, { requireFuture: true });
expect(result.valid).toBe(false);
expect(result.error).toContain('future');
});
it('should validate past dates with requirePast option', () => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 10);
const result = dateService.validateDate(pastDate, { requirePast: true });
expect(result.valid).toBe(true);
});
it('should reject future dates with requirePast option', () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const result = dateService.validateDate(futureDate, { requirePast: true });
expect(result.valid).toBe(false);
expect(result.error).toContain('past');
});
it('should validate dates with minDate constraint', () => {
const minDate = new Date(2024, 0, 1);
const testDate = new Date(2024, 6, 15);
const result = dateService.validateDate(testDate, { minDate });
expect(result.valid).toBe(true);
});
it('should reject dates before minDate', () => {
const minDate = new Date(2024, 6, 1);
const testDate = new Date(2024, 5, 15);
const result = dateService.validateDate(testDate, { minDate });
expect(result.valid).toBe(false);
expect(result.error).toContain('after');
});
it('should validate dates with maxDate constraint', () => {
const maxDate = new Date(2024, 11, 31);
const testDate = new Date(2024, 6, 15);
const result = dateService.validateDate(testDate, { maxDate });
expect(result.valid).toBe(true);
});
it('should reject dates after maxDate', () => {
const maxDate = new Date(2024, 6, 31);
const testDate = new Date(2024, 7, 15);
const result = dateService.validateDate(testDate, { maxDate });
expect(result.valid).toBe(false);
expect(result.error).toContain('before');
});
it('should validate dates with both minDate and maxDate', () => {
const minDate = new Date(2024, 0, 1);
const maxDate = new Date(2024, 11, 31);
const testDate = new Date(2024, 6, 15);
const result = dateService.validateDate(testDate, { minDate, maxDate });
expect(result.valid).toBe(true);
});
it('should reject dates outside min/max range', () => {
const minDate = new Date(2024, 6, 1);
const maxDate = new Date(2024, 6, 31);
const testDate = new Date(2024, 7, 15);
const result = dateService.validateDate(testDate, { minDate, maxDate });
expect(result.valid).toBe(false);
});
});
describe('Invalid Date Scenarios', () => {
it('should handle February 30 (auto-corrects to March)', () => {
const invalidDate = new Date(2024, 1, 30); // Tries Feb 30, 2024
// JavaScript auto-corrects to March
expect(invalidDate.getMonth()).toBe(2); // March
expect(invalidDate.getDate()).toBe(1);
});
it('should handle month overflow (month 13)', () => {
const date = new Date(2024, 12, 1); // Month 13 = January next year
expect(date.getFullYear()).toBe(2025);
expect(date.getMonth()).toBe(0); // January
});
it('should handle negative months', () => {
const date = new Date(2024, -1, 1); // Month -1 = December previous year
expect(date.getFullYear()).toBe(2023);
expect(date.getMonth()).toBe(11); // December
});
it('should handle day 0 (last day of previous month)', () => {
const date = new Date(2024, 1, 0); // Day 0 of Feb = Last day of Jan
expect(date.getMonth()).toBe(0); // January
expect(date.getDate()).toBe(31);
});
it('should handle negative days', () => {
const date = new Date(2024, 1, -1); // Day -1 of Feb
expect(date.getMonth()).toBe(0); // January
expect(date.getDate()).toBe(30);
});
});
describe('Timezone-aware Validation', () => {
it('should validate UTC dates converted to local timezone', () => {
const utcString = '2024-06-15T12:00:00Z';
const localDate = dateService.fromUTC(utcString);
expect(dateService.isValid(localDate)).toBe(true);
expect(dateService.isWithinBounds(localDate)).toBe(true);
});
it('should maintain validation across timezone conversion', () => {
const localDate = new Date(2024, 6, 15, 12, 0);
const utcString = dateService.toUTC(localDate);
const convertedBack = dateService.fromUTC(utcString);
expect(dateService.isValid(convertedBack)).toBe(true);
// Should be same day (accounting for timezone)
const validation = dateService.validateDate(convertedBack);
expect(validation.valid).toBe(true);
});
it('should validate dates during DST transitions', () => {
// Spring DST: March 31, 2024 in Copenhagen
const dstDate = new Date(2024, 2, 31, 2, 30); // Non-existent hour
// JavaScript handles this, should still be valid
expect(dateService.isValid(dstDate)).toBe(true);
});
});
describe('Edge Case Validation Combinations', () => {
it('should reject invalid date even with lenient options', () => {
const invalidDate = new Date('completely invalid');
const result = dateService.validateDate(invalidDate, {
minDate: new Date(1900, 0, 1),
maxDate: new Date(2100, 11, 31)
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid date');
});
it('should validate boundary dates with constraints', () => {
const boundaryDate = new Date(1900, 0, 1);
const result = dateService.validateDate(boundaryDate, {
minDate: new Date(1900, 0, 1)
});
expect(result.valid).toBe(true);
});
it('should provide meaningful error messages', () => {
const testCases = [
{ date: new Date('invalid'), expectedError: 'Invalid date' },
{ date: new Date(1800, 0, 1), expectedError: 'bounds' },
];
testCases.forEach(({ date, expectedError }) => {
const result = dateService.validateDate(date);
expect(result.valid).toBe(false);
expect(result.error).toContain(expectedError);
});
});
it('should validate leap year boundaries correctly', () => {
const leapYearEnd = new Date(2024, 1, 29); // Last day of Feb in leap year
const nonLeapYearEnd = new Date(2023, 1, 28); // Last day of Feb in non-leap year
expect(dateService.validateDate(leapYearEnd).valid).toBe(true);
expect(dateService.validateDate(nonLeapYearEnd).valid).toBe(true);
});
});
});

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