Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
11 changed files with 850 additions and 4 deletions
Showing only changes of commit 6723658fd9 - Show all commits

408
docs/V2-ARCHITECTURE.md Normal file
View file

@ -0,0 +1,408 @@
# Calendar V2 Architecture
## Oversigt
Calendar V2 er bygget med en event-driven arkitektur og et fleksibelt grouping-system der tillader forskellige kalendervisninger (dag, uge, ressource, team).
---
## Event System
### CoreEvents Katalog
Alle events er defineret i `src/v2/constants/CoreEvents.ts`.
#### Drag-Drop Events
| Event | Payload | Emitter | Subscribers |
|-------|---------|---------|-------------|
| `event:drag-start` | `IDragStartPayload` | DragDropManager | EdgeScrollManager |
| `event:drag-move` | `IDragMovePayload` | DragDropManager | EventRenderer |
| `event:drag-end` | `IDragEndPayload` | DragDropManager | EdgeScrollManager |
| `event:drag-cancel` | `IDragCancelPayload` | DragDropManager | EdgeScrollManager |
| `event:drag-column-change` | `IDragColumnChangePayload` | DragDropManager | EventRenderer |
#### Resize Events
| Event | Payload | Emitter | Subscribers |
|-------|---------|---------|-------------|
| `event:resize-start` | `IResizeStartPayload` | ResizeManager | - |
| `event:resize-end` | `IResizeEndPayload` | ResizeManager | - |
#### Edge Scroll Events
| Event | Payload | Emitter | Subscribers |
|-------|---------|---------|-------------|
| `edge-scroll:tick` | `{ scrollDelta: number }` | EdgeScrollManager | DragDropManager |
| `edge-scroll:started` | `{}` | EdgeScrollManager | - |
| `edge-scroll:stopped` | `{}` | EdgeScrollManager | - |
#### Lifecycle Events
| Event | Purpose |
|-------|---------|
| `core:initialized` | DI container klar |
| `core:ready` | Kalender fuldt initialiseret |
| `core:destroyed` | Cleanup færdig |
#### Data Events
| Event | Purpose |
|-------|---------|
| `data:loading` | Data fetch startet |
| `data:loaded` | Data fetch færdig |
| `data:error` | Data fetch fejlet |
| `entity:saved` | Entity gemt i IndexedDB |
| `entity:deleted` | Entity slettet fra IndexedDB |
#### View Events
| Event | Purpose |
|-------|---------|
| `view:changed` | View/grouping ændret |
| `view:rendered` | View rendering færdig |
| `events:rendered` | Events renderet til DOM |
| `grid:rendered` | Time grid renderet |
---
## Event Flows
### Drag-Drop Flow
```
pointerdown på event
└── DragDropManager.handlePointerDown()
└── Gem mouseDownPosition, capture pointer
pointermove (>5px)
└── DragDropManager.initializeDrag()
├── Opret ghost clone (opacity 0.3)
├── Marker original med .dragging
├── EMIT: event:drag-start
│ └── EdgeScrollManager starter scrollTick loop
└── Start animateDrag() RAF loop
pointermove (under drag)
├── DragDropManager.updateDragTarget()
│ ├── Detect kolonneændring
│ │ └── EMIT: event:drag-column-change
│ │ └── EventRenderer flytter element til ny kolonne
│ └── Beregn targetY for smooth interpolation
└── DragDropManager.animateDrag() (RAF)
├── Interpoler currentY mod targetY (factor 0.3)
├── Opdater element.style.top
└── EMIT: event:drag-move
└── EventRenderer.updateDragTimestamp()
└── Beregn snapped tid og opdater visning
EdgeScrollManager.scrollTick() (parallel RAF)
├── Beregn velocity baseret på museafstand til kanter
│ - Inner zone (0-50px): 640 px/sek
│ - Outer zone (50-100px): 140 px/sek
├── Scroll viewport: scrollTop += scrollDelta
└── EMIT: edge-scroll:tick
└── DragDropManager kompenserer element position
pointerup
└── DragDropManager.handlePointerUp()
├── Snap currentY til grid (15-min intervaller)
├── Fjern ghost element
├── EMIT: event:drag-end
│ └── EdgeScrollManager stopper scroll
└── Persist ændringer til IndexedDB
```
---
## Grouping System
### ViewConfig
```typescript
interface ViewConfig {
templateId: string; // 'day' | 'simple' | 'resource' | 'team'
groupings: GroupingConfig[];
}
interface GroupingConfig {
type: string; // 'date' | 'resource' | 'team'
values: string[]; // IDs der skal vises
}
```
### Sådan bestemmer groupings strukturen
1. **Kolonneantal**: Produkt af alle grouping dimensioner
- Eksempel: `5 datoer × 2 ressourcer = 10 kolonner`
2. **Header layout**: CSS grid bruger `data-levels` til at stakke headers
- `data-levels="date"` → 1 header række
- `data-levels="resource date"` → 2 header rækker
- `data-levels="team resource date"` → 3 header rækker
3. **Renderer selektion**: Grouping types matches til tilgængelige renderers
### Konfigurationseksempler
#### Simple Date View (3 dage, ingen ressourcer)
```typescript
{
templateId: 'simple',
groupings: [
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
]
}
```
**Resultat:** 3 kolonner, 1 header række
```
┌─── Date1 ───┬─── Date2 ───┬─── Date3 ───┐
│ │ │ │
```
#### Resource View (2 ressourcer, 3 dage)
```typescript
{
templateId: 'resource',
groupings: [
{ type: 'resource', values: ['EMP001', 'EMP002'] },
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
]
}
```
**Resultat:** 6 kolonner, 2 header rækker
```
┌───── Resource1 (span 3) ─────┬───── Resource2 (span 3) ─────┐
├─── D1 ───┬─── D2 ───┬─── D3 ─┼─── D1 ───┬─── D2 ───┬─── D3 ─┤
│ │ │ │ │ │ │
```
#### Team View (2 teams, 2 ressourcer, 3 dage)
```typescript
{
templateId: 'team',
groupings: [
{ type: 'team', values: ['team1', 'team2'] },
{ type: 'resource', values: ['res1', 'res2'] },
{ type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] }
]
}
```
**Resultat:** 12 kolonner, 3 header rækker
```
┌─────────── Team1 (span 6) ───────────┬─────────── Team2 (span 6) ───────────┐
├───── Res1 (span 3) ──┬── Res2 (3) ───┼───── Res1 (span 3) ──┬── Res2 (3) ───┤
├── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┼── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┤
│ │ │ │ │ │ │ │ │ │ │
```
---
## Header Rendering
### data-levels Attribut
`swp-calendar-header` modtager `data-levels` som bestemmer header row layout:
```typescript
// I CalendarOrchestrator.render()
const levels = viewConfig.groupings.map(g => g.type).join(' ');
headerContainer.dataset.levels = levels; // "resource date" eller "team resource date"
```
### CSS Grid Styling
```css
swp-calendar-header[data-levels="date"] > swp-day-header {
grid-row: 1;
}
swp-calendar-header[data-levels="resource date"] {
> swp-resource-header { grid-row: 1; }
> swp-day-header { grid-row: 2; }
}
swp-calendar-header[data-levels="team resource date"] {
> swp-team-header { grid-row: 1; }
> swp-resource-header { grid-row: 2; }
> swp-day-header { grid-row: 3; }
}
```
### Rendering Pipeline
1. Renderers eksekveres i den rækkefølge de står i `viewConfig.groupings`
2. Hver renderer APPENDER sine headers til `headerContainer`
3. CSS bruger `data-levels` + element type til at positionere i korrekt række
---
## Renderers
### DateRenderer
```typescript
class DateRenderer implements IRenderer {
readonly type = 'date';
render(context: IRenderContext): void {
// For HVER ressource (eller én gang hvis ingen):
// For HVER dato i filter['date']:
// Opret swp-day-header + swp-day-column
// Sæt dataset.date & dataset.resourceId
}
}
```
### ResourceRenderer
```typescript
class ResourceRenderer implements IRenderer {
readonly type = 'resource';
async render(context: IRenderContext): Promise<void> {
// Load IResource[] fra ResourceService
// For HVER ressource:
// Opret swp-resource-header
// Sæt grid-column: span ${dateCount}
}
}
```
### TeamRenderer
```typescript
class TeamRenderer implements IRenderer {
readonly type = 'team';
render(context: IRenderContext): void {
// For HVERT team:
// Tæl ressourcer der tilhører team
// Opret swp-team-header
// Sæt grid-column: span ${colspan}
}
}
```
---
## Grid Konfiguration
### IGridConfig
```typescript
interface IGridConfig {
hourHeight: number; // pixels per time (fx 60)
dayStartHour: number; // fx 6 (06:00)
dayEndHour: number; // fx 18 (18:00)
snapInterval: number; // minutter (fx 15)
}
```
### Position Beregning
```typescript
function calculateEventPosition(start: Date, end: Date, config: IGridConfig): EventPosition {
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const dayStartMinutes = config.dayStartHour * 60;
const minuteHeight = config.hourHeight / 60;
return {
top: (startMinutes - dayStartMinutes) * minuteHeight,
height: (endMinutes - startMinutes) * minuteHeight
};
}
```
**Eksempel:** Event 09:00-10:00 med dayStartHour=6, hourHeight=60
- startMinutes = 540, dayStartMinutes = 360
- top = (540 - 360) × 1 = **180px**
- height = (600 - 540) × 1 = **60px**
---
## Z-Index Stack
```
z-index: 10 ← Events (interaktive, draggable)
z-index: 5 ← Unavailable zones (visuel, pointer-events: none)
z-index: 2 ← Time linjer (baggrund)
z-index: 1 ← Kvarter linjer (baggrund)
z-index: 0 ← Grid baggrund
```
---
## Komponenter
| Komponent | Ansvar | Fil |
|-----------|--------|-----|
| **CalendarOrchestrator** | Orkestrerer renderer pipeline | `core/CalendarOrchestrator.ts` |
| **DateRenderer** | Opretter dag-headers og kolonner | `features/date/DateRenderer.ts` |
| **ResourceRenderer** | Opretter ressource-headers | `features/resource/ResourceRenderer.ts` |
| **TeamRenderer** | Opretter team-headers | `features/team/TeamRenderer.ts` |
| **EventRenderer** | Renderer events med positioner | `features/event/EventRenderer.ts` |
| **ScheduleRenderer** | Renderer unavailable zoner | `features/schedule/ScheduleRenderer.ts` |
| **DragDropManager** | Håndterer drag-drop | `managers/DragDropManager.ts` |
| **ResizeManager** | Håndterer event resize | `managers/ResizeManager.ts` |
| **EdgeScrollManager** | Auto-scroll ved kanter | `managers/EdgeScrollManager.ts` |
| **ScrollManager** | Synkroniserer scroll | `core/ScrollManager.ts` |
| **EventBus** | Central event dispatcher | `core/EventBus.ts` |
| **DateService** | Dato formatering og beregning | `core/DateService.ts` |
---
## Filer
```
src/v2/
├── constants/
│ └── CoreEvents.ts # Alle event konstanter
├── core/
│ ├── CalendarOrchestrator.ts
│ ├── DateService.ts
│ ├── EventBus.ts
│ ├── IGridConfig.ts
│ ├── IGroupingRenderer.ts
│ ├── RenderBuilder.ts
│ ├── ScrollManager.ts
│ └── ViewConfig.ts
├── features/
│ ├── date/
│ │ └── DateRenderer.ts
│ ├── event/
│ │ └── EventRenderer.ts
│ ├── resource/
│ │ └── ResourceRenderer.ts
│ ├── schedule/
│ │ └── ScheduleRenderer.ts
│ └── team/
│ └── TeamRenderer.ts
├── managers/
│ ├── DragDropManager.ts
│ └── EdgeScrollManager.ts
├── storage/
│ ├── BaseEntityService.ts
│ ├── IndexedDBContext.ts
│ └── events/
│ ├── EventService.ts
│ └── EventStore.ts
├── types/
│ ├── CalendarTypes.ts
│ ├── DragTypes.ts
│ └── ScheduleTypes.ts
└── utils/
└── PositionUtils.ts
```

View file

@ -44,6 +44,7 @@ import { DataSeeder } from './workers/DataSeeder';
// Features
import { EventRenderer } from './features/event/EventRenderer';
import { ScheduleRenderer } from './features/schedule/ScheduleRenderer';
import { HeaderDrawerRenderer } from './features/headerdrawer/HeaderDrawerRenderer';
// Schedule
import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore';
@ -135,6 +136,7 @@ export function createV2Container(): Container {
// Features
builder.registerType(EventRenderer).as<EventRenderer>();
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
builder.registerType(HeaderDrawerRenderer).as<HeaderDrawerRenderer>();
// Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
builder.registerType(DateRenderer).as<IRenderer>();

View file

@ -37,6 +37,11 @@ export const CoreEvents = {
EVENT_DRAG_CANCEL: 'event:drag-cancel',
EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change',
// Header drag (timed → header conversion)
EVENT_DRAG_ENTER_HEADER: 'event:drag-enter-header',
EVENT_DRAG_MOVE_HEADER: 'event:drag-move-header',
EVENT_DRAG_LEAVE_HEADER: 'event:drag-leave-header',
// Event resize
EVENT_RESIZE_START: 'event:resize-start',
EVENT_RESIZE_END: 'event:resize-end',

View file

@ -10,6 +10,7 @@ import { ViewConfig } from '../core/ViewConfig';
import { DragDropManager } from '../managers/DragDropManager';
import { EdgeScrollManager } from '../managers/EdgeScrollManager';
import { ResizeManager } from '../managers/ResizeManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
export class DemoApp {
private animator!: NavigationAnimator;
@ -27,7 +28,8 @@ export class DemoApp {
private dataSeeder: DataSeeder,
private dragDropManager: DragDropManager,
private edgeScrollManager: EdgeScrollManager,
private resizeManager: ResizeManager
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer
) {}
async init(): Promise<void> {

View file

@ -0,0 +1,135 @@
/**
* HeaderDrawerLayoutEngine - Calculates row placement for header items
*
* Prevents visual overlap by assigning items to different rows when
* they occupy the same columns. Uses a track-based algorithm similar
* to V1's AllDayLayoutEngine.
*
* Each row can hold multiple items as long as they don't overlap in columns.
* When an item spans columns that are already occupied, it's placed in the
* next available row.
*/
export interface IHeaderItemLayout {
itemId: string;
gridArea: string; // "row / col-start / row+1 / col-end"
startColumn: number;
endColumn: number;
row: number;
}
export interface IHeaderItemInput {
id: string;
columnStart: number; // 0-based column index
columnEnd: number; // 0-based end column (inclusive)
}
export class HeaderDrawerLayoutEngine {
private tracks: boolean[][] = [];
private columnCount: number;
constructor(columnCount: number) {
this.columnCount = columnCount;
this.reset();
}
/**
* Reset tracks for new layout calculation
*/
reset(): void {
this.tracks = [new Array(this.columnCount).fill(false)];
}
/**
* Calculate layout for all items
* Items should be sorted by start column for optimal packing
*/
calculateLayout(items: IHeaderItemInput[]): IHeaderItemLayout[] {
this.reset();
const layouts: IHeaderItemLayout[] = [];
for (const item of items) {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied in this row
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
// gridArea format: "row / col-start / row+1 / col-end"
// CSS grid uses 1-based indices
layouts.push({
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1 // 1-based for CSS
});
}
return layouts;
}
/**
* Calculate layout for a single new item
* Useful for real-time drag operations
*/
calculateSingleLayout(item: IHeaderItemInput): IHeaderItemLayout {
const row = this.findAvailableRow(item.columnStart, item.columnEnd);
// Mark columns as occupied
for (let col = item.columnStart; col <= item.columnEnd; col++) {
this.tracks[row][col] = true;
}
return {
itemId: item.id,
gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`,
startColumn: item.columnStart,
endColumn: item.columnEnd,
row: row + 1
};
}
/**
* Find the first row where all columns in range are available
*/
private findAvailableRow(startCol: number, endCol: number): number {
for (let row = 0; row < this.tracks.length; row++) {
if (this.isRowAvailable(row, startCol, endCol)) {
return row;
}
}
// Add new row if all existing rows are occupied
this.tracks.push(new Array(this.columnCount).fill(false));
return this.tracks.length - 1;
}
/**
* Check if columns in range are all available in given row
*/
private isRowAvailable(row: number, startCol: number, endCol: number): boolean {
for (let col = startCol; col <= endCol; col++) {
if (this.tracks[row][col]) {
return false;
}
}
return true;
}
/**
* Get the number of rows currently in use
*/
getRowCount(): number {
return this.tracks.length;
}
/**
* Update column count (e.g., when view changes)
*/
setColumnCount(count: number): void {
this.columnCount = count;
this.reset();
}
}

View file

@ -0,0 +1,151 @@
import { IEventBus } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { CoreEvents } from '../../constants/CoreEvents';
import {
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from '../../types/DragTypes';
/**
* HeaderDrawerRenderer - Handles rendering of items in the header drawer
*
* Listens to drag events from DragDropManager and creates/manages
* swp-header-item elements in the header drawer.
*
* Uses subgrid for column alignment with parent swp-calendar-header.
* Position items via gridArea for explicit row/column placement.
*/
export class HeaderDrawerRenderer {
private currentItem: HTMLElement | null = null;
private container: HTMLElement | null = null;
private sourceElement: HTMLElement | null = null;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig
) {
this.setupListeners();
}
/**
* Setup event listeners for drag events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => {
const payload = (e as CustomEvent<IDragEnterHeaderPayload>).detail;
this.handleDragEnter(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
this.handleDragMove(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeave(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => {
this.handleDragEnd();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => {
this.cleanup();
});
}
/**
* Handle drag entering header zone - create preview item
*/
private handleDragEnter(payload: IDragEnterHeaderPayload): void {
this.container = document.querySelector('swp-header-drawer');
if (!this.container) return;
// Store reference to source element
this.sourceElement = payload.element;
// Create header item
const item = document.createElement('swp-header-item');
item.dataset.id = payload.eventId;
item.dataset.itemType = payload.itemType;
item.dataset.date = payload.sourceDate;
item.dataset.duration = String(payload.duration);
item.textContent = payload.title;
// Apply color class if present
if (payload.colorClass) {
item.classList.add(payload.colorClass);
}
// Add dragging state
item.classList.add('dragging');
// Initial placement (duration determines column span)
// gridArea format: "row / col-start / row+1 / col-end"
const col = payload.sourceColumnIndex + 1;
const endCol = col + payload.duration;
item.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
this.container.appendChild(item);
this.currentItem = item;
// Hide original element while in header
payload.element.style.visibility = 'hidden';
}
/**
* Handle drag moving within header - update column position
*/
private handleDragMove(payload: IDragMoveHeaderPayload): void {
if (!this.currentItem) return;
// Update column position (duration=1 for now)
const col = payload.columnIndex + 1;
const duration = parseInt(this.currentItem.dataset.duration || '1', 10);
const endCol = col + duration;
this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`;
this.currentItem.dataset.date = payload.dateKey;
}
/**
* Handle drag leaving header - remove preview and restore source
*/
private handleDragLeave(_payload: IDragLeaveHeaderPayload): void {
this.cleanup();
}
/**
* Handle drag end - finalize the item (it stays in header)
*/
private handleDragEnd(): void {
if (!this.currentItem) return;
// Remove dragging state
this.currentItem.classList.remove('dragging');
// Item stays - it's now permanent
// TODO: Emit event to persist allDay=true change
// Clear references but leave item in DOM
this.currentItem = null;
this.sourceElement = null;
}
/**
* Cleanup preview item and restore source visibility
*/
private cleanup(): void {
// Remove preview item
this.currentItem?.remove();
this.currentItem = null;
// Restore source element visibility
if (this.sourceElement) {
this.sourceElement.style.visibility = '';
this.sourceElement = null;
}
}
}

View file

@ -0,0 +1,2 @@
export { HeaderDrawerRenderer } from './HeaderDrawerRenderer';
export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine';

View file

@ -8,7 +8,10 @@ import {
IDragMovePayload,
IDragEndPayload,
IDragCancelPayload,
IDragColumnChangePayload
IDragColumnChangePayload,
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload
} from '../types/DragTypes';
interface DragState {
@ -39,6 +42,7 @@ export class DragDropManager {
private pendingElement: HTMLElement | null = null;
private pendingMouseOffset: IMousePosition | null = null;
private container: HTMLElement | null = null;
private inHeader = false;
private readonly DRAG_THRESHOLD = 5;
private readonly INTERPOLATION_FACTOR = 0.3;
@ -159,6 +163,7 @@ export class DragDropManager {
// Cleanup
this.dragState.element.classList.remove('dragging');
this.dragState = null;
this.inHeader = false;
};
private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void {
@ -217,6 +222,12 @@ export class DragDropManager {
private updateDragTarget(e: PointerEvent): void {
if (!this.dragState) return;
// Check header zone first
this.checkHeaderZone(e);
// Skip normal grid handling if in header
if (this.inHeader) return;
// Check for column change
const columnAtPoint = this.getColumnAtPoint(e.clientX);
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) {
@ -244,6 +255,74 @@ export class DragDropManager {
}
}
/**
* Check if pointer is in header zone and emit appropriate events
*/
private checkHeaderZone(e: PointerEvent): void {
if (!this.dragState) return;
const headerViewport = document.querySelector('swp-header-viewport');
if (!headerViewport) return;
const rect = headerViewport.getBoundingClientRect();
const isInHeader = e.clientY < rect.bottom;
if (isInHeader && !this.inHeader) {
// Entered header
this.inHeader = true;
const payload: IDragEnterHeaderPayload = {
eventId: this.dragState.eventId,
element: this.dragState.element,
sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement),
sourceDate: this.dragState.columnElement.dataset.date || '',
title: this.dragState.element.querySelector('swp-event-title')?.textContent || '',
colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')),
itemType: 'event',
duration: 1
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload);
} else if (!isInHeader && this.inHeader) {
// Left header
this.inHeader = false;
const payload: IDragLeaveHeaderPayload = {
eventId: this.dragState.eventId
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
} else if (isInHeader) {
// Moving within header
const column = this.getColumnAtX(e.clientX);
if (column) {
const payload: IDragMoveHeaderPayload = {
eventId: this.dragState.eventId,
columnIndex: this.getColumnIndex(column),
dateKey: column.dataset.date || ''
};
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload);
}
}
}
/**
* Get column index (0-based) for a column element
*/
private getColumnIndex(column: HTMLElement): number {
if (!this.container) return 0;
const columns = Array.from(this.container.querySelectorAll('swp-day-column'));
return columns.indexOf(column);
}
/**
* Get column at X coordinate (alias for getColumnAtPoint)
*/
private getColumnAtX(clientX: number): HTMLElement | null {
return this.getColumnAtPoint(clientX);
}
/**
* Find column element at given X coordinate
*/
@ -323,5 +402,6 @@ export class DragDropManager {
this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload);
this.dragState = null;
this.inHeader = false;
}
}

View file

@ -45,3 +45,25 @@ export interface IDragColumnChangePayload {
newColumn: HTMLElement;
currentY: number;
}
// Header drag payloads
export interface IDragEnterHeaderPayload {
eventId: string;
element: HTMLElement; // Original dragged element
sourceColumnIndex: number;
sourceDate: string;
title: string;
colorClass?: string;
itemType: 'event' | 'reminder';
duration: number; // Antal dage (default 1)
}
export interface IDragMoveHeaderPayload {
eventId: string;
columnIndex: number;
dateKey: string;
}
export interface IDragLeaveHeaderPayload {
eventId: string;
}

View file

@ -346,3 +346,38 @@ swp-allday-container swp-event.transitioning {
.is-amber { --b-primary: var(--b-color-amber); }
.is-orange { --b-primary: var(--b-color-orange); }
.is-deep-orange { --b-primary: var(--b-color-deep-orange); }
/* Header drawer items */
swp-header-item {
--b-text: var(--color-text);
/* Positioneres via style.gridArea */
height: 22px;
margin: 2px 4px;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
transition: background-color 200ms ease;
/* Color system fra swp-event */
background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix));
color: var(--b-text);
border-left: 4px solid var(--b-primary);
&:hover {
background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix));
}
/* Dragging state */
&.dragging {
opacity: 0.7;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}

View file

@ -73,8 +73,12 @@ swp-header-spacer {
}
swp-header-drawer {
display: block;
height: 0;
display: grid;
grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr));
min-width: calc(var(--grid-columns) * var(--day-column-min-width));
grid-auto-rows: 28px;
gap: 2px 0;
min-height: 0;
overflow: hidden;
background: var(--color-background-alt);
border-bottom: 1px solid var(--color-border);