From 73e284660fa8671459ef61d976b0def98e052eb5 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Wed, 3 Dec 2025 14:43:25 +0100 Subject: [PATCH] Adds EventId type for robust event ID handling Introduces type-safe EventId with centralized normalization logic for clone and standard event IDs Refactors event ID management across multiple components to use consistent ID transformation methods Improves type safety and reduces potential ID-related bugs in drag-and-drop and event rendering --- CLAUDE.md | 55 ++++++++++++++++++++++++--------- src/elements/SwpEventElement.ts | 5 +-- src/managers/AllDayManager.ts | 5 +-- src/renderers/EventRenderer.ts | 15 +++------ src/types/EventId.ts | 31 +++++++++++++++++++ 5 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 src/types/EventId.ts diff --git a/CLAUDE.md b/CLAUDE.md index 11820b8..ff242de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Calendar Plantempus is a professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities. +Calendar Plantempus is a professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities. Supports both date-based (day/week/month) and resource-based (people, rooms) calendar views. ## Build & Development Commands @@ -21,18 +21,18 @@ npm run clean # Type check only npx tsc --noEmit -# Run all tests +# Run all tests (watch mode) npm test -# Run tests in watch mode -npm run test - # Run tests once and exit npm run test:run # Run tests with UI npm run test:ui +# Run single test file +npm test -- + # CSS Development npm run css:build # Build CSS npm run css:watch # Watch and rebuild CSS @@ -57,6 +57,21 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o - Components subscribe via `eventBus.on(CoreEvents.EVENT_NAME, handler)` - Never call methods directly between managers - always use events +### Calendar Modes: Date vs Resource + +The calendar supports two column modes, configured at initialization in `src/index.ts`: + +**Date Mode** (`DateColumnDataSource`): +- Columns represent dates (day/week/month views) +- Uses `DateHeaderRenderer` and `DateColumnRenderer` + +**Resource Mode** (`ResourceColumnDataSource`): +- Columns represent resources (people, rooms, equipment) +- Uses `ResourceHeaderRenderer` and `ResourceColumnRenderer` +- Events filtered per-resource via `IColumnInfo.events` + +Both modes implement `IColumnDataSource` interface, allowing polymorphic column handling. + ### Manager Hierarchy **CalendarManager** (`src/managers/CalendarManager.ts`) - Top-level coordinator @@ -67,7 +82,6 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o **Key Managers**: - **EventManager** - Event CRUD operations, data loading from repository - **GridManager** - Renders time grid structure -- **ViewManager** - Handles view switching (day/week/month) - **NavigationManager** - Date navigation and period calculations - **DragDropManager** - Advanced drag-and-drop with smooth animations, type conversion (timed ↔ all-day), scroll compensation - **ResizeHandleManager** - Event resizing with visual feedback @@ -78,13 +92,20 @@ The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built o ### Repository Pattern -Event data access is abstracted through the **IEventRepository** interface (`src/repositories/IEventRepository.ts`): -- **IndexedDBEventRepository** - Primary: Local storage with offline support -- **ApiEventRepository** - Sends changes to backend API -- **MockEventRepository** - Legacy: Loads from JSON file +Data access is abstracted through **IApiRepository** interface (`src/repositories/IApiRepository.ts`): +- **MockEventRepository**, **MockBookingRepository**, **MockCustomerRepository**, **MockResourceRepository** - Development: Load from JSON files +- **ApiEventRepository**, **ApiBookingRepository**, **ApiCustomerRepository**, **ApiResourceRepository** - Production: Backend API calls All repository methods accept an `UpdateSource` parameter ('local' | 'remote') to distinguish user actions from remote updates. +### Entity Service Pattern + +**IEntityService** (`src/storage/IEntityService.ts`) provides polymorphic entity handling: +- `EventService`, `BookingService`, `CustomerService`, `ResourceService` implement this interface +- Encapsulates sync status manipulation (SyncManager delegates, never manipulates directly) +- Uses `entityType` discriminator for runtime routing without switch statements +- Enables polymorphic operations: `Array>` works across all entity types + ### Offline-First Sync Architecture **SyncManager** (`src/workers/SyncManager.ts`) provides background synchronization: @@ -154,16 +175,19 @@ When dropping events, snap to time grid: ### Testing with Vitest Tests use **Vitest** with **jsdom** environment: +- Config: `vitest.config.ts` - Setup file: `test/setup.ts` -- Test helpers: `test/helpers/dom-helpers.ts` +- Test helpers: `test/helpers/dom-helpers.ts`, `test/helpers/config-helpers.ts` - Run single test: `npm test -- ` ## Key Files to Know -- `src/index.ts` - DI container setup and initialization +- `src/index.ts` - DI container setup and initialization (also sets calendar mode: date vs resource) - `src/core/EventBus.ts` - Central event dispatcher -- `src/constants/CoreEvents.ts` - All event type constants +- `src/constants/CoreEvents.ts` - All event type constants (~34 core events) - `src/types/CalendarTypes.ts` - Core type definitions +- `src/types/ColumnDataSource.ts` - Column abstraction for date/resource modes +- `src/storage/IEntityService.ts` - Entity service interface for polymorphic sync - `src/managers/CalendarManager.ts` - Main coordinator - `src/managers/DragDropManager.ts` - Detailed drag-drop architecture docs - `src/configurations/CalendarConfig.ts` - Configuration schema @@ -207,12 +231,15 @@ Access debug interface in browser console: window.calendarDebug.eventBus.getEventLog() window.calendarDebug.calendarManager window.calendarDebug.eventManager +window.calendarDebug.auditService +window.calendarDebug.syncManager +window.calendarDebug.app // Full DI container ``` ## Dependencies - **@novadi/core** - Dependency injection framework -- **date-fns** / **date-fns-tz** - Date manipulation and timezone support +- **dayjs** - Date manipulation and formatting - **fuse.js** - Fuzzy search for event filtering - **esbuild** - Fast bundler for development - **vitest** - Testing framework diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts index 3f28a70..30c8525 100644 --- a/src/elements/SwpEventElement.ts +++ b/src/elements/SwpEventElement.ts @@ -4,6 +4,7 @@ import { Configuration } from '../configurations/CalendarConfig'; import { TimeFormatter } from '../utils/TimeFormatter'; import { PositionUtils } from '../utils/PositionUtils'; import { DateService } from '../utils/DateService'; +import { EventId } from '../types/EventId'; /** * Base class for event elements @@ -172,7 +173,7 @@ export class SwpEventElement extends BaseSwpEventElement { const clone = this.cloneNode(true) as SwpEventElement; // Apply "clone-" prefix to ID - clone.dataset.eventId = `clone-${this.eventId}`; + clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId); // Disable pointer events on clone so it doesn't interfere with hover detection clone.style.pointerEvents = 'none'; @@ -343,7 +344,7 @@ export class SwpAllDayEventElement extends BaseSwpEventElement { const clone = this.cloneNode(true) as SwpAllDayEventElement; // Apply "clone-" prefix to ID - clone.dataset.eventId = `clone-${this.eventId}`; + clone.dataset.eventId = EventId.toCloneId(this.eventId as EventId); // Disable pointer events on clone so it doesn't interfere with hover detection clone.style.pointerEvents = 'none'; diff --git a/src/managers/AllDayManager.ts b/src/managers/AllDayManager.ts index 7f7c9ed..f003630 100644 --- a/src/managers/AllDayManager.ts +++ b/src/managers/AllDayManager.ts @@ -22,6 +22,7 @@ import { IDragOffset, IMousePosition } from '../types/DragDropTypes'; import { CoreEvents } from '../constants/CoreEvents'; import { EventManager } from './EventManager'; import { DateService } from '../utils/DateService'; +import { EventId } from '../types/EventId'; /** * AllDayManager - Handles all-day row height animations and management @@ -500,7 +501,7 @@ export class AllDayManager { if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; - const eventId = clone.eventId.replace('clone-', ''); + const eventId = EventId.from(clone.eventId); const columnIdentifier = dragEndEvent.finalPosition.column.identifier; // Determine target date based on mode @@ -568,7 +569,7 @@ export class AllDayManager { if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return; const clone = dragEndEvent.draggedClone as SwpAllDayEventElement; - const eventId = clone.eventId.replace('clone-', ''); + const eventId = EventId.from(clone.eventId); const columnIdentifier = dragEndEvent.finalPosition.column.identifier; // Determine target date based on mode diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 853a982..0de20cb 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -10,6 +10,7 @@ import { IDragColumnChangeEventPayload, IDragMoveEventPayload, IDragStartEventPa import { DateService } from '../utils/DateService'; import { EventStackManager } from '../managers/EventStackManager'; import { EventLayoutCoordinator, IGridGroupLayout, IStackedEventLayout } from '../managers/EventLayoutCoordinator'; +import { EventId } from '../types/EventId'; /** * Interface for event rendering strategies @@ -165,22 +166,14 @@ export class DateEventRenderer implements IEventRenderer { * Handle drag end event */ public handleDragEnd(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void { - if (!draggedClone || !originalElement) { - console.warn('Missing draggedClone or originalElement'); - return; - } - + // Only fade out and remove if it's a swp-event (not swp-allday-event) // AllDayManager handles removal of swp-allday-event elements if (originalElement.tagName === 'SWP-EVENT') { this.fadeOutAndRemove(originalElement); } - // Remove clone prefix and normalize clone to be a regular event - const cloneId = draggedClone.dataset.eventId; - if (cloneId && cloneId.startsWith('clone-')) { - draggedClone.dataset.eventId = cloneId.replace('clone-', ''); - } + draggedClone.dataset.eventId = EventId.from(draggedClone.dataset.eventId!); // Fully normalize the clone to be a regular event draggedClone.classList.remove('dragging'); @@ -192,7 +185,7 @@ export class DateEventRenderer implements IEventRenderer { // Clean up any remaining day event clones - const dayEventClone = document.querySelector(`swp-event[data-event-id="clone-${cloneId}"]`); + const dayEventClone = document.querySelector(`swp-event[data-event-id="${draggedClone.dataset.eventId}"]`); if (dayEventClone) { dayEventClone.remove(); } diff --git a/src/types/EventId.ts b/src/types/EventId.ts new file mode 100644 index 0000000..114372b --- /dev/null +++ b/src/types/EventId.ts @@ -0,0 +1,31 @@ +/** + * Branded type for Event IDs + * Ensures type-safety and centralizes ID normalization logic + */ +export type EventId = string & { readonly __brand: 'EventId' }; + +/** + * EventId utility functions + */ +export const EventId = { + /** + * Create EventId from string, normalizing clone- prefix + */ + from(id: string): EventId { + return id.replace('clone-', '') as EventId; + }, + + /** + * Check if raw ID is a clone + */ + isClone(id: string): boolean { + return id.startsWith('clone-'); + }, + + /** + * Create clone ID string from EventId + */ + toCloneId(id: EventId): string { + return `clone-${id}`; + } +};