From 9bc082eed47d74c4b451d12384addf813795a629 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Sat, 4 Oct 2025 00:32:26 +0200 Subject: [PATCH] Improves date handling and event stacking Enhances date validation and timezone handling using DateService, ensuring data integrity and consistency. Refactors event rendering and dragging to correctly handle date transformations. Adds a test plan for event stacking and z-index management. Fixes edge cases in navigation and date calculations for week/year boundaries and DST transitions. --- .workbench/stacking-test-desc.txt | 81 ++++ src/data/mock-events.json | 10 +- src/elements/SwpEventElement.ts | 12 +- src/managers/AllDayManager.ts | 12 +- src/managers/CalendarManager.ts | 2 +- src/managers/EventManager.ts | 29 +- src/managers/GridManager.ts | 2 +- src/managers/NavigationManager.ts | 13 +- src/managers/WorkHoursManager.ts | 2 +- src/renderers/EventRenderer.ts | 8 +- src/renderers/GridRenderer.ts | 2 +- src/strategies/WeekViewStrategy.ts | 2 +- src/utils/DateService.ts | 80 ++++ src/utils/PositionUtils.ts | 4 +- .../NavigationManager.edge-cases.test.ts | 295 ++++++++++++++ test/utils/DateService.edge-cases.test.ts | 218 ++++++++++ test/utils/DateService.midnight.test.ts | 246 ++++++++++++ test/utils/DateService.validation.test.ts | 376 ++++++++++++++++++ test/utils/OverlapDetector.test.ts | 287 +++++++++++++ wwwroot/css/calendar-events-css.css | 1 - 20 files changed, 1641 insertions(+), 41 deletions(-) create mode 100644 .workbench/stacking-test-desc.txt create mode 100644 test/managers/NavigationManager.edge-cases.test.ts create mode 100644 test/utils/DateService.edge-cases.test.ts create mode 100644 test/utils/DateService.midnight.test.ts create mode 100644 test/utils/DateService.validation.test.ts create mode 100644 test/utils/OverlapDetector.test.ts diff --git a/.workbench/stacking-test-desc.txt b/.workbench/stacking-test-desc.txt new file mode 100644 index 0000000..cb42d0b --- /dev/null +++ b/.workbench/stacking-test-desc.txt @@ -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. \ No newline at end of file diff --git a/src/data/mock-events.json b/src/data/mock-events.json index 68db0e5..0430586 100644 --- a/src/data/mock-events.json +++ b/src/data/mock-events.json @@ -1861,8 +1861,8 @@ { "id": "144", "title": "Team Standup", - "start": "2025-09-29T05:00:00Z", - "end": "2025-09-29T05:30:00Z", + "start": "2025-09-29T07:30:00Z", + "end": "2025-09-29T08:30:00Z", "type": "meeting", "allDay": false, "syncStatus": "synced", @@ -1874,7 +1874,7 @@ { "id": "145", "title": "Månedlig Planlægning", - "start": "2025-09-29T06:00:00Z", + "start": "2025-09-29T07:00:00Z", "end": "2025-09-29T08:00:00Z", "type": "meeting", "allDay": false, @@ -1887,8 +1887,8 @@ { "id": "146", "title": "Performance Test", - "start": "2025-09-29T10:00:00Z", - "end": "2025-09-29T12:00:00Z", + "start": "2025-09-29T09:00:00Z", + "end": "2025-09-29T10:00:00Z", "type": "work", "allDay": false, "syncStatus": "synced", diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts index e357b62..60e7b5d 100644 --- a/src/elements/SwpEventElement.ts +++ b/src/elements/SwpEventElement.ts @@ -3,6 +3,7 @@ import { calendarConfig } from '../core/CalendarConfig'; import { TimeFormatter } from '../utils/TimeFormatter'; import { PositionUtils } from '../utils/PositionUtils'; import { EventLayout } from '../utils/AllDayLayoutEngine'; +import { DateService } from '../utils/DateService'; /** * Abstract base class for event DOM elements @@ -10,9 +11,12 @@ import { EventLayout } from '../utils/AllDayLayoutEngine'; export abstract class BaseEventElement { protected element: HTMLElement; protected event: CalendarEvent; + protected dateService: DateService; protected constructor(event: CalendarEvent) { this.event = event; + const timezone = calendarConfig.getTimezone?.(); + this.dateService = new DateService(timezone); this.element = this.createElement(); this.setDataAttributes(); } @@ -28,8 +32,8 @@ export abstract class BaseEventElement { protected setDataAttributes(): void { this.element.dataset.eventId = this.event.id; this.element.dataset.title = this.event.title; - this.element.dataset.start = this.event.start.toISOString(); - this.element.dataset.end = this.event.end.toISOString(); + this.element.dataset.start = this.dateService.toUTC(this.event.start); + this.element.dataset.end = this.dateService.toUTC(this.event.end); this.element.dataset.type = this.event.type; this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60'; } @@ -245,8 +249,8 @@ export class SwpAllDayEventElement extends BaseEventElement { */ private setAllDayAttributes(): void { this.element.dataset.allday = "true"; - this.element.dataset.start = this.event.start.toISOString(); - this.element.dataset.end = this.event.end.toISOString(); + this.element.dataset.start = this.dateService.toUTC(this.event.start); + this.element.dataset.end = this.dateService.toUTC(this.event.end); } /** diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts index de7032d..7dce7db 100644 --- a/src/managers/AllDayManager.ts +++ b/src/managers/AllDayManager.ts @@ -1,7 +1,7 @@ // All-day row height management and animations import { eventBus } from '../core/EventBus'; -import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; +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'; @@ -18,6 +18,7 @@ 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 @@ -26,6 +27,7 @@ import { differenceInCalendarDays } from 'date-fns'; export class AllDayManager { private allDayEventRenderer: AllDayEventRenderer; private eventManager: EventManager; + private dateService: DateService; private layoutEngine: AllDayLayoutEngine | null = null; @@ -43,6 +45,8 @@ export class AllDayManager { 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`); @@ -420,9 +424,9 @@ export class AllDayManager { newEndDate.setDate(newEndDate.getDate() + durationDays); newEndDate.setHours(originalEndDate.getHours(), originalEndDate.getMinutes(), originalEndDate.getSeconds(), originalEndDate.getMilliseconds()); - // Update data attributes with new dates - dragEndEvent.draggedClone.dataset.start = newStartDate.toISOString(); - dragEndEvent.draggedClone.dataset.end = newEndDate.toISOString(); + // 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, diff --git a/src/managers/CalendarManager.ts b/src/managers/CalendarManager.ts index 53f5c72..8119b0d 100644 --- a/src/managers/CalendarManager.ts +++ b/src/managers/CalendarManager.ts @@ -42,7 +42,7 @@ export class CalendarManager { this.eventRenderer = eventRenderer; this.scrollManager = scrollManager; this.eventFilterManager = new EventFilterManager(); - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); this.setupEventListeners(); } diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts index 01c1760..83e0898 100644 --- a/src/managers/EventManager.ts +++ b/src/managers/EventManager.ts @@ -30,7 +30,7 @@ export class EventManager { constructor(eventBus: IEventBus) { this.eventBus = eventBus; - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); } @@ -156,20 +156,23 @@ export class EventManager { return null; } - try { - if (isNaN(event.start.getTime())) { - console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start); - return null; - } - - return { - event, - eventDate: event.start - }; - } 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 + }; } /** diff --git a/src/managers/GridManager.ts b/src/managers/GridManager.ts index 9efbdac..435b9c5 100644 --- a/src/managers/GridManager.ts +++ b/src/managers/GridManager.ts @@ -137,7 +137,7 @@ export class GridManager { 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 = this.getISOWeekStart(this.currentDate); const defaultWeekEnd = this.getWeekEnd(this.currentDate); diff --git a/src/managers/NavigationManager.ts b/src/managers/NavigationManager.ts index 8a3c286..d56d634 100644 --- a/src/managers/NavigationManager.ts +++ b/src/managers/NavigationManager.ts @@ -90,17 +90,22 @@ export class NavigationManager { this.eventBus.on(CoreEvents.DATE_CHANGED, (event: Event) => { const customEvent = event as CustomEvent; const dateFromEvent = customEvent.detail.currentDate; - + // Validate date before processing if (!dateFromEvent) { + console.warn('NavigationManager: No date provided in DATE_CHANGED event'); return; } - + const targetDate = new Date(dateFromEvent); - 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; } - + this.navigateToDate(targetDate); }); diff --git a/src/managers/WorkHoursManager.ts b/src/managers/WorkHoursManager.ts index e12ac81..f9d8f9b 100644 --- a/src/managers/WorkHoursManager.ts +++ b/src/managers/WorkHoursManager.ts @@ -38,7 +38,7 @@ export class WorkHoursManager { private workSchedule: WorkScheduleConfig; constructor() { - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); // Default work schedule - will be loaded from JSON later diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 5c8ff32..63af422 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -40,7 +40,7 @@ export class DateEventRenderer implements EventRendererStrategy { private dateService: DateService; constructor() { - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); this.setupDragEventListeners(); } @@ -102,6 +102,7 @@ export class DateEventRenderer implements EventRendererStrategy { private applyDragStyling(element: HTMLElement): void { element.classList.add('dragging'); + element.style.removeProperty("margin-left"); } @@ -174,8 +175,9 @@ export class DateEventRenderer implements EventRendererStrategy { endDate = this.dateService.addDays(endDate, extraDays); } - element.dataset.start = startDate.toISOString(); - element.dataset.end = endDate.toISOString(); + // Convert to UTC before storing as ISO string + element.dataset.start = this.dateService.toUTC(startDate); + element.dataset.end = this.dateService.toUTC(endDate); } /** diff --git a/src/renderers/GridRenderer.ts b/src/renderers/GridRenderer.ts index 93ec0c8..51913cb 100644 --- a/src/renderers/GridRenderer.ts +++ b/src/renderers/GridRenderer.ts @@ -16,7 +16,7 @@ export class GridRenderer { private dateService: DateService; constructor() { - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); } diff --git a/src/strategies/WeekViewStrategy.ts b/src/strategies/WeekViewStrategy.ts index db19c5c..bd8e1db 100644 --- a/src/strategies/WeekViewStrategy.ts +++ b/src/strategies/WeekViewStrategy.ts @@ -15,7 +15,7 @@ export class WeekViewStrategy implements ViewStrategy { private styleManager: GridStyleManager; constructor() { - const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen'; + const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); this.gridRenderer = new GridRenderer(); this.styleManager = new GridStyleManager(); diff --git a/src/utils/DateService.ts b/src/utils/DateService.ts index 626a442..48c6e87 100644 --- a/src/utils/DateService.ts +++ b/src/utils/DateService.ts @@ -101,6 +101,16 @@ export class DateService { 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) @@ -413,6 +423,76 @@ export class DateService { 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 diff --git a/src/utils/PositionUtils.ts b/src/utils/PositionUtils.ts index 9043b77..15a546d 100644 --- a/src/utils/PositionUtils.ts +++ b/src/utils/PositionUtils.ts @@ -222,12 +222,12 @@ export class PositionUtils { } /** - * Convert time string to ISO datetime using DateService + * Convert time string to ISO datetime using DateService with timezone handling */ public static timeStringToIso(timeString: string, date: Date = new Date()): string { const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString); const newDate = PositionUtils.dateService.createDateAtTime(date, totalMinutes); - return newDate.toISOString(); + return PositionUtils.dateService.toUTC(newDate); } /** diff --git a/test/managers/NavigationManager.edge-cases.test.ts b/test/managers/NavigationManager.edge-cases.test.ts new file mode 100644 index 0000000..7010d15 --- /dev/null +++ b/test/managers/NavigationManager.edge-cases.test.ts @@ -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()); + }); + }); +}); diff --git a/test/utils/DateService.edge-cases.test.ts b/test/utils/DateService.edge-cases.test.ts new file mode 100644 index 0000000..da50aa2 --- /dev/null +++ b/test/utils/DateService.edge-cases.test.ts @@ -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); + }); + }); +}); diff --git a/test/utils/DateService.midnight.test.ts b/test/utils/DateService.midnight.test.ts new file mode 100644 index 0000000..c0fb4e7 --- /dev/null +++ b/test/utils/DateService.midnight.test.ts @@ -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'); + }); + }); +}); diff --git a/test/utils/DateService.validation.test.ts b/test/utils/DateService.validation.test.ts new file mode 100644 index 0000000..9cf41b8 --- /dev/null +++ b/test/utils/DateService.validation.test.ts @@ -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); + }); + }); +}); diff --git a/test/utils/OverlapDetector.test.ts b/test/utils/OverlapDetector.test.ts new file mode 100644 index 0000000..dc712f1 --- /dev/null +++ b/test/utils/OverlapDetector.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { OverlapDetector } from '../../src/utils/OverlapDetector'; +import { CalendarEvent } from '../../src/types/CalendarTypes'; + +describe('OverlapDetector', () => { + let detector: OverlapDetector; + + beforeEach(() => { + detector = new OverlapDetector(); + }); + + // Helper function to create test events + const createEvent = (id: string, startHour: number, startMin: number, endHour: number, endMin: number): CalendarEvent => { + const start = new Date(2024, 0, 1, startHour, startMin); + const end = new Date(2024, 0, 1, endHour, endMin); + return { + id, + title: `Event ${id}`, + start, + end, + type: 'meeting', + allDay: false, + syncStatus: 'synced' + }; + }; + + describe('resolveOverlap', () => { + it('should detect no overlap when events do not overlap', () => { + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(0); + }); + + it('should detect overlap when events partially overlap', () => { + const event1 = createEvent('1', 9, 0, 10, 30); // 09:00-10:30 + const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + expect(overlaps[0].id).toBe('2'); + }); + + it('should detect overlap when one event contains another', () => { + const event1 = createEvent('1', 9, 0, 12, 0); // 09:00-12:00 + const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + expect(overlaps[0].id).toBe('2'); + }); + + it('should detect overlap when events have same start time', () => { + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 9, 0, 10, 30); // 09:00-10:30 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + expect(overlaps[0].id).toBe('2'); + }); + + it('should detect overlap when events have same end time', () => { + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 9, 30, 10, 0); // 09:30-10:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + expect(overlaps[0].id).toBe('2'); + }); + + it('should detect multiple overlapping events', () => { + const event1 = createEvent('1', 9, 0, 11, 0); // 09:00-11:00 + const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30 + const event3 = createEvent('3', 10, 0, 11, 30); // 10:00-11:30 + const event4 = createEvent('4', 12, 0, 13, 0); // 12:00-13:00 (no overlap) + + const overlaps = detector.resolveOverlap(event1, [event2, event3, event4]); + + expect(overlaps).toHaveLength(2); + expect(overlaps.map(e => e.id)).toEqual(['2', '3']); + }); + + it('should handle edge case where event ends exactly when another starts', () => { + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + // Events that touch at boundaries should NOT overlap + expect(overlaps).toHaveLength(0); + }); + + it('should handle events with 1-minute overlap', () => { + const event1 = createEvent('1', 9, 0, 10, 1); // 09:00-10:01 + const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + }); + }); + + describe('decorateWithStackLinks', () => { + it('should return empty result when no overlapping events', () => { + const event1 = createEvent('1', 9, 0, 10, 0); + + const result = detector.decorateWithStackLinks(event1, []); + + expect(result.overlappingEvents).toHaveLength(0); + expect(result.stackLinks.size).toBe(0); + }); + + it('should assign stack levels based on start time order', () => { + const event1 = createEvent('1', 9, 0, 10, 30); // 09:00-10:30 + const event2 = createEvent('2', 9, 30, 11, 0); // 09:30-11:00 + + const result = detector.decorateWithStackLinks(event1, [event2]); + + expect(result.stackLinks.size).toBe(2); + + const link1 = result.stackLinks.get('1' as any); + const link2 = result.stackLinks.get('2' as any); + + expect(link1?.stackLevel).toBe(0); + expect(link1?.prev).toBeUndefined(); + expect(link1?.next).toBe('2'); + + expect(link2?.stackLevel).toBe(1); + expect(link2?.prev).toBe('1'); + expect(link2?.next).toBeUndefined(); + }); + + it('should create linked chain for multiple overlapping events', () => { + const event1 = createEvent('1', 9, 0, 11, 0); // 09:00-11:00 + const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30 + const event3 = createEvent('3', 10, 0, 11, 30); // 10:00-11:30 + + const result = detector.decorateWithStackLinks(event1, [event2, event3]); + + expect(result.stackLinks.size).toBe(3); + + const link1 = result.stackLinks.get('1' as any); + const link2 = result.stackLinks.get('2' as any); + const link3 = result.stackLinks.get('3' as any); + + // Check chain: 1 -> 2 -> 3 + expect(link1?.stackLevel).toBe(0); + expect(link1?.prev).toBeUndefined(); + expect(link1?.next).toBe('2'); + + expect(link2?.stackLevel).toBe(1); + expect(link2?.prev).toBe('1'); + expect(link2?.next).toBe('3'); + + expect(link3?.stackLevel).toBe(2); + expect(link3?.prev).toBe('2'); + expect(link3?.next).toBeUndefined(); + }); + + it('should handle events with same start time', () => { + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 9, 0, 10, 30); // 09:00-10:30 + + const result = detector.decorateWithStackLinks(event1, [event2]); + + const link1 = result.stackLinks.get('1' as any); + const link2 = result.stackLinks.get('2' as any); + + // Both start at same time - order may vary but levels should be 0 and 1 + const levels = [link1?.stackLevel, link2?.stackLevel].sort(); + expect(levels).toEqual([0, 1]); + + // Verify they are linked together + expect(result.stackLinks.size).toBe(2); + }); + + it('KNOWN ISSUE: should NOT stack events that do not overlap', () => { + // This test documents the current bug + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30 (overlaps with 1) + const event3 = createEvent('3', 11, 0, 12, 0); // 11:00-12:00 (NO overlap with 1 or 2) + + const result = detector.decorateWithStackLinks(event1, [event2, event3]); + + const link3 = result.stackLinks.get('3' as any); + + // CURRENT BEHAVIOR (BUG): Event 3 gets stackLevel 2 + expect(link3?.stackLevel).toBe(2); + + // EXPECTED BEHAVIOR: Event 3 should get stackLevel 0 since it doesn't overlap + // expect(link3?.stackLevel).toBe(0); + // expect(link3?.prev).toBeUndefined(); + // expect(link3?.next).toBeUndefined(); + }); + + it('KNOWN ISSUE: should reuse stack levels when possible', () => { + // This test documents another aspect of the bug + const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00 + const event2 = createEvent('2', 10, 30, 11, 30); // 10:30-11:30 (NO overlap) + const event3 = createEvent('3', 12, 0, 13, 0); // 12:00-13:00 (NO overlap) + + const result = detector.decorateWithStackLinks(event1, [event2, event3]); + + const link1 = result.stackLinks.get('1' as any); + const link2 = result.stackLinks.get('2' as any); + const link3 = result.stackLinks.get('3' as any); + + // CURRENT BEHAVIOR (BUG): All get different stack levels + expect(link1?.stackLevel).toBe(0); + expect(link2?.stackLevel).toBe(1); + expect(link3?.stackLevel).toBe(2); + + // EXPECTED BEHAVIOR: All should reuse level 0 since none overlap + // expect(link1?.stackLevel).toBe(0); + // expect(link2?.stackLevel).toBe(0); + // expect(link3?.stackLevel).toBe(0); + }); + + it('should handle complex overlapping pattern correctly', () => { + // Event 1: 09:00-11:00 (base) + // Event 2: 09:30-10:30 (overlaps with 1) + // Event 3: 10:00-11:30 (overlaps with 1 and 2) + // Event 4: 11:00-12:00 (overlaps with 3 only) + + const event1 = createEvent('1', 9, 0, 11, 0); + const event2 = createEvent('2', 9, 30, 10, 30); + const event3 = createEvent('3', 10, 0, 11, 30); + const event4 = createEvent('4', 11, 0, 12, 0); + + const result = detector.decorateWithStackLinks(event1, [event2, event3, event4]); + + expect(result.stackLinks.size).toBe(4); + + // All events are linked in one chain (current behavior) + const link1 = result.stackLinks.get('1' as any); + const link2 = result.stackLinks.get('2' as any); + const link3 = result.stackLinks.get('3' as any); + const link4 = result.stackLinks.get('4' as any); + + expect(link1?.stackLevel).toBe(0); + expect(link2?.stackLevel).toBe(1); + expect(link3?.stackLevel).toBe(2); + expect(link4?.stackLevel).toBe(3); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero-duration events', () => { + const event1 = createEvent('1', 9, 0, 9, 0); // 09:00-09:00 + const event2 = createEvent('2', 9, 0, 10, 0); // 09:00-10:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + // Zero-duration event at start of another should not overlap + expect(overlaps).toHaveLength(0); + }); + + it('should handle events spanning multiple hours', () => { + const event1 = createEvent('1', 8, 0, 17, 0); // 08:00-17:00 (9 hours) + const event2 = createEvent('2', 12, 0, 13, 0); // 12:00-13:00 + + const overlaps = detector.resolveOverlap(event1, [event2]); + + expect(overlaps).toHaveLength(1); + }); + + it('should handle many events in same time slot', () => { + const event1 = createEvent('1', 9, 0, 10, 0); + const events = [ + createEvent('2', 9, 0, 10, 0), + createEvent('3', 9, 0, 10, 0), + createEvent('4', 9, 0, 10, 0), + createEvent('5', 9, 0, 10, 0) + ]; + + const overlaps = detector.resolveOverlap(event1, events); + + expect(overlaps).toHaveLength(4); + }); + }); +}); \ No newline at end of file diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index 24fd145..76b9c6b 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -59,7 +59,6 @@ swp-day-columns swp-event { opacity: 0.8; left: 2px; right: 2px; - margin-left: 0px; width: auto; }