Refactors calendar project structure and build configuration

Consolidates V2 codebase into main project directory
Updates build script to support simplified entry points
Removes redundant files and cleans up project organization

Simplifies module imports and entry points for calendar application
This commit is contained in:
Janus C. H. Knudsen 2025-12-17 23:54:25 +01:00
parent 9f360237cf
commit 863b433eba
200 changed files with 2331 additions and 16193 deletions

View file

@ -0,0 +1,68 @@
import { IRenderer, IRenderContext } from '../../core/IGroupingRenderer';
import { DateService } from '../../core/DateService';
export class DateRenderer implements IRenderer {
readonly type = 'date';
constructor(private dateService: DateService) {}
render(context: IRenderContext): void {
const dates = context.filter['date'] || [];
const resourceIds = context.filter['resource'] || [];
// Check if date headers should be hidden (e.g., in day view)
const dateGrouping = context.groupings?.find(g => g.type === 'date');
const hideHeader = dateGrouping?.hideHeader === true;
// Render dates for HVER resource (eller 1 gang hvis ingen resources)
const iterations = resourceIds.length || 1;
let columnCount = 0;
for (let r = 0; r < iterations; r++) {
const resourceId = resourceIds[r]; // undefined hvis ingen resources
for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr);
// Build columnKey for uniform identification
const segments: Record<string, string> = { date: dateStr };
if (resourceId) segments.resource = resourceId;
const columnKey = this.dateService.buildColumnKey(segments);
// Header
const header = document.createElement('swp-day-header');
header.dataset.date = dateStr;
header.dataset.columnKey = columnKey;
if (resourceId) {
header.dataset.resourceId = resourceId;
}
if (hideHeader) {
header.dataset.hidden = 'true';
}
header.innerHTML = `
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
context.headerContainer.appendChild(header);
// Column
const column = document.createElement('swp-day-column');
column.dataset.date = dateStr;
column.dataset.columnKey = columnKey;
if (resourceId) {
column.dataset.resourceId = resourceId;
}
column.innerHTML = '<swp-events-layer></swp-events-layer>';
context.columnContainer.appendChild(column);
columnCount++;
}
}
// Set grid columns on container
const container = context.columnContainer.closest('swp-calendar-container');
if (container) {
(container as HTMLElement).style.setProperty('--grid-columns', String(columnCount));
}
}
}

View file

@ -0,0 +1 @@
export { DateRenderer } from './DateRenderer';

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { DepartmentService } from '../../storage/departments/DepartmentService';
import { IDepartment } from '../../types/CalendarTypes';
export class DepartmentRenderer extends BaseGroupingRenderer<IDepartment> {
readonly type = 'department';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-department-header',
idAttribute: 'departmentId',
colspanVar: '--department-cols'
};
constructor(private departmentService: DepartmentService) {
super();
}
protected getEntities(ids: string[]): Promise<IDepartment[]> {
return this.departmentService.getByIds(ids);
}
protected getDisplayName(entity: IDepartment): string {
return entity.name;
}
}

View file

@ -0,0 +1,279 @@
/**
* EventLayoutEngine - Simplified stacking/grouping algorithm
*
* Supports two layout modes:
* - GRID: Events starting at same time rendered side-by-side
* - STACKING: Overlapping events with margin-left offset (15px per level)
*
* No prev/next chains, single-pass greedy algorithm
*/
import { ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition } from '../../utils/PositionUtils';
import { IColumnLayout, IGridGroupLayout, IStackedEventLayout } from './EventLayoutTypes';
/**
* Check if two events overlap (strict - touching at boundary = NOT overlapping)
* This matches Scenario 8: end===start is NOT overlap
*/
export function eventsOverlap(a: ICalendarEvent, b: ICalendarEvent): boolean {
return a.start < b.end && a.end > b.start;
}
/**
* Check if two events are within threshold for grid grouping.
* This includes:
* 1. Start-to-start: Events start within threshold of each other
* 2. End-to-start: One event starts within threshold before another ends
*/
function eventsWithinThreshold(a: ICalendarEvent, b: ICalendarEvent, thresholdMinutes: number): boolean {
const thresholdMs = thresholdMinutes * 60 * 1000;
// Start-to-start: both events start within threshold
const startToStartDiff = Math.abs(a.start.getTime() - b.start.getTime());
if (startToStartDiff <= thresholdMs) return true;
// End-to-start: one event starts within threshold before the other ends
// B starts within threshold before A ends
const bStartsBeforeAEnds = a.end.getTime() - b.start.getTime();
if (bStartsBeforeAEnds > 0 && bStartsBeforeAEnds <= thresholdMs) return true;
// A starts within threshold before B ends
const aStartsBeforeBEnds = b.end.getTime() - a.start.getTime();
if (aStartsBeforeBEnds > 0 && aStartsBeforeBEnds <= thresholdMs) return true;
return false;
}
/**
* Check if all events in a group start within threshold of each other
*/
function allStartWithinThreshold(events: ICalendarEvent[], thresholdMinutes: number): boolean {
if (events.length <= 1) return true;
// Find earliest and latest start times
let earliest = events[0].start.getTime();
let latest = events[0].start.getTime();
for (const event of events) {
const time = event.start.getTime();
if (time < earliest) earliest = time;
if (time > latest) latest = time;
}
const diffMinutes = (latest - earliest) / (1000 * 60);
return diffMinutes <= thresholdMinutes;
}
/**
* Find groups of overlapping events (connected by overlap chain)
* Events are grouped if they overlap with any event in the group
*/
function findOverlapGroups(events: ICalendarEvent[]): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
// Start a new group with this event
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand group by finding all connected events (via overlap)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
// Check if candidate overlaps with any event in group
const connects = group.some(member => eventsOverlap(member, candidate));
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Find grid candidates within a group - events connected via threshold chain
* Uses V1 logic: events are connected if within threshold (no overlap requirement)
*/
function findGridCandidates(
events: ICalendarEvent[],
thresholdMinutes: number
): ICalendarEvent[][] {
if (events.length === 0) return [];
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const used = new Set<string>();
const groups: ICalendarEvent[][] = [];
for (const event of sorted) {
if (used.has(event.id)) continue;
const group: ICalendarEvent[] = [event];
used.add(event.id);
// Expand by threshold chain (V1 logic: no overlap requirement, just threshold)
let expanded = true;
while (expanded) {
expanded = false;
for (const candidate of sorted) {
if (used.has(candidate.id)) continue;
const connects = group.some(member =>
eventsWithinThreshold(member, candidate, thresholdMinutes)
);
if (connects) {
group.push(candidate);
used.add(candidate.id);
expanded = true;
}
}
}
groups.push(group);
}
return groups;
}
/**
* Calculate stack levels for overlapping events using greedy algorithm
* For each event: level = max(overlapping already-processed events) + 1
*/
function calculateStackLevels(events: ICalendarEvent[]): Map<string, number> {
const levels = new Map<string, number>();
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
for (const event of sorted) {
let maxOverlappingLevel = -1;
// Find max level among overlapping events already processed
for (const [id, level] of levels) {
const other = events.find(e => e.id === id);
if (other && eventsOverlap(event, other)) {
maxOverlappingLevel = Math.max(maxOverlappingLevel, level);
}
}
levels.set(event.id, maxOverlappingLevel + 1);
}
return levels;
}
/**
* Allocate events to columns for GRID layout using greedy algorithm
* Non-overlapping events can share a column to minimize total columns
*/
function allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
const columns: ICalendarEvent[][] = [];
for (const event of sorted) {
// Find first column where event doesn't overlap with existing events
let placed = false;
for (const column of columns) {
const canFit = !column.some(e => eventsOverlap(event, e));
if (canFit) {
column.push(event);
placed = true;
break;
}
}
// No suitable column found, create new one
if (!placed) {
columns.push([event]);
}
}
return columns;
}
/**
* Main entry point: Calculate complete layout for a column's events
*
* Algorithm:
* 1. Find overlap groups (events connected by overlap chain)
* 2. For each overlap group, find grid candidates (events within threshold chain)
* 3. If all events in overlap group form a single grid candidate GRID mode
* 4. Otherwise STACKING mode with calculated levels
*/
export function calculateColumnLayout(
events: ICalendarEvent[],
config: IGridConfig
): IColumnLayout {
const thresholdMinutes = config.gridStartThresholdMinutes ?? 10;
const result: IColumnLayout = {
grids: [],
stacked: []
};
if (events.length === 0) return result;
// Find all overlapping event groups
const overlapGroups = findOverlapGroups(events);
for (const overlapGroup of overlapGroups) {
if (overlapGroup.length === 1) {
// Single event - no grouping needed
result.stacked.push({
event: overlapGroup[0],
stackLevel: 0
});
continue;
}
// Within this overlap group, find grid candidates (threshold-connected subgroups)
const gridSubgroups = findGridCandidates(overlapGroup, thresholdMinutes);
// Check if the ENTIRE overlap group forms a single grid candidate
// This happens when all events are connected via threshold chain
const largestGridCandidate = gridSubgroups.reduce((max, g) =>
g.length > max.length ? g : max, gridSubgroups[0]);
if (largestGridCandidate.length === overlapGroup.length) {
// All events in overlap group are connected via threshold chain → GRID mode
const columns = allocateColumns(overlapGroup);
const earliest = overlapGroup.reduce((min, e) =>
e.start < min.start ? e : min, overlapGroup[0]);
const position = calculateEventPosition(earliest.start, earliest.end, config);
result.grids.push({
events: overlapGroup,
columns,
stackLevel: 0,
position: { top: position.top }
});
} else {
// Not all events connected via threshold → STACKING mode
const levels = calculateStackLevels(overlapGroup);
for (const event of overlapGroup) {
result.stacked.push({
event,
stackLevel: levels.get(event.id) ?? 0
});
}
}
}
return result;
}

View file

@ -0,0 +1,35 @@
import { ICalendarEvent } from '../../types/CalendarTypes';
/**
* Stack link metadata stored on event elements
* Simplified from V1: No prev/next chains - only stackLevel needed for rendering
*/
export interface IStackLink {
stackLevel: number;
}
/**
* Layout result for a stacked event (overlapping events with margin offset)
*/
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLevel: number;
}
/**
* Layout result for a grid group (simultaneous events side-by-side)
*/
export interface IGridGroupLayout {
events: ICalendarEvent[];
columns: ICalendarEvent[][]; // Events grouped by column (non-overlapping within column)
stackLevel: number; // Stack level for entire group (if nested in another event)
position: { top: number }; // Top position of earliest event in pixels
}
/**
* Complete layout result for a column's events
*/
export interface IColumnLayout {
grids: IGridGroupLayout[];
stacked: IStackedEventLayout[];
}

View file

@ -0,0 +1,434 @@
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../../types/CalendarTypes';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils';
import { CoreEvents } from '../../constants/CoreEvents';
import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes';
import { calculateColumnLayout } from './EventLayoutEngine';
import { IGridGroupLayout } from './EventLayoutTypes';
import { FilterTemplate } from '../../core/FilterTemplate';
/**
* EventRenderer - Renders calendar events to the DOM
*
* CLEAN approach:
* - Only data-id attribute on event element
* - innerHTML contains only visible content
* - Event data retrieved via EventService when needed
*/
export class EventRenderer {
private container: HTMLElement | null = null;
constructor(
private eventService: EventService,
private dateService: DateService,
private gridConfig: IGridConfig,
private eventBus: IEventBus
) {
this.setupListeners();
}
/**
* Setup listeners for drag-drop and update events
*/
private setupListeners(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, (e) => {
const payload = (e as CustomEvent<IDragColumnChangePayload>).detail;
this.handleColumnChange(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE, (e) => {
const payload = (e as CustomEvent<IDragMovePayload>).detail;
this.updateDragTimestamp(payload);
});
this.eventBus.on(CoreEvents.EVENT_UPDATED, (e) => {
const payload = (e as CustomEvent<IEventUpdatedPayload>).detail;
this.handleEventUpdated(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).detail;
this.handleDragLeaveHeader(payload);
});
}
/**
* Handle EVENT_DRAG_END - remove element if dropped in header
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Event was dropped in header drawer - remove from grid
const element = this.container?.querySelector(`swp-content-viewport swp-event[data-event-id="${payload.swpEvent.eventId}"]`);
element?.remove();
}
}
/**
* Handle header item leaving header - create swp-event in grid
*/
private handleDragLeaveHeader(payload: IDragLeaveHeaderPayload): void {
// Only handle when source is header (header item dragged to grid)
if (payload.source !== 'header') return;
if (!payload.targetColumn || !payload.start || !payload.end) return;
// Turn header item into ghost (stays visible but faded)
if (payload.element) {
payload.element.classList.add('drag-ghost');
payload.element.style.opacity = '0.3';
payload.element.style.pointerEvents = 'none';
}
// Create event object from header item data
const event: ICalendarEvent = {
id: payload.eventId,
title: payload.title || '',
description: '',
start: payload.start,
end: payload.end,
type: 'customer',
allDay: false,
syncStatus: 'pending'
};
// Create swp-event element using existing method
const element = this.createEventElement(event);
// Add to target column
let eventsLayer = payload.targetColumn.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
payload.targetColumn.appendChild(eventsLayer);
}
eventsLayer.appendChild(element);
// Mark as dragging so DragDropManager can continue with it
element.classList.add('dragging');
}
/**
* Handle EVENT_UPDATED - re-render affected columns
*/
private async handleEventUpdated(payload: IEventUpdatedPayload): Promise<void> {
// Re-render source column (if different from target)
if (payload.sourceColumnKey !== payload.targetColumnKey) {
await this.rerenderColumn(payload.sourceColumnKey);
}
// Re-render target column
await this.rerenderColumn(payload.targetColumnKey);
}
/**
* Re-render a single column with fresh data from IndexedDB
*/
private async rerenderColumn(columnKey: string): Promise<void> {
const column = this.findColumn(columnKey);
if (!column) return;
// Read date and resourceId directly from column attributes (columnKey is opaque)
const date = column.dataset.date;
const resourceId = column.dataset.resourceId;
if (!date) return;
// Get date range for this day
const startDate = new Date(date);
const endDate = new Date(date);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = resourceId
? await this.eventService.getByResourceAndDateRange(resourceId, startDate, endDate)
: await this.eventService.getByDateRange(startDate, endDate);
// Filter to timed events and match date exactly
const timedEvents = events.filter(event =>
!event.allDay && this.dateService.getDateKey(event.start) === date
);
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
}
/**
* Find a column element by columnKey
*/
private findColumn(columnKey: string): HTMLElement | null {
if (!this.container) return null;
return this.container.querySelector(`swp-day-column[data-column-key="${columnKey}"]`) as HTMLElement;
}
/**
* Handle event moving to a new column during drag
*/
private handleColumnChange(payload: IDragColumnChangePayload): void {
const eventsLayer = payload.newColumn.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Move element to new column
eventsLayer.appendChild(payload.element);
// Preserve Y position
payload.element.style.top = `${payload.currentY}px`;
}
/**
* Update timestamp display during drag (snapped to grid)
*/
private updateDragTimestamp(payload: IDragMovePayload): void {
const timeEl = payload.element.querySelector('swp-event-time');
if (!timeEl) return;
// Snap position to grid interval
const snappedY = snapToGrid(payload.currentY, this.gridConfig);
// Calculate new start time
const minutesFromGridStart = pixelsToMinutes(snappedY, this.gridConfig);
const startMinutes = (this.gridConfig.dayStartHour * 60) + minutesFromGridStart;
// Keep original duration (from element height)
const height = parseFloat(payload.element.style.height) || this.gridConfig.hourHeight;
const durationMinutes = pixelsToMinutes(height, this.gridConfig);
// Create Date objects for consistent formatting via DateService
const start = this.minutesToDate(startMinutes);
const end = this.minutesToDate(startMinutes + durationMinutes);
timeEl.textContent = this.dateService.formatTimeRange(start, end);
}
/**
* Convert minutes since midnight to a Date object (today)
*/
private minutesToDate(minutes: number): Date {
const date = new Date();
date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0);
return date;
}
/**
* Render events for visible dates into day columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and optionally 'resource' arrays
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store container reference for later re-renders
this.container = container;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get date range for query
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
// Fetch events from IndexedDB
const events = await this.eventService.getByDateRange(startDate, endDate);
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
// Render events into each column based on FilterTemplate matching
columns.forEach(column => {
const columnEl = column as HTMLElement;
// Use FilterTemplate for matching - only fields in template are checked
const columnEvents = events.filter(event => filterTemplate.matches(event, columnEl));
// Get or create events layer
let eventsLayer = column.querySelector('swp-events-layer');
if (!eventsLayer) {
eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
}
// Clear existing events
eventsLayer.innerHTML = '';
// Filter to timed events only
const timedEvents = columnEvents.filter(event => !event.allDay);
// Calculate layout with stacking/grouping
const layout = calculateColumnLayout(timedEvents, this.gridConfig);
// Render GRID groups (simultaneous events side-by-side)
layout.grids.forEach(grid => {
const groupEl = this.renderGridGroup(grid);
eventsLayer!.appendChild(groupEl);
});
// Render STACKED events (overlapping with margin offset)
layout.stacked.forEach(item => {
const eventEl = this.renderStackedEvent(item.event, item.stackLevel);
eventsLayer!.appendChild(eventEl);
});
});
}
/**
* Create a single event element
*
* CLEAN approach:
* - Only data-id for lookup
* - Visible content in innerHTML only
*/
private createEventElement(event: ICalendarEvent): HTMLElement {
const element = document.createElement('swp-event');
// Data attributes for SwpEvent compatibility
element.dataset.eventId = event.id;
if (event.resourceId) {
element.dataset.resourceId = event.resourceId;
}
// Calculate position
const position = calculateEventPosition(event.start, event.end, this.gridConfig);
element.style.top = `${position.top}px`;
element.style.height = `${position.height}px`;
// Color class based on event type
const colorClass = this.getColorClass(event);
if (colorClass) {
element.classList.add(colorClass);
}
// Visible content only
element.innerHTML = `
<swp-event-time>${this.dateService.formatTimeRange(event.start, event.end)}</swp-event-time>
<swp-event-title>${this.escapeHtml(event.title)}</swp-event-title>
${event.description ? `<swp-event-description>${this.escapeHtml(event.description)}</swp-event-description>` : ''}
`;
return element;
}
/**
* Get color class based on metadata.color or event type
*/
private getColorClass(event: ICalendarEvent): string {
// Check metadata.color first
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
// Fallback to type-based color
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Render a GRID group with side-by-side columns
* Used when multiple events start at the same time
*/
private renderGridGroup(layout: IGridGroupLayout): HTMLElement {
const group = document.createElement('swp-event-group');
group.classList.add(`cols-${layout.columns.length}`);
group.style.top = `${layout.position.top}px`;
// Stack level styling for entire group (if nested in another event)
if (layout.stackLevel > 0) {
group.style.marginLeft = `${layout.stackLevel * 15}px`;
group.style.zIndex = `${100 + layout.stackLevel}`;
}
// Calculate the height needed for the group (tallest event)
let maxBottom = 0;
for (const event of layout.events) {
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
const eventBottom = pos.top + pos.height;
if (eventBottom > maxBottom) maxBottom = eventBottom;
}
const groupHeight = maxBottom - layout.position.top;
group.style.height = `${groupHeight}px`;
// Create wrapper div for each column
layout.columns.forEach(columnEvents => {
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
columnEvents.forEach(event => {
const eventEl = this.createEventElement(event);
// Position relative to group top
const pos = calculateEventPosition(event.start, event.end, this.gridConfig);
eventEl.style.top = `${pos.top - layout.position.top}px`;
eventEl.style.position = 'absolute';
eventEl.style.left = '0';
eventEl.style.right = '0';
wrapper.appendChild(eventEl);
});
group.appendChild(wrapper);
});
return group;
}
/**
* Render a STACKED event with margin-left offset
* Used for overlapping events that don't start at the same time
*/
private renderStackedEvent(event: ICalendarEvent, stackLevel: number): HTMLElement {
const element = this.createEventElement(event);
// Add stack metadata for drag-drop and other features
element.dataset.stackLink = JSON.stringify({ stackLevel });
// Visual styling based on stack level
if (stackLevel > 0) {
element.style.marginLeft = `${stackLevel * 15}px`;
element.style.zIndex = `${100 + stackLevel}`;
}
return element;
}
}

View file

@ -0,0 +1 @@
export { EventRenderer } from './EventRenderer';

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,419 @@
import { IEventBus, ICalendarEvent } from '../../types/CalendarTypes';
import { IGridConfig } from '../../core/IGridConfig';
import { CoreEvents } from '../../constants/CoreEvents';
import { HeaderDrawerManager } from '../../core/HeaderDrawerManager';
import { EventService } from '../../storage/events/EventService';
import { DateService } from '../../core/DateService';
import { FilterTemplate } from '../../core/FilterTemplate';
import {
IDragEnterHeaderPayload,
IDragMoveHeaderPayload,
IDragLeaveHeaderPayload,
IDragEndPayload
} from '../../types/DragTypes';
/**
* Layout information for a header item
*/
interface IHeaderItemLayout {
event: ICalendarEvent;
columnKey: string; // Opaque column identifier
row: number; // 1-indexed
colStart: number; // 1-indexed
colEnd: number; // exclusive
}
/**
* 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;
private wasExpandedBeforeDrag = false;
private filterTemplate: FilterTemplate | null = null;
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig,
private headerDrawerManager: HeaderDrawerManager,
private eventService: EventService,
private dateService: DateService
) {
this.setupListeners();
}
/**
* Render allDay events into the header drawer with row stacking
* @param filterTemplate - Template for matching events to columns
*/
async render(container: HTMLElement, filter: Record<string, string[]>, filterTemplate: FilterTemplate): Promise<void> {
// Store filterTemplate for buildColumnKeyFromEvent
this.filterTemplate = filterTemplate;
const drawer = container.querySelector('swp-header-drawer');
if (!drawer) return;
const visibleDates = filter['date'] || [];
if (visibleDates.length === 0) return;
// Get column keys from DOM for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Fetch events for date range
const startDate = new Date(visibleDates[0]);
const endDate = new Date(visibleDates[visibleDates.length - 1]);
endDate.setHours(23, 59, 59, 999);
const events = await this.eventService.getByDateRange(startDate, endDate);
// Filter to allDay events only (allDay !== false)
const allDayEvents = events.filter(event => event.allDay !== false);
// Clear existing items
drawer.innerHTML = '';
if (allDayEvents.length === 0) return;
// Calculate layout with row stacking using columnKeys
const layouts = this.calculateLayout(allDayEvents, visibleColumnKeys);
const rowCount = Math.max(1, ...layouts.map(l => l.row));
// Render each item with layout
layouts.forEach(layout => {
const item = this.createHeaderItem(layout);
drawer.appendChild(item);
});
// Expand drawer to fit all rows
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Create a header item element from layout
*/
private createHeaderItem(layout: IHeaderItemLayout): HTMLElement {
const { event, columnKey, row, colStart, colEnd } = layout;
const item = document.createElement('swp-header-item');
item.dataset.eventId = event.id;
item.dataset.itemType = 'event';
item.dataset.start = event.start.toISOString();
item.dataset.end = event.end.toISOString();
item.dataset.columnKey = columnKey;
item.textContent = event.title;
// Color class
const colorClass = this.getColorClass(event);
if (colorClass) item.classList.add(colorClass);
// Grid position from layout
item.style.gridArea = `${row} / ${colStart} / ${row + 1} / ${colEnd}`;
return item;
}
/**
* Calculate layout for all events with row stacking
* Uses track-based algorithm to find available rows for overlapping events
*/
private calculateLayout(events: ICalendarEvent[], visibleColumnKeys: string[]): IHeaderItemLayout[] {
// tracks[row][col] = occupied
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
const layouts: IHeaderItemLayout[] = [];
for (const event of events) {
// Build columnKey from event fields (only place we need to construct it)
const columnKey = this.buildColumnKeyFromEvent(event);
const startCol = visibleColumnKeys.indexOf(columnKey);
const endColumnKey = this.buildColumnKeyFromEvent(event, event.end);
const endCol = visibleColumnKeys.indexOf(endColumnKey);
if (startCol === -1 && endCol === -1) continue;
// Clamp til synlige kolonner
const colStart = Math.max(0, startCol);
const colEnd = (endCol !== -1 ? endCol : visibleColumnKeys.length - 1) + 1;
// Find ledig række
const row = this.findAvailableRow(tracks, colStart, colEnd);
// Marker som optaget
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
layouts.push({ event, columnKey, row: row + 1, colStart: colStart + 1, colEnd: colEnd + 1 });
}
return layouts;
}
/**
* Build columnKey from event using FilterTemplate
* Uses the same template that columns use for matching
*/
private buildColumnKeyFromEvent(event: ICalendarEvent, date?: Date): string {
if (!this.filterTemplate) {
// Fallback if no template - shouldn't happen in normal flow
const dateStr = this.dateService.getDateKey(date || event.start);
return dateStr;
}
// For multi-day events, we need to override the date in the event
if (date && date.getTime() !== event.start.getTime()) {
// Create temporary event with overridden start for key generation
const tempEvent = { ...event, start: date };
return this.filterTemplate.buildKeyFromEvent(tempEvent);
}
return this.filterTemplate.buildKeyFromEvent(event);
}
/**
* Find available row for event spanning columns [colStart, colEnd)
*/
private findAvailableRow(tracks: boolean[][], colStart: number, colEnd: number): number {
for (let row = 0; row < tracks.length; row++) {
let available = true;
for (let c = colStart; c < colEnd; c++) {
if (tracks[row][c]) { available = false; break; }
}
if (available) return row;
}
// Ny række
tracks.push(new Array(tracks[0].length).fill(false));
return tracks.length - 1;
}
/**
* Get color class based on event metadata or type
*/
private getColorClass(event: ICalendarEvent): string {
if (event.metadata?.color) {
return `is-${event.metadata.color}`;
}
const typeColors: Record<string, string> = {
'customer': 'is-blue',
'vacation': 'is-green',
'break': 'is-amber',
'meeting': 'is-purple',
'blocked': 'is-red'
};
return typeColors[event.type] || 'is-blue';
}
/**
* 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, (e) => {
const payload = (e as CustomEvent<IDragEndPayload>).detail;
this.handleDragEnd(payload);
});
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;
// Remember if drawer was already expanded
this.wasExpandedBeforeDrag = this.headerDrawerManager.isExpanded();
// Expand to at least 1 row if collapsed, otherwise keep current height
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.expandToRows(1);
}
// Store reference to source element
this.sourceElement = payload.element;
// Create header item
const item = document.createElement('swp-header-item');
item.dataset.eventId = payload.eventId;
item.dataset.itemType = payload.itemType;
item.dataset.duration = String(payload.duration);
item.dataset.columnKey = payload.sourceColumnKey;
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
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}`;
// Update columnKey to new position
this.currentItem.dataset.columnKey = payload.columnKey;
}
/**
* Handle drag leaving header - cleanup for gridheader drag only
*/
private handleDragLeave(payload: IDragLeaveHeaderPayload): void {
// Only cleanup for grid→header drag (when grid event leaves header back to grid)
// For header→grid drag, the header item stays as ghost until drop
if (payload.source === 'grid') {
this.cleanup();
}
// For header source, do nothing - ghost stays until EVENT_DRAG_END
}
/**
* Handle drag end - finalize based on drop target
*/
private handleDragEnd(payload: IDragEndPayload): void {
if (payload.target === 'header') {
// Grid→Header: Finalize the header item (it stays in header)
if (this.currentItem) {
this.currentItem.classList.remove('dragging');
this.recalculateDrawerLayout();
this.currentItem = null;
this.sourceElement = null;
}
} else {
// Header→Grid: Remove ghost header item and recalculate
const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`);
ghost?.remove();
this.recalculateDrawerLayout();
}
}
/**
* Recalculate layout for all items currently in the drawer
* Called after drop to reposition items and adjust height
*/
private recalculateDrawerLayout(): void {
const drawer = document.querySelector('swp-header-drawer');
if (!drawer) return;
const items = Array.from(drawer.querySelectorAll('swp-header-item')) as HTMLElement[];
if (items.length === 0) return;
// Get visible column keys for correct multi-resource positioning
const visibleColumnKeys = this.getVisibleColumnKeysFromDOM();
if (visibleColumnKeys.length === 0) return;
// Build layout data from DOM items - use columnKey directly (opaque matching)
const itemData = items.map(item => ({
element: item,
columnKey: item.dataset.columnKey || '',
duration: parseInt(item.dataset.duration || '1', 10)
}));
// Calculate new layout using track algorithm
const tracks: boolean[][] = [new Array(visibleColumnKeys.length).fill(false)];
for (const item of itemData) {
// Direct columnKey matching - no parsing or construction needed
const startCol = visibleColumnKeys.indexOf(item.columnKey);
if (startCol === -1) continue;
const colStart = startCol;
const colEnd = Math.min(startCol + item.duration, visibleColumnKeys.length);
const row = this.findAvailableRow(tracks, colStart, colEnd);
for (let c = colStart; c < colEnd; c++) {
tracks[row][c] = true;
}
// Update element position
item.element.style.gridArea = `${row + 1} / ${colStart + 1} / ${row + 2} / ${colEnd + 1}`;
}
// Update drawer height
const rowCount = tracks.length;
this.headerDrawerManager.expandToRows(rowCount);
}
/**
* Get visible column keys from DOM (preserves order for multi-resource views)
* Uses filterTemplate.buildKeyFromColumn() for consistent key format with events
*/
private getVisibleColumnKeysFromDOM(): string[] {
if (!this.filterTemplate) return [];
const columns = document.querySelectorAll('swp-day-column');
const columnKeys: string[] = [];
columns.forEach(col => {
const columnKey = this.filterTemplate!.buildKeyFromColumn(col as HTMLElement);
if (columnKey) columnKeys.push(columnKey);
});
return columnKeys;
}
/**
* 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;
}
// Collapse drawer if it wasn't expanded before drag
if (!this.wasExpandedBeforeDrag) {
this.headerDrawerManager.collapse();
}
}
}

View file

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

View file

@ -0,0 +1,69 @@
import { IRenderContext } from '../../core/IGroupingRenderer';
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { ResourceService } from '../../storage/resources/ResourceService';
import { IResource } from '../../types/CalendarTypes';
export class ResourceRenderer extends BaseGroupingRenderer<IResource> {
readonly type = 'resource';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-resource-header',
idAttribute: 'resourceId',
colspanVar: '--resource-cols'
};
constructor(private resourceService: ResourceService) {
super();
}
protected getEntities(ids: string[]): Promise<IResource[]> {
return this.resourceService.getByIds(ids);
}
protected getDisplayName(entity: IResource): string {
return entity.displayName;
}
/**
* Override render to handle:
* 1. Special ordering when parentChildMap exists (resources grouped by parent)
* 2. Different colspan calculation (just dateCount, not childCount * dateCount)
*/
async render(context: IRenderContext): Promise<void> {
const resourceIds = context.filter['resource'] || [];
const dateCount = context.filter['date']?.length || 1;
// Determine render order based on parentChildMap
// If parentChildMap exists, render resources grouped by parent (e.g., team)
// Otherwise, render in filter order
let orderedResourceIds: string[];
if (context.parentChildMap) {
// Render resources in parent-child order
orderedResourceIds = [];
for (const childIds of Object.values(context.parentChildMap)) {
for (const childId of childIds) {
if (resourceIds.includes(childId)) {
orderedResourceIds.push(childId);
}
}
}
} else {
orderedResourceIds = resourceIds;
}
const resources = await this.getEntities(orderedResourceIds);
// Create a map for quick lookup to preserve order
const resourceMap = new Map(resources.map(r => [r.id, r]));
for (const resourceId of orderedResourceIds) {
const resource = resourceMap.get(resourceId);
if (!resource) continue;
const header = this.createHeader(resource, context);
header.style.gridColumn = `span ${dateCount}`;
context.headerContainer.appendChild(header);
}
}
}

View file

@ -0,0 +1 @@
export { ResourceRenderer } from './ResourceRenderer';

View file

@ -0,0 +1,106 @@
import { ResourceScheduleService } from '../../storage/schedules/ResourceScheduleService';
import { DateService } from '../../core/DateService';
import { IGridConfig } from '../../core/IGridConfig';
import { ITimeSlot } from '../../types/ScheduleTypes';
/**
* ScheduleRenderer - Renders unavailable time zones in day columns
*
* Creates visual indicators for times outside the resource's working hours:
* - Before work start (e.g., 06:00 - 09:00)
* - After work end (e.g., 17:00 - 18:00)
* - Full day if resource is off (schedule = null)
*/
export class ScheduleRenderer {
constructor(
private scheduleService: ResourceScheduleService,
private dateService: DateService,
private gridConfig: IGridConfig
) {}
/**
* Render unavailable zones for visible columns
* @param container - Calendar container element
* @param filter - Filter with 'date' and 'resource' arrays
*/
async render(container: HTMLElement, filter: Record<string, string[]>): Promise<void> {
const dates = filter['date'] || [];
const resourceIds = filter['resource'] || [];
if (dates.length === 0) return;
// Find day columns
const dayColumns = container.querySelector('swp-day-columns');
if (!dayColumns) return;
const columns = dayColumns.querySelectorAll('swp-day-column');
for (const column of columns) {
const date = (column as HTMLElement).dataset.date;
const resourceId = (column as HTMLElement).dataset.resourceId;
if (!date || !resourceId) continue;
// Get or create unavailable layer
let unavailableLayer = column.querySelector('swp-unavailable-layer');
if (!unavailableLayer) {
unavailableLayer = document.createElement('swp-unavailable-layer');
column.insertBefore(unavailableLayer, column.firstChild);
}
// Clear existing
unavailableLayer.innerHTML = '';
// Get schedule for this resource/date
const schedule = await this.scheduleService.getScheduleForDate(resourceId, date);
// Render unavailable zones
this.renderUnavailableZones(unavailableLayer as HTMLElement, schedule);
}
}
/**
* Render unavailable time zones based on schedule
*/
private renderUnavailableZones(layer: HTMLElement, schedule: ITimeSlot | null): void {
const dayStartMinutes = this.gridConfig.dayStartHour * 60;
const dayEndMinutes = this.gridConfig.dayEndHour * 60;
const minuteHeight = this.gridConfig.hourHeight / 60;
if (schedule === null) {
// Full day unavailable
const zone = this.createUnavailableZone(0, (dayEndMinutes - dayStartMinutes) * minuteHeight);
layer.appendChild(zone);
return;
}
const workStartMinutes = this.dateService.timeToMinutes(schedule.start);
const workEndMinutes = this.dateService.timeToMinutes(schedule.end);
// Before work start
if (workStartMinutes > dayStartMinutes) {
const top = 0;
const height = (workStartMinutes - dayStartMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
// After work end
if (workEndMinutes < dayEndMinutes) {
const top = (workEndMinutes - dayStartMinutes) * minuteHeight;
const height = (dayEndMinutes - workEndMinutes) * minuteHeight;
const zone = this.createUnavailableZone(top, height);
layer.appendChild(zone);
}
}
/**
* Create an unavailable zone element
*/
private createUnavailableZone(top: number, height: number): HTMLElement {
const zone = document.createElement('swp-unavailable-zone');
zone.style.top = `${top}px`;
zone.style.height = `${height}px`;
return zone;
}
}

View file

@ -0,0 +1 @@
export { ScheduleRenderer } from './ScheduleRenderer';

View file

@ -0,0 +1,25 @@
import { BaseGroupingRenderer, IGroupingRendererConfig } from '../../core/BaseGroupingRenderer';
import { TeamService } from '../../storage/teams/TeamService';
import { ITeam } from '../../types/CalendarTypes';
export class TeamRenderer extends BaseGroupingRenderer<ITeam> {
readonly type = 'team';
protected readonly config: IGroupingRendererConfig = {
elementTag: 'swp-team-header',
idAttribute: 'teamId',
colspanVar: '--team-cols'
};
constructor(private teamService: TeamService) {
super();
}
protected getEntities(ids: string[]): Promise<ITeam[]> {
return this.teamService.getByIds(ids);
}
protected getDisplayName(entity: ITeam): string {
return entity.name;
}
}

View file

@ -0,0 +1 @@
export { TeamRenderer } from './TeamRenderer';

View file

@ -0,0 +1,10 @@
export class TimeAxisRenderer {
render(container: HTMLElement, startHour = 6, endHour = 20): void {
container.innerHTML = '';
for (let hour = startHour; hour <= endHour; hour++) {
const marker = document.createElement('swp-hour-marker');
marker.textContent = `${hour.toString().padStart(2, '0')}:00`;
container.appendChild(marker);
}
}
}