Refactor workweek preset change handling

Simplifies workweek change event propagation across managers

Removes redundant grid rerendering logic and focuses on clean event communication
Prepares infrastructure for more flexible workweek configuration updates

Relates to debug-gridstyle branch
This commit is contained in:
Janus C. H. Knudsen 2025-11-07 15:12:05 +01:00
parent 1fa7aa26e5
commit 024ad45bfd
5 changed files with 300 additions and 39 deletions

219
CLAUDE.md Normal file
View file

@ -0,0 +1,219 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Calendar Plantempus is a professional TypeScript calendar component with offline-first architecture, drag-and-drop functionality, and real-time synchronization capabilities.
## Build & Development Commands
```bash
# Build the project (bundles to wwwroot/js/calendar.js)
npm run build
# Watch mode for development
npm run watch
# Clean build output
npm run clean
# Type check only
npx tsc --noEmit
# Run all tests
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
# CSS Development
npm run css:build # Build CSS
npm run css:watch # Watch and rebuild CSS
npm run css:build:prod # Build minified production CSS
npm run css:analyze # Analyze CSS metrics
```
## Architecture
### Core Design Pattern: Dependency Injection with NovaDI
The application uses **NovaDI** (@novadi/core) for dependency injection. All managers, services, and repositories are registered in `src/index.ts` and resolved through the DI container.
**Key principle**: Never instantiate managers or services directly with `new`. Always use constructor injection and register types in the container.
### Event-Driven Architecture
The application uses a **centralized EventBus** (`src/core/EventBus.ts`) built on DOM CustomEvents for all inter-component communication. This is the ONLY way components should communicate.
- All event types are defined in `src/constants/CoreEvents.ts` (reduced from 102+ to ~20 core events)
- Components emit events via `eventBus.emit(CoreEvents.EVENT_NAME, payload)`
- Components subscribe via `eventBus.on(CoreEvents.EVENT_NAME, handler)`
- Never call methods directly between managers - always use events
### Manager Hierarchy
**CalendarManager** (`src/managers/CalendarManager.ts`) - Top-level coordinator
- Manages calendar state (current view, current date)
- Orchestrates initialization sequence
- Coordinates other managers via EventBus
**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
- **AllDayManager** - All-day event layout and rendering
- **HeaderManager** - Date headers and all-day event container
- **ScrollManager** - Scroll behavior and position management
- **EdgeScrollManager** - Automatic scrolling at viewport edges during drag
### 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
All repository methods accept an `UpdateSource` parameter ('local' | 'remote') to distinguish user actions from remote updates.
### Offline-First Sync Architecture
**SyncManager** (`src/workers/SyncManager.ts`) provides background synchronization:
1. Local changes are written to **IndexedDB** immediately
2. Operations are queued in **OperationQueue**
3. SyncManager processes queue when online (5-second polling)
4. Failed operations retry with exponential backoff (max 5 retries)
5. Events have `syncStatus`: 'synced' | 'pending' | 'error'
### Rendering Strategy Pattern
**EventRenderingService** (`src/renderers/EventRendererManager.ts`) uses strategy pattern:
- **IEventRenderer** interface defines rendering contract
- **DateEventRenderer** - Renders timed events in day columns
- **AllDayEventRenderer** - Renders all-day events in header
- Strategies can be swapped without changing core logic
### Layout Engines
**EventStackManager** (`src/managers/EventStackManager.ts`) - Uses CSS flexbox for overlapping events:
- Groups overlapping events into stacks
- Calculates flex positioning (basis, grow, shrink)
- Handles multi-column spanning events
**AllDayLayoutEngine** (`src/utils/AllDayLayoutEngine.ts`) - Row-based layout for all-day events:
- Detects overlaps and assigns row positions
- Supports collapsed view (max 4 rows) with "+N more" indicator
- Calculates container height dynamically
### Configuration System
Configuration is loaded from `wwwroot/data/calendar-config.json` via **ConfigManager**:
- **GridSettings** - Hour height, work hours, snap interval
- **DateViewSettings** - Period type, first day of week
- **TimeFormatConfig** - Timezone, locale, 12/24-hour format
- **WorkWeekSettings** - Configurable work week presets
- **Interaction** - Enable/disable drag, resize, create
Access via injected `Configuration` instance, never load config directly.
## Important Patterns & Conventions
### Event Type Conversion (Drag & Drop)
When dragging events between timed grid and all-day area:
- **Timed → All-day**: `DragDropManager` emits `drag:mouseenter-header`, `AllDayManager` creates all-day clone
- **All-day → Timed**: `DragDropManager` emits `drag:mouseenter-column`, `EventRenderingService` creates timed clone
- Original element is marked with `data-conversion-source="true"`
- Clone is marked with `data-converted-clone="true"`
### Scroll Compensation During Drag
`DragDropManager` tracks scroll delta during edge-scrolling:
1. Listens to `edge-scroll:scrolling` events
2. Accumulates `scrollDeltaY` from scroll events
3. Compensates dragged element position: `targetY = mouseY - scrollDeltaY - mouseOffset.y`
4. Prevents visual "jumping" during scroll
### Grid Snapping
When dropping events, snap to time grid:
1. Get mouse Y position relative to column
2. Convert to time using `PositionUtils.getTimeAtPosition()`
3. Account for `mouseOffset.y` (click position within event)
4. Snap to nearest `snapInterval` (default 15 minutes)
### Testing with Vitest
Tests use **Vitest** with **jsdom** environment:
- Setup file: `test/setup.ts`
- Test helpers: `test/helpers/dom-helpers.ts`
- Run single test: `npm test -- <test-file-name>`
## Key Files to Know
- `src/index.ts` - DI container setup and initialization
- `src/core/EventBus.ts` - Central event dispatcher
- `src/constants/CoreEvents.ts` - All event type constants
- `src/types/CalendarTypes.ts` - Core type definitions
- `src/managers/CalendarManager.ts` - Main coordinator
- `src/managers/DragDropManager.ts` - Detailed drag-drop architecture docs
- `src/configurations/CalendarConfig.ts` - Configuration schema
- `wwwroot/data/calendar-config.json` - Runtime configuration
## Common Tasks
### Adding a New Event Type to CoreEvents
1. Add constant to `src/constants/CoreEvents.ts`
2. Define payload type in `src/types/EventTypes.ts`
3. Emit with `eventBus.emit(CoreEvents.NEW_EVENT, payload)`
4. Subscribe with `eventBus.on(CoreEvents.NEW_EVENT, handler)`
### Adding a New Manager
1. Create in `src/managers/`
2. Inject dependencies via constructor (EventBus, Configuration, other managers)
3. Register in DI container in `src/index.ts`: `builder.registerType(NewManager).as<NewManager>()`
4. Communicate via EventBus only, never direct method calls
5. Initialize in CalendarManager if needed
### Modifying Event Data
Always go through EventManager:
- Create: `eventManager.createEvent(eventData)`
- Update: `eventManager.updateEvent(id, updates)`
- Delete: `eventManager.deleteEvent(id)`
EventManager handles repository calls, event emission, and UI updates.
### Debugging
Debug mode is enabled in development:
```javascript
eventBus.setDebug(true); // In src/index.ts
```
Access debug interface in browser console:
```javascript
window.calendarDebug.eventBus.getEventLog()
window.calendarDebug.calendarManager
window.calendarDebug.eventManager
```
## Dependencies
- **@novadi/core** - Dependency injection framework
- **date-fns** / **date-fns-tz** - Date manipulation and timezone support
- **fuse.js** - Fuzzy search for event filtering
- **esbuild** - Fast bundler for development
- **vitest** - Testing framework
- **postcss** - CSS processing and optimization

View file

@ -111,7 +111,7 @@ export class CalendarManager {
/** /**
* Setup event listeners for at håndtere events fra andre managers * Setup event listeners for at håndtere events fra andre managers
*/ */
private setupEventListeners(): void { private setupEventListeners(): void {
// Listen for workweek changes only // Listen for workweek changes only
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => { this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => {
@ -185,22 +185,6 @@ export class CalendarManager {
*/ */
private handleWorkweekChange(): void { private handleWorkweekChange(): void {
// Force a complete grid rebuild by clearing existing structure
const container = document.querySelector('swp-calendar-container');
if (container) {
container.innerHTML = ''; // Clear everything to force full rebuild
}
// Re-render the grid with new workweek settings (will now rebuild everything)
this.gridManager.render();
// Re-initialize scroll manager after grid rebuild
this.scrollManager.initialize();
// 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', { this.eventBus.emit('workweek:header-update', {
currentDate: this.currentDate, currentDate: this.currentDate,
currentView: this.currentView, currentView: this.currentView,
@ -208,26 +192,4 @@ export class CalendarManager {
}); });
} }
/**
* Re-render events after grid structure changes
*/
private async rerenderEvents(): Promise<void> {
// Get current period data to determine date range
const periodData = this.calculateCurrentPeriod();
// Find the grid container to render events in
const container = document.querySelector('swp-calendar-container');
if (!container) {
return;
}
// Trigger event rendering for the current date range using correct method
await this.eventRenderer.renderEvents({
container: container as HTMLElement,
startDate: new Date(periodData.start),
endDate: new Date(periodData.end)
});
}
} }

View file

@ -83,6 +83,9 @@ export class HeaderManager {
}); });
// Listen for workweek header updates after grid rebuild // Listen for workweek header updates after grid rebuild
//currentDate: this.currentDate,
//currentView: this.currentView,
//workweek: this.config.currentWorkWeek
eventBus.on('workweek:header-update', (event) => { eventBus.on('workweek:header-update', (event) => {
const { currentDate } = (event as CustomEvent).detail; const { currentDate } = (event as CustomEvent).detail;
this.updateHeader(currentDate); this.updateHeader(currentDate);

View file

@ -102,6 +102,11 @@ export class ViewManager {
this.updateAllButtons(); this.updateAllButtons();
const settings = this.config.getWorkWeekSettings(); const settings = this.config.getWorkWeekSettings();
//currentDate: this.currentDate,
//currentView: this.currentView,
//workweek: this.config.currentWorkWeek
this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, { this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, {
workWeekId: workweekId, workWeekId: workweekId,
settings: settings settings: settings

View file

@ -0,0 +1,72 @@
# Workweek Preset Click Sequence Diagram
Dette diagram viser hvad der sker når brugeren klikker på en workweek preset knap (f.eks. "Mon-Fri", "Mon-Thu", etc.)
```mermaid
sequenceDiagram
actor User
participant HTML as swp-preset-button
participant VM as ViewManager
participant Config as Configuration
participant CM as ConfigManager
participant EventBus
participant GM as GridManager
participant GR as GridRenderer
participant HM as HeaderManager
participant HR as HeaderRenderer
participant DOM
User->>HTML: Click på preset button<br/>(data-workweek="compressed")
HTML->>VM: click event
Note over VM: setupButtonGroup handler
VM->>VM: getAttribute('data-workweek')<br/>→ "compressed"
VM->>VM: changeWorkweek("compressed")
VM->>Config: setWorkWeek("compressed")
Note over Config: Opdaterer currentWorkWeek<br/>og workweek settings
VM->>CM: updateCSSProperties(config)
Note over CM: Opdaterer CSS custom properties
CM->>DOM: setProperty('--grid-columns', '4')
CM->>DOM: setProperty('--hour-height', '80px')
CM->>DOM: setProperty('--day-start-hour', '6')
CM->>DOM: setProperty('--work-start-hour', '8')
Note over DOM: CSS grid layout opdateres
VM->>VM: updateAllButtons()
VM->>DOM: Update data-active attributter<br/>på alle preset buttons
Note over DOM: Compressed knap får<br/>data-active="true"<br/>Andre knapper mister active
VM->>Config: getWorkWeekSettings()
Config-->>VM: { id: 'compressed',<br/>workDays: [1,2,3,4],<br/>totalDays: 4 }
VM->>EventBus: emit(WORKWEEK_CHANGED, payload)
Note over EventBus: Event: 'workweek:changed'<br/>Payload: { workWeekId, settings }
EventBus->>GM: WORKWEEK_CHANGED event
Note over GM: Listener setup i subscribeToEvents()
GM->>GM: render()
GM->>GR: renderGrid(container, currentDate)
alt First render (empty grid)
GR->>GR: createCompleteGridStructure()
GR->>DOM: Create time axis
GR->>DOM: Create grid container
GR->>DOM: Create 4 columns (Mon-Thu)
else Update existing grid
GR->>GR: updateGridContent()
GR->>DOM: Update existing columns
end
GM->>EventBus: emit(GRID_RENDERED)
EventBus->>HM: WORKWEEK_CHANGED event
Note over HM: Via 'workweek:header-update'<br/>from CalendarManager
HM->>HM: updateHeader(currentDate)
HM->>HR: render(context)
HR->>DOM: Update header med 4 dage<br/>(Mon, Tue, Wed, Thu)
Note over DOM: Grid viser nu kun<br/>Man-Tor (4 dage)<br/>med opdaterede headers
DOM-->>User: Visuelt feedback:<br/>4-dages arbejdsuge