Initial commit: Calendar Plantempus project setup with TypeScript, ASP.NET Core, and event-driven architecture
This commit is contained in:
commit
f06c02121c
38 changed files with 8233 additions and 0 deletions
256
src/managers/CalendarManager.ts
Normal file
256
src/managers/CalendarManager.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { EventBus } from '../core/EventBus.js';
|
||||
import { EventTypes } from '../constants/EventTypes.js';
|
||||
import { CalendarConfig } from '../core/CalendarConfig.js';
|
||||
import { CalendarEvent, CalendarView, IEventBus } from '../types/CalendarTypes.js';
|
||||
|
||||
/**
|
||||
* CalendarManager - Hovedkoordinator for alle calendar managers
|
||||
* Håndterer initialisering, koordinering og kommunikation mellem alle managers
|
||||
*/
|
||||
export class CalendarManager {
|
||||
private eventBus: IEventBus;
|
||||
private config: CalendarConfig;
|
||||
private currentView: CalendarView = 'week';
|
||||
private currentDate: Date = new Date();
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
constructor(eventBus: IEventBus, config: CalendarConfig) {
|
||||
this.eventBus = eventBus;
|
||||
this.config = config;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiser calendar systemet
|
||||
*/
|
||||
public initialize(): void {
|
||||
if (this.isInitialized) {
|
||||
console.warn('CalendarManager is already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing CalendarManager...');
|
||||
|
||||
// Emit initialization event
|
||||
this.eventBus.emit(EventTypes.CALENDAR_INITIALIZING, {
|
||||
view: this.currentView,
|
||||
date: this.currentDate,
|
||||
config: this.config
|
||||
});
|
||||
|
||||
// Set initial view and date
|
||||
this.setView(this.currentView);
|
||||
this.setCurrentDate(this.currentDate);
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
// Emit initialization complete event
|
||||
this.eventBus.emit(EventTypes.CALENDAR_INITIALIZED, {
|
||||
view: this.currentView,
|
||||
date: this.currentDate
|
||||
});
|
||||
|
||||
console.log('CalendarManager initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift calendar view (dag/uge/måned)
|
||||
*/
|
||||
public setView(view: CalendarView): void {
|
||||
if (this.currentView === view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousView = this.currentView;
|
||||
this.currentView = view;
|
||||
|
||||
console.log(`Changing view from ${previousView} to ${view}`);
|
||||
|
||||
// Emit view change event
|
||||
this.eventBus.emit(EventTypes.VIEW_CHANGED, {
|
||||
previousView,
|
||||
currentView: view,
|
||||
date: this.currentDate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sæt aktuel dato
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
const previousDate = this.currentDate;
|
||||
this.currentDate = new Date(date);
|
||||
|
||||
console.log(`Changing date from ${previousDate.toISOString()} to ${date.toISOString()}`);
|
||||
|
||||
// Emit date change event
|
||||
this.eventBus.emit(EventTypes.DATE_CHANGED, {
|
||||
previousDate,
|
||||
currentDate: this.currentDate,
|
||||
view: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Naviger til i dag
|
||||
*/
|
||||
public goToToday(): void {
|
||||
this.setCurrentDate(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Naviger til næste periode (dag/uge/måned afhængig af view)
|
||||
*/
|
||||
public goToNext(): void {
|
||||
const nextDate = this.calculateNextDate();
|
||||
this.setCurrentDate(nextDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Naviger til forrige periode (dag/uge/måned afhængig af view)
|
||||
*/
|
||||
public goToPrevious(): void {
|
||||
const previousDate = this.calculatePreviousDate();
|
||||
this.setCurrentDate(previousDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hent aktuel view
|
||||
*/
|
||||
public getCurrentView(): CalendarView {
|
||||
return this.currentView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hent aktuel dato
|
||||
*/
|
||||
public getCurrentDate(): Date {
|
||||
return new Date(this.currentDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hent calendar konfiguration
|
||||
*/
|
||||
public getConfig(): CalendarConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check om calendar er initialiseret
|
||||
*/
|
||||
public isCalendarInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genindlæs calendar data
|
||||
*/
|
||||
public refresh(): void {
|
||||
console.log('Refreshing calendar...');
|
||||
|
||||
this.eventBus.emit(EventTypes.CALENDAR_REFRESH_REQUESTED, {
|
||||
view: this.currentView,
|
||||
date: this.currentDate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ryd calendar og nulstil til standard tilstand
|
||||
*/
|
||||
public reset(): void {
|
||||
console.log('Resetting calendar...');
|
||||
|
||||
this.currentView = 'week';
|
||||
this.currentDate = new Date();
|
||||
|
||||
this.eventBus.emit(EventTypes.CALENDAR_RESET, {
|
||||
view: this.currentView,
|
||||
date: this.currentDate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for at håndtere events fra andre managers
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Lyt efter navigation events
|
||||
this.eventBus.on(EventTypes.NAVIGATE_TO_DATE, (event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { date } = customEvent.detail;
|
||||
this.setCurrentDate(new Date(date));
|
||||
});
|
||||
|
||||
// Lyt efter view change requests
|
||||
this.eventBus.on(EventTypes.VIEW_CHANGE_REQUESTED, (event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { view } = customEvent.detail;
|
||||
this.setView(view);
|
||||
});
|
||||
|
||||
// Lyt efter today navigation
|
||||
this.eventBus.on(EventTypes.NAVIGATE_TO_TODAY, () => {
|
||||
this.goToToday();
|
||||
});
|
||||
|
||||
// Lyt efter next/previous navigation
|
||||
this.eventBus.on(EventTypes.NAVIGATE_NEXT, () => {
|
||||
this.goToNext();
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.NAVIGATE_PREVIOUS, () => {
|
||||
this.goToPrevious();
|
||||
});
|
||||
|
||||
// Lyt efter refresh requests
|
||||
this.eventBus.on(EventTypes.REFRESH_REQUESTED, () => {
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
// Lyt efter reset requests
|
||||
this.eventBus.on(EventTypes.RESET_REQUESTED, () => {
|
||||
this.reset();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Beregn næste dato baseret på aktuel view
|
||||
*/
|
||||
private calculateNextDate(): Date {
|
||||
const nextDate = new Date(this.currentDate);
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'day':
|
||||
nextDate.setDate(nextDate.getDate() + 1);
|
||||
break;
|
||||
case 'week':
|
||||
nextDate.setDate(nextDate.getDate() + 7);
|
||||
break;
|
||||
case 'month':
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beregn forrige dato baseret på aktuel view
|
||||
*/
|
||||
private calculatePreviousDate(): Date {
|
||||
const previousDate = new Date(this.currentDate);
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'day':
|
||||
previousDate.setDate(previousDate.getDate() - 1);
|
||||
break;
|
||||
case 'week':
|
||||
previousDate.setDate(previousDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
previousDate.setMonth(previousDate.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return previousDate;
|
||||
}
|
||||
}
|
||||
414
src/managers/DataManager.ts
Normal file
414
src/managers/DataManager.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// Data management and API communication
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
import { CalendarEvent, EventData, Period, EventType } from '../types/CalendarTypes';
|
||||
|
||||
/**
|
||||
* Event creation data interface
|
||||
*/
|
||||
interface EventCreateData {
|
||||
title: string;
|
||||
type: EventType;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event update data interface
|
||||
*/
|
||||
interface EventUpdateData {
|
||||
eventId: string;
|
||||
changes: Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages data fetching and API communication
|
||||
* Currently uses mock data until backend is implemented
|
||||
*/
|
||||
export class DataManager {
|
||||
private baseUrl: string = '/api/events';
|
||||
private useMockData: boolean = true; // Toggle this when backend is ready
|
||||
private cache: Map<string, EventData> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for period changes to fetch new data
|
||||
eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => {
|
||||
this.fetchEventsForPeriod((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
// Listen for event updates
|
||||
eventBus.on(EventTypes.EVENT_UPDATE, (e: Event) => {
|
||||
this.updateEvent((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
// Listen for event creation
|
||||
eventBus.on(EventTypes.EVENT_CREATE, (e: Event) => {
|
||||
this.createEvent((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
// Listen for event deletion
|
||||
eventBus.on(EventTypes.EVENT_DELETE, (e: Event) => {
|
||||
this.deleteEvent((e as CustomEvent).detail.eventId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events for a specific period
|
||||
*/
|
||||
async fetchEventsForPeriod(period: Period): Promise<EventData> {
|
||||
const cacheKey = `${period.start}-${period.end}-${period.view}`;
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
const cachedData = this.cache.get(cacheKey)!;
|
||||
eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, cachedData);
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// Emit loading start
|
||||
eventBus.emit(EventTypes.DATA_FETCH_START, { period });
|
||||
|
||||
try {
|
||||
let data: EventData;
|
||||
|
||||
if (this.useMockData) {
|
||||
// Simulate network delay
|
||||
await this.delay(300);
|
||||
data = this.getMockData(period);
|
||||
} else {
|
||||
// Real API call
|
||||
const params = new URLSearchParams({
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
view: period.view
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch events');
|
||||
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
// Cache the data
|
||||
this.cache.set(cacheKey, data);
|
||||
|
||||
// Emit success
|
||||
eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
eventBus.emit(EventTypes.DATA_FETCH_ERROR, { error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
*/
|
||||
async createEvent(eventData: EventCreateData): Promise<CalendarEvent> {
|
||||
eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'create' });
|
||||
|
||||
try {
|
||||
if (this.useMockData) {
|
||||
await this.delay(200);
|
||||
const newEvent: CalendarEvent = {
|
||||
id: `evt-${Date.now()}`,
|
||||
title: eventData.title,
|
||||
start: eventData.start,
|
||||
end: eventData.end,
|
||||
type: eventData.type,
|
||||
allDay: eventData.allDay,
|
||||
syncStatus: 'synced',
|
||||
metadata: eventData.description ? { description: eventData.description } : undefined
|
||||
};
|
||||
|
||||
// Clear cache to force refresh
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'create',
|
||||
event: newEvent
|
||||
});
|
||||
|
||||
return newEvent;
|
||||
} else {
|
||||
// Real API call
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(eventData)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create event');
|
||||
|
||||
const newEvent = await response.json();
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'create',
|
||||
event: newEvent
|
||||
});
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
eventBus.emit(EventTypes.DATA_SYNC_ERROR, {
|
||||
action: 'create',
|
||||
error: errorMessage
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
*/
|
||||
async updateEvent(updateData: EventUpdateData): Promise<boolean> {
|
||||
eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'update' });
|
||||
|
||||
try {
|
||||
if (this.useMockData) {
|
||||
await this.delay(200);
|
||||
|
||||
// Clear cache to force refresh
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'update',
|
||||
eventId: updateData.eventId,
|
||||
changes: updateData.changes
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// Real API call
|
||||
const response = await fetch(`${this.baseUrl}/${updateData.eventId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData.changes)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update event');
|
||||
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'update',
|
||||
eventId: updateData.eventId
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
eventBus.emit(EventTypes.DATA_SYNC_ERROR, {
|
||||
action: 'update',
|
||||
error: errorMessage,
|
||||
eventId: updateData.eventId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
async deleteEvent(eventId: string): Promise<boolean> {
|
||||
eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'delete' });
|
||||
|
||||
try {
|
||||
if (this.useMockData) {
|
||||
await this.delay(200);
|
||||
|
||||
// Clear cache to force refresh
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'delete',
|
||||
eventId
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// Real API call
|
||||
const response = await fetch(`${this.baseUrl}/${eventId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete event');
|
||||
|
||||
this.cache.clear();
|
||||
|
||||
eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, {
|
||||
action: 'delete',
|
||||
eventId
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
eventBus.emit(EventTypes.DATA_SYNC_ERROR, {
|
||||
action: 'delete',
|
||||
error: errorMessage,
|
||||
eventId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate mock data for testing
|
||||
*/
|
||||
private getMockData(period: Period): EventData {
|
||||
const events: CalendarEvent[] = [];
|
||||
const types: EventType[] = ['meeting', 'meal', 'work', 'milestone'];
|
||||
const titles: Record<EventType, string[]> = {
|
||||
meeting: ['Team Standup', 'Client Meeting', 'Project Review', 'Sprint Planning', 'Design Review'],
|
||||
meal: ['Breakfast', 'Lunch', 'Coffee Break', 'Dinner'],
|
||||
work: ['Deep Work Session', 'Code Review', 'Documentation', 'Testing'],
|
||||
milestone: ['Project Deadline', 'Release Day', 'Demo Day']
|
||||
};
|
||||
|
||||
// Parse dates
|
||||
const startDate = new Date(period.start);
|
||||
const endDate = new Date(period.end);
|
||||
|
||||
// Generate some events for each day
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
// Skip weekends for most events
|
||||
const dayOfWeek = d.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
if (isWeekend) {
|
||||
// Maybe one or two events on weekends
|
||||
if (Math.random() > 0.7) {
|
||||
const type: EventType = 'meal';
|
||||
const title = titles[type][Math.floor(Math.random() * titles[type].length)];
|
||||
const hour = 12 + Math.floor(Math.random() * 4);
|
||||
|
||||
events.push({
|
||||
id: `evt-${events.length + 1}`,
|
||||
title,
|
||||
type,
|
||||
start: `${this.formatDate(d)}T${hour}:00:00`,
|
||||
end: `${this.formatDate(d)}T${hour + 1}:00:00`,
|
||||
allDay: false,
|
||||
syncStatus: 'synced'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular workday events
|
||||
|
||||
// Morning standup
|
||||
if (Math.random() > 0.3) {
|
||||
events.push({
|
||||
id: `evt-${events.length + 1}`,
|
||||
title: 'Team Standup',
|
||||
type: 'meeting',
|
||||
start: `${this.formatDate(d)}T09:00:00`,
|
||||
end: `${this.formatDate(d)}T09:30:00`,
|
||||
allDay: false,
|
||||
syncStatus: 'synced'
|
||||
});
|
||||
}
|
||||
|
||||
// Lunch
|
||||
events.push({
|
||||
id: `evt-${events.length + 1}`,
|
||||
title: 'Lunch',
|
||||
type: 'meal',
|
||||
start: `${this.formatDate(d)}T12:00:00`,
|
||||
end: `${this.formatDate(d)}T13:00:00`,
|
||||
allDay: false,
|
||||
syncStatus: 'synced'
|
||||
});
|
||||
|
||||
// Random afternoon events
|
||||
const numAfternoonEvents = Math.floor(Math.random() * 3) + 1;
|
||||
for (let i = 0; i < numAfternoonEvents; i++) {
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
const title = titles[type][Math.floor(Math.random() * titles[type].length)];
|
||||
const startHour = 13 + Math.floor(Math.random() * 4);
|
||||
const duration = 1 + Math.floor(Math.random() * 2);
|
||||
|
||||
events.push({
|
||||
id: `evt-${events.length + 1}`,
|
||||
title,
|
||||
type,
|
||||
start: `${this.formatDate(d)}T${startHour}:${Math.random() > 0.5 ? '00' : '30'}:00`,
|
||||
end: `${this.formatDate(d)}T${startHour + duration}:00:00`,
|
||||
allDay: false,
|
||||
syncStatus: Math.random() > 0.9 ? 'pending' : 'synced'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a multi-day event
|
||||
if (period.view === 'week') {
|
||||
const midWeek = new Date(startDate);
|
||||
midWeek.setDate(midWeek.getDate() + 2);
|
||||
|
||||
events.push({
|
||||
id: `evt-${events.length + 1}`,
|
||||
title: 'Project Sprint',
|
||||
type: 'milestone',
|
||||
start: `${this.formatDate(startDate)}T00:00:00`,
|
||||
end: `${this.formatDate(midWeek)}T23:59:59`,
|
||||
allDay: true,
|
||||
syncStatus: 'synced'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
meta: {
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
view: period.view,
|
||||
total: events.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility methods
|
||||
*/
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between mock and real data
|
||||
*/
|
||||
setUseMockData(useMock: boolean): void {
|
||||
this.useMockData = useMock;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
227
src/managers/EventManager.ts
Normal file
227
src/managers/EventManager.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { EventBus } from '../core/EventBus';
|
||||
import { IEventBus, CalendarEvent } from '../types/CalendarTypes';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
|
||||
/**
|
||||
* EventManager - Administrerer event lifecycle og CRUD operationer
|
||||
* Håndterer mock data og event synchronization
|
||||
*/
|
||||
export class EventManager {
|
||||
private eventBus: IEventBus;
|
||||
private events: CalendarEvent[] = [];
|
||||
|
||||
constructor(eventBus: IEventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.setupEventListeners();
|
||||
this.loadMockData();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on(EventTypes.CALENDAR_INITIALIZED, () => {
|
||||
this.syncEvents();
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.DATE_CHANGED, () => {
|
||||
this.syncEvents();
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.VIEW_RENDERED, () => {
|
||||
this.syncEvents();
|
||||
});
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock events baseret på POC data med korrekt CalendarEvent struktur
|
||||
this.events = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Team Standup',
|
||||
start: '2024-01-15T09:00:00',
|
||||
end: '2024-01-15T09:30:00',
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 1, duration: 30 }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Client Meeting',
|
||||
start: '2024-01-15T14:00:00',
|
||||
end: '2024-01-15T15:30:00',
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 1, duration: 90 }
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Lunch',
|
||||
start: '2024-01-15T12:00:00',
|
||||
end: '2024-01-15T13:00:00',
|
||||
type: 'meal',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 1, duration: 60 }
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Deep Work Session',
|
||||
start: '2024-01-16T10:00:00',
|
||||
end: '2024-01-16T12:00:00',
|
||||
type: 'work',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 2, duration: 120 }
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Team Standup',
|
||||
start: '2024-01-16T09:00:00',
|
||||
end: '2024-01-16T09:30:00',
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 2, duration: 30 }
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Lunch',
|
||||
start: '2024-01-16T12:30:00',
|
||||
end: '2024-01-16T13:30:00',
|
||||
type: 'meal',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 2, duration: 60 }
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Project Review',
|
||||
start: '2024-01-17T15:00:00',
|
||||
end: '2024-01-17T16:00:00',
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 3, duration: 60 }
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Lunch',
|
||||
start: '2024-01-17T12:00:00',
|
||||
end: '2024-01-17T13:00:00',
|
||||
type: 'meal',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 3, duration: 60 }
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Sprint Planning',
|
||||
start: '2024-01-18T10:00:00',
|
||||
end: '2024-01-18T12:00:00',
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 4, duration: 120 }
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Coffee Break',
|
||||
start: '2024-01-18T15:00:00',
|
||||
end: '2024-01-18T15:30:00',
|
||||
type: 'meal',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 4, duration: 30 }
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'Documentation',
|
||||
start: '2024-01-19T13:00:00',
|
||||
end: '2024-01-19T16:00:00',
|
||||
type: 'work',
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: { day: 5, duration: 180 }
|
||||
}
|
||||
];
|
||||
|
||||
console.log(`EventManager: Loaded ${this.events.length} mock events`);
|
||||
}
|
||||
|
||||
private syncEvents(): void {
|
||||
// Emit events for rendering
|
||||
this.eventBus.emit(EventTypes.EVENTS_LOADED, {
|
||||
events: this.events
|
||||
});
|
||||
|
||||
console.log(`EventManager: Synced ${this.events.length} events`);
|
||||
}
|
||||
|
||||
public getEvents(): CalendarEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
public getEventsByDay(day: number): CalendarEvent[] {
|
||||
return this.events.filter(event => event.metadata?.day === day);
|
||||
}
|
||||
|
||||
public getEventById(id: string): CalendarEvent | undefined {
|
||||
return this.events.find(event => event.id === id);
|
||||
}
|
||||
|
||||
public addEvent(event: Omit<CalendarEvent, 'id'>): CalendarEvent {
|
||||
const newEvent: CalendarEvent = {
|
||||
...event,
|
||||
id: Date.now().toString()
|
||||
};
|
||||
|
||||
this.events.push(newEvent);
|
||||
this.syncEvents();
|
||||
|
||||
this.eventBus.emit(EventTypes.EVENT_CREATED, {
|
||||
event: newEvent
|
||||
});
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
public updateEvent(id: string, updates: Partial<CalendarEvent>): CalendarEvent | null {
|
||||
const eventIndex = this.events.findIndex(event => event.id === id);
|
||||
if (eventIndex === -1) return null;
|
||||
|
||||
const updatedEvent = { ...this.events[eventIndex], ...updates };
|
||||
this.events[eventIndex] = updatedEvent;
|
||||
|
||||
this.syncEvents();
|
||||
|
||||
this.eventBus.emit(EventTypes.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
});
|
||||
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
public deleteEvent(id: string): boolean {
|
||||
const eventIndex = this.events.findIndex(event => event.id === id);
|
||||
if (eventIndex === -1) return false;
|
||||
|
||||
const deletedEvent = this.events[eventIndex];
|
||||
this.events.splice(eventIndex, 1);
|
||||
|
||||
this.syncEvents();
|
||||
|
||||
this.eventBus.emit(EventTypes.EVENT_DELETED, {
|
||||
event: deletedEvent
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.syncEvents();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.events = [];
|
||||
}
|
||||
}
|
||||
177
src/managers/EventRenderer.ts
Normal file
177
src/managers/EventRenderer.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { EventBus } from '../core/EventBus';
|
||||
import { IEventBus, CalendarEvent } from '../types/CalendarTypes';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
|
||||
/**
|
||||
* EventRenderer - Render events i DOM med positionering
|
||||
* Håndterer event positioning og overlap detection
|
||||
*/
|
||||
export class EventRenderer {
|
||||
private eventBus: IEventBus;
|
||||
|
||||
constructor(eventBus: IEventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on(EventTypes.EVENTS_LOADED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { events } = customEvent.detail;
|
||||
this.renderEvents(events);
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.VIEW_RENDERED, () => {
|
||||
// Clear existing events when view changes
|
||||
this.clearEvents();
|
||||
});
|
||||
}
|
||||
|
||||
private renderEvents(events: CalendarEvent[]): void {
|
||||
console.log(`EventRenderer: Rendering ${events.length} events`);
|
||||
|
||||
// Clear existing events first
|
||||
this.clearEvents();
|
||||
|
||||
// Group events by day for better rendering
|
||||
const eventsByDay = this.groupEventsByDay(events);
|
||||
|
||||
// Render events for each day
|
||||
Object.entries(eventsByDay).forEach(([dayIndex, dayEvents]) => {
|
||||
this.renderDayEvents(parseInt(dayIndex), dayEvents);
|
||||
});
|
||||
|
||||
this.eventBus.emit(EventTypes.EVENT_RENDERED, {
|
||||
count: events.length
|
||||
});
|
||||
}
|
||||
|
||||
private groupEventsByDay(events: CalendarEvent[]): Record<number, CalendarEvent[]> {
|
||||
const grouped: Record<number, CalendarEvent[]> = {};
|
||||
|
||||
events.forEach(event => {
|
||||
const day = event.metadata?.day || 0;
|
||||
if (!grouped[day]) {
|
||||
grouped[day] = [];
|
||||
}
|
||||
grouped[day].push(event);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private renderDayEvents(dayIndex: number, events: CalendarEvent[]): void {
|
||||
const dayColumns = document.querySelectorAll('swp-day-column');
|
||||
const dayColumn = dayColumns[dayIndex];
|
||||
if (!dayColumn) {
|
||||
console.warn(`EventRenderer: Day column ${dayIndex} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsLayer = dayColumn.querySelector('swp-events-layer');
|
||||
if (!eventsLayer) {
|
||||
console.warn(`EventRenderer: Events layer not found for day ${dayIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort events by start time
|
||||
const sortedEvents = events.sort((a, b) => a.start.localeCompare(b.start));
|
||||
|
||||
sortedEvents.forEach(event => {
|
||||
this.renderEvent(event, eventsLayer);
|
||||
});
|
||||
}
|
||||
|
||||
private renderEvent(event: CalendarEvent, container: Element): void {
|
||||
const eventElement = document.createElement('swp-event');
|
||||
eventElement.dataset.eventId = event.id;
|
||||
eventElement.dataset.type = event.type;
|
||||
|
||||
// Calculate position based on time
|
||||
const position = this.calculateEventPosition(event);
|
||||
eventElement.style.top = `${position.top}px`;
|
||||
eventElement.style.height = `${position.height}px`;
|
||||
|
||||
// Format time for display
|
||||
const startTime = this.formatTime(event.start);
|
||||
const endTime = this.formatTime(event.end);
|
||||
|
||||
// Create event content
|
||||
eventElement.innerHTML = `
|
||||
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
|
||||
<swp-event-title>${event.title}</swp-event-title>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
this.addEventListeners(eventElement, event);
|
||||
|
||||
container.appendChild(eventElement);
|
||||
}
|
||||
|
||||
private calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
|
||||
const startDate = new Date(event.start);
|
||||
const endDate = new Date(event.end);
|
||||
|
||||
const startHour = calendarConfig.get('dayStartHour');
|
||||
const hourHeight = calendarConfig.get('hourHeight');
|
||||
|
||||
// Calculate minutes from day start
|
||||
const startMinutes = (startDate.getHours() - startHour) * 60 + startDate.getMinutes();
|
||||
const duration = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // Duration in minutes
|
||||
|
||||
// Convert to pixels
|
||||
const top = startMinutes * (hourHeight / 60);
|
||||
const height = duration * (hourHeight / 60);
|
||||
|
||||
return { top, height };
|
||||
}
|
||||
|
||||
private formatTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
const displayMinutes = minutes.toString().padStart(2, '0');
|
||||
|
||||
return `${displayHours}:${displayMinutes} ${period}`;
|
||||
}
|
||||
|
||||
private addEventListeners(eventElement: HTMLElement, event: CalendarEvent): void {
|
||||
// Click handler
|
||||
eventElement.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.eventBus.emit(EventTypes.EVENT_SELECTED, {
|
||||
event,
|
||||
element: eventElement
|
||||
});
|
||||
});
|
||||
|
||||
// Hover effects are handled by CSS
|
||||
eventElement.addEventListener('mouseenter', () => {
|
||||
eventElement.style.zIndex = '20';
|
||||
});
|
||||
|
||||
eventElement.addEventListener('mouseleave', () => {
|
||||
eventElement.style.zIndex = '10';
|
||||
});
|
||||
}
|
||||
|
||||
private clearEvents(): void {
|
||||
const eventsLayers = document.querySelectorAll('swp-events-layer');
|
||||
eventsLayers.forEach(layer => {
|
||||
layer.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
// Request fresh events from EventManager
|
||||
this.eventBus.emit(EventTypes.REFRESH_REQUESTED);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.clearEvents();
|
||||
}
|
||||
}
|
||||
348
src/managers/GridManager.ts
Normal file
348
src/managers/GridManager.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// Grid structure management
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
import { DateUtils } from '../utils/DateUtils';
|
||||
|
||||
/**
|
||||
* Grid position interface
|
||||
*/
|
||||
interface GridPosition {
|
||||
minutes: number;
|
||||
time: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the calendar grid structure
|
||||
*/
|
||||
export class GridManager {
|
||||
private container: HTMLElement | null = null;
|
||||
private timeAxis: HTMLElement | null = null;
|
||||
private weekHeader: HTMLElement | null = null;
|
||||
private timeGrid: HTMLElement | null = null;
|
||||
private dayColumns: HTMLElement | null = null;
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private currentWeek: Date | null = null;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.findElements();
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private findElements(): void {
|
||||
this.container = document.querySelector('swp-calendar-container');
|
||||
this.timeAxis = document.querySelector('swp-time-axis');
|
||||
this.weekHeader = document.querySelector('swp-week-header');
|
||||
this.timeGrid = document.querySelector('swp-time-grid');
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Re-render grid on config changes
|
||||
eventBus.on(EventTypes.CONFIG_UPDATE, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (['dayStartHour', 'dayEndHour', 'hourHeight', 'view', 'weekDays'].includes(detail.key)) {
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-render on view change
|
||||
eventBus.on(EventTypes.VIEW_CHANGE, () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Re-render on period change
|
||||
eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentWeek = detail.week;
|
||||
this.renderHeaders();
|
||||
});
|
||||
|
||||
// Handle grid clicks
|
||||
this.setupGridInteractions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete grid structure
|
||||
*/
|
||||
render(): void {
|
||||
this.renderTimeAxis();
|
||||
this.renderHeaders();
|
||||
this.renderGrid();
|
||||
this.renderGridLines();
|
||||
|
||||
// Emit grid rendered event
|
||||
eventBus.emit(EventTypes.GRID_RENDERED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render time axis (left side hours)
|
||||
*/
|
||||
private renderTimeAxis(): void {
|
||||
if (!this.timeAxis) return;
|
||||
|
||||
const startHour = calendarConfig.get('dayStartHour');
|
||||
const endHour = calendarConfig.get('dayEndHour');
|
||||
|
||||
this.timeAxis.innerHTML = '';
|
||||
|
||||
for (let hour = startHour; hour <= endHour; hour++) {
|
||||
const marker = document.createElement('swp-hour-marker');
|
||||
marker.textContent = this.formatHour(hour);
|
||||
(marker as any).dataset.hour = hour;
|
||||
this.timeAxis.appendChild(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render week headers
|
||||
*/
|
||||
private renderHeaders(): void {
|
||||
if (!this.weekHeader || !this.currentWeek) return;
|
||||
|
||||
const view = calendarConfig.get('view');
|
||||
const weekDays = calendarConfig.get('weekDays');
|
||||
|
||||
this.weekHeader.innerHTML = '';
|
||||
|
||||
if (view === 'week') {
|
||||
const dates = this.getWeekDates(this.currentWeek);
|
||||
const daysToShow = dates.slice(0, weekDays);
|
||||
|
||||
daysToShow.forEach((date, index) => {
|
||||
const header = document.createElement('swp-day-header');
|
||||
header.innerHTML = `
|
||||
<swp-day-name>${this.getDayName(date)}</swp-day-name>
|
||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||
`;
|
||||
(header as any).dataset.date = this.formatDate(date);
|
||||
(header as any).dataset.dayIndex = index;
|
||||
|
||||
// Mark today
|
||||
if (this.isToday(date)) {
|
||||
(header as any).dataset.today = 'true';
|
||||
}
|
||||
|
||||
this.weekHeader!.appendChild(header);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main grid structure
|
||||
*/
|
||||
private renderGrid(): void {
|
||||
if (!this.timeGrid) return;
|
||||
|
||||
// Clear existing columns
|
||||
let dayColumns = this.timeGrid.querySelector('swp-day-columns');
|
||||
if (!dayColumns) {
|
||||
dayColumns = document.createElement('swp-day-columns');
|
||||
this.timeGrid.appendChild(dayColumns);
|
||||
}
|
||||
|
||||
dayColumns.innerHTML = '';
|
||||
|
||||
const view = calendarConfig.get('view');
|
||||
const columnsCount = view === 'week' ? calendarConfig.get('weekDays') : 1;
|
||||
|
||||
// Create columns
|
||||
for (let i = 0; i < columnsCount; i++) {
|
||||
const column = document.createElement('swp-day-column');
|
||||
(column as any).dataset.columnIndex = i;
|
||||
|
||||
if (this.currentWeek) {
|
||||
const dates = this.getWeekDates(this.currentWeek);
|
||||
if (dates[i]) {
|
||||
(column as any).dataset.date = this.formatDate(dates[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add events container
|
||||
const eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
|
||||
dayColumns.appendChild(column);
|
||||
}
|
||||
|
||||
this.dayColumns = dayColumns as HTMLElement;
|
||||
this.updateGridStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render grid lines
|
||||
*/
|
||||
private renderGridLines(): void {
|
||||
if (!this.timeGrid) return;
|
||||
|
||||
let gridLines = this.timeGrid.querySelector('swp-grid-lines');
|
||||
if (!gridLines) {
|
||||
gridLines = document.createElement('swp-grid-lines');
|
||||
this.timeGrid.insertBefore(gridLines, this.timeGrid.firstChild);
|
||||
}
|
||||
|
||||
const totalHours = calendarConfig.totalHours;
|
||||
const hourHeight = calendarConfig.get('hourHeight');
|
||||
|
||||
// Set CSS variables
|
||||
this.timeGrid.style.setProperty('--total-hours', totalHours.toString());
|
||||
this.timeGrid.style.setProperty('--hour-height', `${hourHeight}px`);
|
||||
|
||||
// Grid lines are handled by CSS
|
||||
}
|
||||
|
||||
/**
|
||||
* Update grid CSS variables
|
||||
*/
|
||||
private updateGridStyles(): void {
|
||||
const root = document.documentElement;
|
||||
const config = calendarConfig.getAll();
|
||||
|
||||
// Set CSS variables
|
||||
root.style.setProperty('--hour-height', `${config.hourHeight}px`);
|
||||
root.style.setProperty('--minute-height', `${config.hourHeight / 60}px`);
|
||||
root.style.setProperty('--snap-interval', config.snapInterval.toString());
|
||||
root.style.setProperty('--day-start-hour', config.dayStartHour.toString());
|
||||
root.style.setProperty('--day-end-hour', config.dayEndHour.toString());
|
||||
root.style.setProperty('--work-start-hour', config.workStartHour.toString());
|
||||
root.style.setProperty('--work-end-hour', config.workEndHour.toString());
|
||||
|
||||
// Set grid height
|
||||
const totalHeight = calendarConfig.totalHours * config.hourHeight;
|
||||
if (this.timeGrid) {
|
||||
this.timeGrid.style.height = `${totalHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup grid interaction handlers
|
||||
*/
|
||||
private setupGridInteractions(): void {
|
||||
if (!this.timeGrid) return;
|
||||
|
||||
// Click handler
|
||||
this.timeGrid.addEventListener('click', (e: MouseEvent) => {
|
||||
// Ignore if clicking on an event
|
||||
if ((e.target as Element).closest('swp-event')) return;
|
||||
|
||||
const column = (e.target as Element).closest('swp-day-column') as HTMLElement;
|
||||
if (!column) return;
|
||||
|
||||
const position = this.getClickPosition(e, column);
|
||||
|
||||
eventBus.emit(EventTypes.GRID_CLICK, {
|
||||
date: (column as any).dataset.date,
|
||||
time: position.time,
|
||||
minutes: position.minutes,
|
||||
columnIndex: parseInt((column as any).dataset.columnIndex)
|
||||
});
|
||||
});
|
||||
|
||||
// Double click handler
|
||||
this.timeGrid.addEventListener('dblclick', (e: MouseEvent) => {
|
||||
// Ignore if clicking on an event
|
||||
if ((e.target as Element).closest('swp-event')) return;
|
||||
|
||||
const column = (e.target as Element).closest('swp-day-column') as HTMLElement;
|
||||
if (!column) return;
|
||||
|
||||
const position = this.getClickPosition(e, column);
|
||||
|
||||
eventBus.emit(EventTypes.GRID_DBLCLICK, {
|
||||
date: (column as any).dataset.date,
|
||||
time: position.time,
|
||||
minutes: position.minutes,
|
||||
columnIndex: parseInt((column as any).dataset.columnIndex)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get click position in grid
|
||||
*/
|
||||
private getClickPosition(event: MouseEvent, column: HTMLElement): GridPosition {
|
||||
const rect = column.getBoundingClientRect();
|
||||
const y = event.clientY - rect.top + (this.scrollableContent?.scrollTop || 0);
|
||||
|
||||
const minuteHeight = calendarConfig.minuteHeight;
|
||||
const snapInterval = calendarConfig.get('snapInterval');
|
||||
const dayStartHour = calendarConfig.get('dayStartHour');
|
||||
|
||||
// Calculate minutes from start of day
|
||||
let minutes = Math.floor(y / minuteHeight);
|
||||
|
||||
// Snap to interval
|
||||
minutes = Math.round(minutes / snapInterval) * snapInterval;
|
||||
|
||||
// Add day start offset
|
||||
const totalMinutes = (dayStartHour * 60) + minutes;
|
||||
|
||||
return {
|
||||
minutes: totalMinutes,
|
||||
time: this.minutesToTime(totalMinutes),
|
||||
y: minutes * minuteHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility methods
|
||||
*/
|
||||
|
||||
private formatHour(hour: number): string {
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
||||
return `${displayHour} ${period}`;
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private getDayName(date: Date): string {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
|
||||
private getWeekDates(weekStart: Date): Date[] {
|
||||
const dates: Date[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(weekStart);
|
||||
date.setDate(weekStart.getDate() + i);
|
||||
dates.push(date);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
private minutesToTime(totalMinutes: number): string {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
|
||||
|
||||
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific hour
|
||||
*/
|
||||
scrollToHour(hour: number): void {
|
||||
if (!this.scrollableContent) return;
|
||||
|
||||
const hourHeight = calendarConfig.get('hourHeight');
|
||||
const dayStartHour = calendarConfig.get('dayStartHour');
|
||||
const scrollTop = (hour - dayStartHour) * hourHeight;
|
||||
|
||||
this.scrollableContent.scrollTop = scrollTop;
|
||||
}
|
||||
}
|
||||
239
src/managers/NavigationManager.ts
Normal file
239
src/managers/NavigationManager.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { IEventBus } from '../types/CalendarTypes.js';
|
||||
import { DateUtils } from '../utils/DateUtils.js';
|
||||
import { EventTypes } from '../constants/EventTypes.js';
|
||||
|
||||
/**
|
||||
* NavigationManager handles calendar navigation (prev/next/today buttons)
|
||||
* and week transitions with smooth animations
|
||||
*/
|
||||
export class NavigationManager {
|
||||
private eventBus: IEventBus;
|
||||
private currentWeek: Date;
|
||||
private targetWeek: Date;
|
||||
private animationQueue: number = 0;
|
||||
|
||||
constructor(eventBus: IEventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.currentWeek = DateUtils.getWeekStart(new Date(), 0); // Sunday start like POC
|
||||
this.targetWeek = new Date(this.currentWeek);
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.setupEventListeners();
|
||||
this.updateWeekInfo();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Listen for navigation button clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const navButton = target.closest('[data-action]') as HTMLElement;
|
||||
|
||||
if (!navButton) return;
|
||||
|
||||
const action = navButton.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'prev':
|
||||
this.navigateToPreviousWeek();
|
||||
break;
|
||||
case 'next':
|
||||
this.navigateToNextWeek();
|
||||
break;
|
||||
case 'today':
|
||||
this.navigateToToday();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for external navigation requests
|
||||
this.eventBus.on(EventTypes.NAVIGATE_TO_DATE, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const targetDate = new Date(customEvent.detail.date);
|
||||
this.navigateToDate(targetDate);
|
||||
});
|
||||
}
|
||||
|
||||
private navigateToPreviousWeek(): void {
|
||||
this.targetWeek.setDate(this.targetWeek.getDate() - 7);
|
||||
const weekToShow = new Date(this.targetWeek);
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', weekToShow);
|
||||
}
|
||||
|
||||
private navigateToNextWeek(): void {
|
||||
this.targetWeek.setDate(this.targetWeek.getDate() + 7);
|
||||
const weekToShow = new Date(this.targetWeek);
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', weekToShow);
|
||||
}
|
||||
|
||||
private navigateToToday(): void {
|
||||
const today = new Date();
|
||||
const todayWeekStart = DateUtils.getWeekStart(today, 0);
|
||||
|
||||
// Reset to today
|
||||
this.targetWeek = new Date(todayWeekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = todayWeekStart.getTime();
|
||||
|
||||
if (currentTime < targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', todayWeekStart);
|
||||
} else if (currentTime > targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', todayWeekStart);
|
||||
}
|
||||
}
|
||||
|
||||
private navigateToDate(date: Date): void {
|
||||
const weekStart = DateUtils.getWeekStart(date, 0);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
if (currentTime < targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', weekStart);
|
||||
} else if (currentTime > targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', weekStart);
|
||||
}
|
||||
}
|
||||
|
||||
private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void {
|
||||
const container = document.querySelector('swp-calendar-container');
|
||||
const currentWeekContainer = document.querySelector('swp-week-container');
|
||||
|
||||
if (!container || !currentWeekContainer) {
|
||||
console.warn('NavigationManager: Required DOM elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new week container
|
||||
const newWeekContainer = document.createElement('swp-week-container');
|
||||
newWeekContainer.innerHTML = `
|
||||
<swp-week-header></swp-week-header>
|
||||
<swp-scrollable-content>
|
||||
<swp-time-grid>
|
||||
<swp-grid-lines></swp-grid-lines>
|
||||
<swp-day-columns></swp-day-columns>
|
||||
</swp-time-grid>
|
||||
</swp-scrollable-content>
|
||||
`;
|
||||
|
||||
// Position new week off-screen
|
||||
newWeekContainer.style.position = 'absolute';
|
||||
newWeekContainer.style.top = '0';
|
||||
newWeekContainer.style.left = '0';
|
||||
newWeekContainer.style.width = '100%';
|
||||
newWeekContainer.style.height = '100%';
|
||||
newWeekContainer.style.transform = direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
|
||||
// Add to container
|
||||
container.appendChild(newWeekContainer);
|
||||
|
||||
// Notify other managers to render content for the new week
|
||||
this.eventBus.emit(EventTypes.WEEK_CONTAINER_CREATED, {
|
||||
container: newWeekContainer,
|
||||
weekStart: targetWeek
|
||||
});
|
||||
|
||||
// Animate transition
|
||||
requestAnimationFrame(() => {
|
||||
// Slide out current week
|
||||
(currentWeekContainer as HTMLElement).style.transform = direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)';
|
||||
(currentWeekContainer as HTMLElement).style.opacity = '0.5';
|
||||
|
||||
// Slide in new week
|
||||
newWeekContainer.style.transform = 'translateX(0)';
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
currentWeekContainer.remove();
|
||||
newWeekContainer.style.position = 'relative';
|
||||
|
||||
// Update currentWeek only after animation is complete
|
||||
this.currentWeek = new Date(targetWeek);
|
||||
this.animationQueue--;
|
||||
|
||||
// If this was the last queued animation, ensure we're in sync
|
||||
if (this.animationQueue === 0) {
|
||||
this.currentWeek = new Date(this.targetWeek);
|
||||
}
|
||||
|
||||
// Update week info and notify other managers
|
||||
this.updateWeekInfo();
|
||||
this.eventBus.emit(EventTypes.WEEK_CHANGED, {
|
||||
weekStart: this.currentWeek,
|
||||
weekEnd: DateUtils.addDays(this.currentWeek, 6)
|
||||
});
|
||||
|
||||
}, 400); // Match CSS transition duration
|
||||
});
|
||||
}
|
||||
|
||||
private updateWeekInfo(): void {
|
||||
const weekNumber = DateUtils.getWeekNumber(this.currentWeek);
|
||||
const weekEnd = DateUtils.addDays(this.currentWeek, 6);
|
||||
const dateRange = DateUtils.formatDateRange(this.currentWeek, weekEnd);
|
||||
|
||||
// Update week info in DOM
|
||||
const weekNumberElement = document.querySelector('swp-week-number');
|
||||
const dateRangeElement = document.querySelector('swp-date-range');
|
||||
|
||||
if (weekNumberElement) {
|
||||
weekNumberElement.textContent = `Week ${weekNumber}`;
|
||||
}
|
||||
|
||||
if (dateRangeElement) {
|
||||
dateRangeElement.textContent = dateRange;
|
||||
}
|
||||
|
||||
// Notify other managers about week info update
|
||||
this.eventBus.emit(EventTypes.WEEK_INFO_UPDATED, {
|
||||
weekNumber,
|
||||
dateRange,
|
||||
weekStart: this.currentWeek,
|
||||
weekEnd
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current week start date
|
||||
*/
|
||||
getCurrentWeek(): Date {
|
||||
return new Date(this.currentWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target week (where navigation is heading)
|
||||
*/
|
||||
getTargetWeek(): Date {
|
||||
return new Date(this.targetWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation animation is in progress
|
||||
*/
|
||||
isAnimating(): boolean {
|
||||
return this.animationQueue > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force navigation to specific week without animation
|
||||
*/
|
||||
setWeek(weekStart: Date): void {
|
||||
this.currentWeek = new Date(weekStart);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
this.updateWeekInfo();
|
||||
|
||||
this.eventBus.emit(EventTypes.WEEK_CHANGED, {
|
||||
weekStart: this.currentWeek,
|
||||
weekEnd: DateUtils.addDays(this.currentWeek, 6)
|
||||
});
|
||||
}
|
||||
}
|
||||
174
src/managers/ViewManager.ts
Normal file
174
src/managers/ViewManager.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { EventBus } from '../core/EventBus';
|
||||
import { CalendarView, IEventBus } from '../types/CalendarTypes';
|
||||
import { calendarConfig } from '../core/CalendarConfig';
|
||||
import { EventTypes } from '../constants/EventTypes';
|
||||
|
||||
/**
|
||||
* ViewManager - Håndterer skift mellem dag/uge/måned visninger
|
||||
* Arbejder med custom tags fra POC design
|
||||
*/
|
||||
export class ViewManager {
|
||||
private eventBus: IEventBus;
|
||||
private currentView: CalendarView = 'week';
|
||||
|
||||
constructor(eventBus: IEventBus) {
|
||||
this.eventBus = eventBus;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on(EventTypes.CALENDAR_INITIALIZED, () => {
|
||||
this.initializeView();
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.VIEW_CHANGE_REQUESTED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { currentView } = customEvent.detail;
|
||||
this.changeView(currentView);
|
||||
});
|
||||
|
||||
this.eventBus.on(EventTypes.DATE_CHANGED, () => {
|
||||
this.refreshCurrentView();
|
||||
});
|
||||
|
||||
// Setup view button handlers
|
||||
this.setupViewButtonHandlers();
|
||||
}
|
||||
|
||||
private setupViewButtonHandlers(): void {
|
||||
const viewButtons = document.querySelectorAll('swp-view-button[data-view]');
|
||||
viewButtons.forEach(button => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const view = button.getAttribute('data-view') as CalendarView;
|
||||
if (view && this.isValidView(view)) {
|
||||
this.changeView(view);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private initializeView(): void {
|
||||
this.renderTimeAxis();
|
||||
this.renderWeekHeaders();
|
||||
this.renderDayColumns();
|
||||
this.updateViewButtons();
|
||||
|
||||
this.eventBus.emit(EventTypes.VIEW_RENDERED, {
|
||||
view: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
private changeView(newView: CalendarView): void {
|
||||
if (newView === this.currentView) return;
|
||||
|
||||
const previousView = this.currentView;
|
||||
this.currentView = newView;
|
||||
|
||||
console.log(`ViewManager: Changing view from ${previousView} to ${newView}`);
|
||||
|
||||
this.updateViewButtons();
|
||||
|
||||
this.eventBus.emit(EventTypes.VIEW_CHANGED, {
|
||||
previousView,
|
||||
currentView: newView
|
||||
});
|
||||
}
|
||||
|
||||
private renderTimeAxis(): void {
|
||||
const timeAxis = document.querySelector('swp-time-axis');
|
||||
if (!timeAxis) return;
|
||||
|
||||
const startHour = calendarConfig.get('dayStartHour');
|
||||
const endHour = calendarConfig.get('dayEndHour');
|
||||
|
||||
timeAxis.innerHTML = '';
|
||||
|
||||
for (let hour = startHour; hour <= endHour; hour++) {
|
||||
const marker = document.createElement('swp-hour-marker');
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
|
||||
marker.textContent = `${displayHour} ${period}`;
|
||||
timeAxis.appendChild(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private renderWeekHeaders(): void {
|
||||
const weekHeader = document.querySelector('swp-week-header');
|
||||
if (!weekHeader) return;
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
weekHeader.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const header = document.createElement('swp-day-header');
|
||||
header.innerHTML = `
|
||||
<swp-day-name>${days[i]}</swp-day-name>
|
||||
<swp-day-date>${i + 1}</swp-day-date>
|
||||
`;
|
||||
header.dataset.dayIndex = i.toString();
|
||||
|
||||
// Check if today (this will be updated by NavigationManager later)
|
||||
if (i === 1) { // Mock today as Monday for now
|
||||
header.setAttribute('data-today', 'true');
|
||||
}
|
||||
|
||||
weekHeader.appendChild(header);
|
||||
}
|
||||
}
|
||||
|
||||
private renderDayColumns(): void {
|
||||
const dayColumns = document.querySelector('swp-day-columns');
|
||||
if (!dayColumns) return;
|
||||
|
||||
dayColumns.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const column = document.createElement('swp-day-column');
|
||||
column.dataset.dayIndex = i.toString();
|
||||
|
||||
const eventsLayer = document.createElement('swp-events-layer');
|
||||
column.appendChild(eventsLayer);
|
||||
|
||||
dayColumns.appendChild(column);
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewButtons(): void {
|
||||
const viewButtons = document.querySelectorAll('swp-view-button[data-view]');
|
||||
viewButtons.forEach(button => {
|
||||
const buttonView = button.getAttribute('data-view') as CalendarView;
|
||||
if (buttonView === this.currentView) {
|
||||
button.setAttribute('data-active', 'true');
|
||||
} else {
|
||||
button.removeAttribute('data-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCurrentView(): void {
|
||||
this.renderWeekHeaders();
|
||||
this.renderDayColumns();
|
||||
|
||||
this.eventBus.emit(EventTypes.VIEW_RENDERED, {
|
||||
view: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
private isValidView(view: string): view is CalendarView {
|
||||
return ['day', 'week', 'month'].includes(view);
|
||||
}
|
||||
|
||||
public getCurrentView(): CalendarView {
|
||||
return this.currentView;
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.refreshCurrentView();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// Event listeners bliver automatisk fjernet af EventBus
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue