diff --git a/components/calendar.tsx b/components/calendar.tsx index e43dd48..d46d150 100644 --- a/components/calendar.tsx +++ b/components/calendar.tsx @@ -46,12 +46,26 @@ export function Calendar() { end: event.end, allDay: event.isAllDay, calendar: calendar.name, - calendarColor: calendar.color + calendarColor: calendar.color, + externalEventId: event.externalEventId || null, // Add externalEventId for deduplication })) ); + // Deduplicate events by externalEventId (if same event appears in multiple calendars) + // Keep the first occurrence of each unique externalEventId + const seenExternalIds = new Set(); + const deduplicatedEvents = allEvents.filter((event: any) => { + if (event.externalEventId) { + if (seenExternalIds.has(event.externalEventId)) { + return false; // Skip duplicate + } + seenExternalIds.add(event.externalEventId); + } + return true; // Keep event (either has no externalEventId or is first occurrence) + }); + // Filter for upcoming events - const upcomingEvents = allEvents + const upcomingEvents = deduplicatedEvents .filter((event: any) => new Date(event.start) >= now) .sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime()) .slice(0, 7); diff --git a/lib/services/microsoft-calendar-sync.ts b/lib/services/microsoft-calendar-sync.ts index 7f4c832..dfe7e61 100644 --- a/lib/services/microsoft-calendar-sync.ts +++ b/lib/services/microsoft-calendar-sync.ts @@ -18,12 +18,14 @@ export interface MicrosoftEvent { contentType?: string; }; start: { - dateTime: string; - timeZone: string; + dateTime?: string; // For timed events + date?: string; // For all-day events (YYYY-MM-DD format) + timeZone?: string; }; end: { - dateTime: string; - timeZone: string; + dateTime?: string; // For timed events + date?: string; // For all-day events (YYYY-MM-DD format) + timeZone?: string; }; location?: { displayName?: string; @@ -138,24 +140,30 @@ export async function fetchMicrosoftEvents( }; // Add date filter if provided - // Note: Microsoft Graph API filter syntax is limited - // We can't easily filter both dateTime and date in one query - // So we'll filter by dateTime and handle all-day events separately if needed + // Microsoft Graph API supports filtering by start/dateTime for timed events + // For all-day events, we need to filter by start/date (date only, no time) + // We'll use a combined filter that handles both cases if (startDate && endDate) { // Format dates for Microsoft Graph API - // Use ISO 8601 format for dateTime filter + // For dateTime filter: use ISO 8601 format const startDateTimeStr = startDate.toISOString(); const endDateTimeStr = endDate.toISOString(); - // Microsoft Graph API filter: filter by start/dateTime - // This will match timed events. All-day events might need separate handling - // but Microsoft Graph usually returns all-day events with dateTime set to start of day - params.$filter = `start/dateTime ge '${startDateTimeStr}' and start/dateTime le '${endDateTimeStr}'`; + // For date filter (all-day events): use YYYY-MM-DD format + const startDateStr = startDate.toISOString().split('T')[0]; + const endDateStr = endDate.toISOString().split('T')[0]; + + // Combined filter: (start/dateTime >= startDate OR start/date >= startDate) + // AND (start/dateTime <= endDate OR start/date <= endDate) + // This handles both timed and all-day events + params.$filter = `(start/dateTime ge '${startDateTimeStr}' or start/date ge '${startDateStr}') and (start/dateTime le '${endDateTimeStr}' or start/date le '${endDateStr}')`; logger.debug('Microsoft Graph API filter', { filter: params.$filter, startDateTime: startDateTimeStr, endDateTime: endDateTimeStr, + startDate: startDateStr, + endDate: endDateStr, }); } else { // If no date filter, get all events (might be too many, but useful for debugging) @@ -192,7 +200,7 @@ export async function fetchMicrosoftEvents( hasValue: !!response.data.value, status: response.status, // Log first few event IDs to verify they're being returned - eventIds: events.slice(0, 5).map(e => e.id), + eventIds: events.slice(0, 5).map((e: MicrosoftEvent) => e.id), }); // Log if we got fewer events than expected @@ -243,23 +251,30 @@ export function convertMicrosoftEventToCalDAV(microsoftEvent: MicrosoftEvent): { allDay: boolean; } { // Microsoft Graph API uses different formats for all-day vs timed events - // All-day events have dateTime in format "YYYY-MM-DD" without time - // Timed events have dateTime in ISO format with time - const isAllDay = microsoftEvent.isAllDay || - (microsoftEvent.start.dateTime && !microsoftEvent.start.dateTime.includes('T')); + // All-day events have 'date' field (YYYY-MM-DD format) or isAllDay=true + // Timed events have 'dateTime' field (ISO format with time) + const isAllDay = microsoftEvent.isAllDay || !!microsoftEvent.start.date; let startDate: Date; let endDate: Date; if (isAllDay) { - // For all-day events, parse date only (YYYY-MM-DD) - startDate = new Date(microsoftEvent.start.dateTime.split('T')[0]); - endDate = new Date(microsoftEvent.end.dateTime.split('T')[0]); + // For all-day events, use 'date' field or parse dateTime if date not available + const startDateStr = microsoftEvent.start.date || microsoftEvent.start.dateTime?.split('T')[0] || ''; + const endDateStr = microsoftEvent.end.date || microsoftEvent.end.dateTime?.split('T')[0] || ''; + startDate = new Date(startDateStr); + endDate = new Date(endDateStr); // Set to start of day startDate.setHours(0, 0, 0, 0); endDate.setHours(0, 0, 0, 0); } else { // For timed events, parse full ISO datetime + if (!microsoftEvent.start.dateTime) { + throw new Error(`Timed event missing dateTime: ${JSON.stringify(microsoftEvent.start)}`); + } + if (!microsoftEvent.end.dateTime) { + throw new Error(`Timed event missing end dateTime: ${JSON.stringify(microsoftEvent.end)}`); + } startDate = new Date(microsoftEvent.start.dateTime); endDate = new Date(microsoftEvent.end.dateTime); } @@ -351,7 +366,7 @@ export async function syncMicrosoftCalendar( start: startDate.toISOString(), end: endDate.toISOString() }, - allEvents: microsoftEvents.map(e => ({ + allEvents: microsoftEvents.map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject || '(sans titre)', start: e.start.dateTime || e.start.date, @@ -363,25 +378,27 @@ export async function syncMicrosoftCalendar( })), }); - // Check if "Test" event is in the list - const testEvent = microsoftEvents.find(e => - e.subject && e.subject.toLowerCase().includes('test') + // Check if "test" or "retest" events are in the list + const testEvents = microsoftEvents.filter((e: MicrosoftEvent) => + e.subject && (e.subject.toLowerCase().includes('test') || e.subject.toLowerCase().includes('retest')) ); - if (testEvent) { - logger.info('Found "Test" event in Microsoft response', { + if (testEvents.length > 0) { + logger.info('Found "test"/"retest" events in Microsoft response', { calendarSyncId, - testEvent: { - id: testEvent.id, - subject: testEvent.subject, - start: testEvent.start.dateTime || testEvent.start.date, - isAllDay: testEvent.isAllDay, - } + count: testEvents.length, + events: testEvents.map((e: MicrosoftEvent) => ({ + id: e.id, + subject: e.subject, + start: e.start.dateTime || e.start.date, + isAllDay: e.isAllDay, + })) }); } else { - logger.warn('"Test" event NOT found in Microsoft response', { + logger.warn('"test"/"retest" events NOT found in Microsoft response', { calendarSyncId, totalEvents: microsoftEvents.length, - eventSubjects: microsoftEvents.map(e => e.subject || '(sans titre)'), + dateRange: { start: startDate.toISOString(), end: endDate.toISOString() }, + eventSubjects: microsoftEvents.map((e: MicrosoftEvent) => e.subject || '(sans titre)').slice(0, 10), // First 10 for debugging }); } @@ -395,15 +412,16 @@ export async function syncMicrosoftCalendar( } else { // Log events in the future to help debug const now = new Date(); - const futureEvents = microsoftEvents.filter(e => { + const futureEvents = microsoftEvents.filter((e: MicrosoftEvent) => { const eventStart = e.start.dateTime || e.start.date; + if (!eventStart) return false; return new Date(eventStart) > now; }); logger.info('Microsoft events in the future', { calendarSyncId, futureEventCount: futureEvents.length, totalEvents: microsoftEvents.length, - futureEvents: futureEvents.map(e => ({ + futureEvents: futureEvents.map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject || '(sans titre)', start: e.start.dateTime || e.start.date, @@ -412,15 +430,16 @@ export async function syncMicrosoftCalendar( }); // Also log events in the past to see all events - const pastEvents = microsoftEvents.filter(e => { + const pastEvents = microsoftEvents.filter((e: MicrosoftEvent) => { const eventStart = e.start.dateTime || e.start.date; + if (!eventStart) return false; return new Date(eventStart) <= now; }); if (pastEvents.length > 0) { logger.info('Microsoft events in the past', { calendarSyncId, pastEventCount: pastEvents.length, - pastEvents: pastEvents.map(e => ({ + pastEvents: pastEvents.map((e: MicrosoftEvent) => ({ id: e.id, subject: e.subject || '(sans titre)', start: e.start.dateTime || e.start.date, @@ -487,7 +506,7 @@ export async function syncMicrosoftCalendar( // Priority 2: Fallback to checking description for [MS_ID:xxx] (backward compatibility) if (!existingEvent && microsoftId) { - existingEvent = existingEvents.find((e) => { + existingEvent = existingEvents.find((e: typeof existingEvents[0]) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const hasExternalId = !!(e as any).externalEventId; if (!hasExternalId && e.description && e.description.includes(`[MS_ID:${microsoftId}]`)) { @@ -510,7 +529,7 @@ export async function syncMicrosoftCalendar( // This helps migrate old events that were created before externalEventId was added if (!existingEvent && microsoftId) { existingEvent = existingEvents.find( - (e) => { + (e: typeof existingEvents[0]) => { // Access externalEventId safely (may not be in Prisma type if client not regenerated) const hasExternalId = !!(e as any).externalEventId; // Only match events that don't have externalEventId yet (to avoid false matches)