Merge branch 'wip-colors'
This commit is contained in:
commit
b6f2aba398
109 changed files with 18703 additions and 8572 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ Thumbs.db
|
|||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
js/
|
||||
|
|
|
|||
BIN
.workbench/image.png
Normal file
BIN
.workbench/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
217
.workbench/review.txt
Normal file
217
.workbench/review.txt
Normal 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:00–23: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
15
.workbench/scenarie3.html
Normal 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="{"stackLevel":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="{"stackLevel":0,"next":"S3B"}" 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="{"stackLevel":2,"prev":"S3B"}" 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="{"stackLevel":0,"next":"S4B"}" 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
11
.workbench/scenarie9.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{"stackLevel":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="{"stackLevel":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>
|
||||
81
.workbench/stacking-test-desc.txt
Normal file
81
.workbench/stacking-test-desc.txt
Normal 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:00–11:00, *Når* stack beregnes, *Så* `A.prev=null` og `A.stackLevel=1` (SL2).
|
||||
2. **Simpel overlap**
|
||||
*Givet* A 10:00–13:00 og B 10:45–11:15 i samme lane, *Når* stack beregnes, *Så* `B.prev='A'` og `B.stackLevel=2` (SL1–SL3).
|
||||
3. **Fler-leddet overlap**
|
||||
*Givet* A 10–13, B 10:45–11:15, C 11:00–11:30, *Når* stack beregnes, *Så* `B.stackLevel=2`, `C.stackLevel≥2`, ingen huller i levels (SL5).
|
||||
4. **Ingen overlap**
|
||||
*Givet* A 10:00–11:00 og B 11:30–12: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) 10–13 og B(lane2) 10:15–11: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 (SL5–SL6).
|
||||
8. **Resize der fjerner overlap**
|
||||
*Givet* A 10–13 og B 10:45–11:15 (stacked), *Når* B resizes til 13:00–13: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 B’s titel læses fuldt ud, og A’s 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 (1–7)
|
||||
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.
|
||||
34
CLAUDE.md
34
CLAUDE.md
|
|
@ -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
|
||||
578
CYCLOMATIC_COMPLEXITY_ANALYSIS.md
Normal file
578
CYCLOMATIC_COMPLEXITY_ANALYSIS.md
Normal 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
772
STACKING_CONCEPT.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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!** 🚀
|
||||
|
|
@ -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!
|
||||
|
|
@ -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
|
||||
393
code_review.md
393
code_review.md
|
|
@ -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
0
complexity-output.json
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
329
docs/code-analysis-report.md
Normal file
329
docs/code-analysis-report.md
Normal 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
|
||||
50
docs/cyclomatic-complexity-report.md
Normal file
50
docs/cyclomatic-complexity-report.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
204
docs/stack-binding-system.md
Normal file
204
docs/stack-binding-system.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
2230
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
62
scenarios/scenario-1.html
Normal 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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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>
|
||||
77
scenarios/scenario-10.html
Normal file
77
scenarios/scenario-10.html
Normal 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="{"stackLevel":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
61
scenarios/scenario-2.html
Normal 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="{"stackLevel":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
69
scenarios/scenario-3.html
Normal 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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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
69
scenarios/scenario-4.html
Normal 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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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
69
scenarios/scenario-5.html
Normal 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="{"stackLevel":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
69
scenarios/scenario-6.html
Normal 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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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
62
scenarios/scenario-7.html
Normal 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="{"stackLevel":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="{"stackLevel":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="{"stackLevel":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
55
scenarios/scenario-8.html
Normal 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="{"stackLevel":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="{"stackLevel":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
67
scenarios/scenario-9.html
Normal 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="{"stackLevel":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>
|
||||
162
scenarios/scenario-styles.css
Normal file
162
scenarios/scenario-styles.css
Normal 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;
|
||||
}
|
||||
152
scenarios/scenario-test-runner.js
Normal file
152
scenarios/scenario-test-runner.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
390
src/elements/SwpEventElement.ts
Normal file
390
src/elements/SwpEventElement.ts
Normal 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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
25
src/index.ts
25
src/index.ts
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
624
src/managers/AllDayManager.ts
Normal file
624
src/managers/AllDayManager.ts
Normal 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();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
276
src/managers/EventLayoutCoordinator.ts
Normal file
276
src/managers/EventLayoutCoordinator.ts
Normal 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 (A→B→C where each conflicts with next)
|
||||
*
|
||||
* @param firstEvent - The first event to start with
|
||||
* @param remaining - Remaining events to check
|
||||
* @param thresholdMinutes - Threshold in minutes
|
||||
* @returns Array of all events in the conflict chain
|
||||
*/
|
||||
private expandGridCandidates(
|
||||
firstEvent: 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 på 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 på 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;
|
||||
}
|
||||
}
|
||||
269
src/managers/EventStackManager.ts
Normal file
269
src/managers/EventStackManager.ts
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
158
src/managers/HeaderManager.ts
Normal file
158
src/managers/HeaderManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
259
src/managers/ResizeHandleManager.ts
Normal file
259
src/managers/ResizeHandleManager.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
128
src/renderers/AllDayEventRenderer.ts
Normal file
128
src/renderers/AllDayEventRenderer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
47
src/types/DragDropTypes.ts
Normal file
47
src/types/DragDropTypes.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
104
src/types/ManagerTypes.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
142
src/utils/AllDayLayoutEngine.ts
Normal file
142
src/utils/AllDayLayoutEngine.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
118
src/utils/ColumnDetectionUtils.ts
Normal file
118
src/utils/ColumnDetectionUtils.ts
Normal 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;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
508
src/utils/DateService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
222
src/utils/TimeFormatter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
150
stacking-visualization-new.html
Normal file
150
stacking-visualization-new.html
Normal 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
1836
stacking-visualization.html
Normal file
File diff suppressed because it is too large
Load diff
40
test/helpers/dom-helpers.ts
Normal file
40
test/helpers/dom-helpers.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
270
test/managers/AllDayLayoutEngine.test.ts
Normal file
270
test/managers/AllDayLayoutEngine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
test/managers/AllDayManager.test.ts
Normal file
43
test/managers/AllDayManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1237
test/managers/EventStackManager.flexbox.test.ts
Normal file
1237
test/managers/EventStackManager.flexbox.test.ts
Normal file
File diff suppressed because it is too large
Load diff
656
test/managers/EventStackManager.test.ts
Normal file
656
test/managers/EventStackManager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
295
test/managers/NavigationManager.edge-cases.test.ts
Normal file
295
test/managers/NavigationManager.edge-cases.test.ts
Normal 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
13
test/setup.ts
Normal 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
|
||||
}
|
||||
});
|
||||
218
test/utils/DateService.edge-cases.test.ts
Normal file
218
test/utils/DateService.edge-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
246
test/utils/DateService.midnight.test.ts
Normal file
246
test/utils/DateService.midnight.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
259
test/utils/DateService.test.ts
Normal file
259
test/utils/DateService.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
376
test/utils/DateService.validation.test.ts
Normal file
376
test/utils/DateService.validation.test.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue