Implements offline-first calendar sync infrastructure

Adds IndexedDB and operation queue for robust offline synchronization
Introduces SyncManager to handle background data synchronization
Supports local event operations with automatic remote sync queuing

Enhances application reliability and user experience in low/no connectivity scenarios
This commit is contained in:
Janus C. H. Knudsen 2025-11-05 00:37:57 +01:00
parent 9c765b35ab
commit e7011526e3
20 changed files with 3822 additions and 57 deletions

View file

@ -127,13 +127,13 @@ export class AllDayManager {
});
// Listen for header ready - when dates are populated with period data
eventBus.on('header:ready', (event: Event) => {
eventBus.on('header:ready', async (event: Event) => {
let headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
let startDate = new Date(headerReadyEventPayload.headerElements.at(0)!.date);
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1)!.date);
let events: ICalendarEvent[] = this.eventManager.getEventsForPeriod(startDate, endDate);
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
// Filter for all-day events
const allDayEvents = events.filter(event => event.allDay);
@ -380,7 +380,7 @@ export class AllDayManager {
}
private handleDragEnd(dragEndEvent: IDragEndEventPayload): void {
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
const getEventDurationDays = (start: string | undefined, end: string | undefined): number => {
@ -496,6 +496,15 @@ export class AllDayManager {
// 7. Apply highlight class to show the dropped event with highlight color
dragEndEvent.draggedClone.classList.add('highlight');
// 8. CRITICAL FIX: Update event in repository to mark as allDay=true
// This ensures EventManager's repository has correct state
// Without this, the event will reappear in grid on re-render
await this.eventManager.updateEvent(eventId, {
start: newStartDate,
end: newEndDate,
allDay: true
});
this.fadeOutAndRemove(dragEndEvent.originalElement);
this.checkAndAnimateAllDayHeight();

View file

@ -211,7 +211,7 @@ export class CalendarManager {
/**
* Re-render events after grid structure changes
*/
private rerenderEvents(): void {
private async rerenderEvents(): Promise<void> {
// Get current period data to determine date range
const periodData = this.calculateCurrentPeriod();
@ -223,7 +223,7 @@ export class CalendarManager {
}
// Trigger event rendering for the current date range using correct method
this.eventRenderer.renderEvents({
await this.eventRenderer.renderEvents({
container: container as HTMLElement,
startDate: new Date(periodData.start),
endDate: new Date(periodData.end)

View file

@ -6,11 +6,11 @@ import { IEventRepository } from '../repositories/IEventRepository';
/**
* EventManager - Event lifecycle and CRUD operations
* Handles event management and CRUD operations
* Delegates all data operations to IEventRepository
* No longer maintains in-memory cache - repository is single source of truth
*/
export class EventManager {
private events: ICalendarEvent[] = [];
private dateService: DateService;
private config: Configuration;
private repository: IEventRepository;
@ -28,30 +28,32 @@ export class EventManager {
/**
* Load event data from repository
* No longer caches - delegates to repository
*/
public async loadData(): Promise<void> {
try {
this.events = await this.repository.loadEvents();
// Just ensure repository is ready - no caching
await this.repository.loadEvents();
} catch (error) {
console.error('Failed to load event data:', error);
this.events = [];
throw error;
}
}
/**
* Get events with optional copying for performance
* Get all events from repository
*/
public getEvents(copy: boolean = false): ICalendarEvent[] {
return copy ? [...this.events] : this.events;
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
const events = await this.repository.loadEvents();
return copy ? [...events] : events;
}
/**
* Optimized event lookup with early return
* Get event by ID from repository
*/
public getEventById(id: string): ICalendarEvent | undefined {
// Use find for better performance than filter + first
return this.events.find(event => event.id === id);
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
const events = await this.repository.loadEvents();
return events.find(event => event.id === id);
}
/**
@ -59,8 +61,8 @@ export class EventManager {
* @param id Event ID to find
* @returns Event with navigation info or null if not found
*/
public getEventForNavigation(id: string): { event: ICalendarEvent; eventDate: Date } | null {
const event = this.getEventById(id);
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
const event = await this.getEventById(id);
if (!event) {
return null;
}
@ -90,8 +92,8 @@ export class EventManager {
* @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);
public async navigateToEvent(eventId: string): Promise<boolean> {
const eventInfo = await this.getEventForNavigation(eventId);
if (!eventInfo) {
console.warn(`EventManager: Event with ID ${eventId} not found`);
return false;
@ -113,23 +115,20 @@ export class EventManager {
/**
* Get events that overlap with a given time period
*/
public getEventsForPeriod(startDate: Date, endDate: Date): ICalendarEvent[] {
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
const events = await this.repository.loadEvents();
// Event overlaps period if it starts before period ends AND ends after period starts
return this.events.filter(event => {
return events.filter(event => {
return event.start <= endDate && event.end >= startDate;
});
}
/**
* Create a new event and add it to the calendar
* Delegates to repository with source='local'
*/
public addEvent(event: Omit<ICalendarEvent, 'id'>): ICalendarEvent {
const newEvent: ICalendarEvent = {
...event,
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
};
this.events.push(newEvent);
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
const newEvent = await this.repository.createEvent(event, 'local');
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
event: newEvent
@ -140,18 +139,59 @@ export class EventManager {
/**
* Update an existing event
* Delegates to repository with source='local'
*/
public updateEvent(id: string, updates: Partial<ICalendarEvent>): ICalendarEvent | null {
const eventIndex = this.events.findIndex(event => event.id === id);
if (eventIndex === -1) return null;
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
try {
const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
const updatedEvent = { ...this.events[eventIndex], ...updates };
this.events[eventIndex] = updatedEvent;
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: updatedEvent
});
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event: updatedEvent
});
return updatedEvent;
} catch (error) {
console.error(`Failed to update event ${id}:`, error);
return null;
}
}
return updatedEvent;
/**
* Delete an event
* Delegates to repository with source='local'
*/
public async deleteEvent(id: string): Promise<boolean> {
try {
await this.repository.deleteEvent(id, 'local');
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
eventId: id
});
return true;
} catch (error) {
console.error(`Failed to delete event ${id}:`, error);
return false;
}
}
/**
* Handle remote update from SignalR
* Delegates to repository with source='remote'
*/
public async handleRemoteUpdate(event: ICalendarEvent): Promise<void> {
try {
await this.repository.updateEvent(event.id, event, 'remote');
this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
event
});
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event
});
} catch (error) {
console.error(`Failed to handle remote update for event ${event.id}:`, error);
}
}
}