diff --git a/app/api/external-syncs/[id]/sync/route.ts b/app/api/external-syncs/[id]/sync/route.ts index 3c7f047..b09f0f6 100644 --- a/app/api/external-syncs/[id]/sync/route.ts +++ b/app/api/external-syncs/[id]/sync/route.ts @@ -10,6 +10,7 @@ import { splitMultiDayEvent, createEventFingerprint, needsUpdate, + processTodoToShift, } from "@/lib/external-calendar-utils"; import { rateLimit } from "@/lib/rate-limiter"; import { @@ -97,6 +98,7 @@ export async function syncExternalCalendar( const comp = new ICAL.Component(jcalData); const vevents = comp.getAllSubcomponents("vevent"); + const vtodos = comp.getAllSubcomponents("vtodo"); // Define sync window: 3 months back to 1 year forward const syncWindowStart = new Date(); @@ -230,6 +232,76 @@ export async function syncExternalCalendar( } } + // Process VTODO components (tasks/to-dos) + for (const vtodo of vtodos) { + const todoData = processTodoToShift(vtodo); + + if (!todoData) { + continue; // Skip invalid todos + } + + // Check if task date is within sync window + const taskDate = todoData.date; + if (taskDate < syncWindowStart || taskDate > syncWindowEnd) { + continue; // Skip tasks outside sync window + } + + // Create event ID for the todo + const eventId = todoData.uid; + const title = todoData.title; + + // Create fingerprint based on task content + const fingerprint = createEventFingerprint( + todoData.date, + todoData.startTime, + todoData.endTime, + title, + undefined, + externalSync.syncType !== "custom" ? eventId : undefined + ); + + processedFingerprints.add(fingerprint); + + const shiftData = { + calendarId: externalSync.calendarId, + date: todoData.date, + startTime: todoData.startTime, + endTime: todoData.endTime, + title, + color: externalSync.color, + notes: todoData.notes, + isAllDay: todoData.isAllDay, + isSecondary: false, + externalEventId: eventId, + externalSyncId: syncId, + syncedFromExternal: true, + presetId: null, + }; + + // Check if this task already exists by fingerprint + const existingShift = existingShiftsByFingerprint.get(fingerprint); + + if (existingShift) { + // Only update if data has actually changed + if (needsUpdate(existingShift, shiftData)) { + shiftsToUpdate.push({ + id: existingShift.id, + ...shiftData, + updatedAt: new Date(), + }); + } + // If no changes, skip this shift (no update needed) + } else { + // Collect for batch insert + shiftsToInsert.push({ + id: crypto.randomUUID(), + ...shiftData, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + } + // Calculate which shifts to delete before transaction // Delete shifts that are no longer in the external calendar (based on fingerprint) const shiftIdsToDelete = existingShifts diff --git a/lib/external-calendar-utils.ts b/lib/external-calendar-utils.ts index 8614ee4..60ee37b 100644 --- a/lib/external-calendar-utils.ts +++ b/lib/external-calendar-utils.ts @@ -111,7 +111,8 @@ export function isValidICSContent(icsContent: string): boolean { const jcalData = ICAL.parse(icsContent); const comp = new ICAL.Component(jcalData); const vevents = comp.getAllSubcomponents("vevent"); - return vevents.length > 0; + const vtodos = comp.getAllSubcomponents("vtodo"); + return vevents.length > 0 || vtodos.length > 0; } catch { return false; } @@ -405,3 +406,73 @@ export function needsUpdate( // No differences found return false; } + +/** + * Processes a VTODO component and converts it to shift data + * @param vtodo - The VTODO component from iCal.js + * @returns Shift data object or null if invalid + */ +export function processTodoToShift(vtodo: ICAL.Component): { + date: Date; + startTime: string; + endTime: string; + title: string; + notes: string | null; + isAllDay: boolean; + uid: string; +} | null { + try { + // Get the TODO properties + const summary = vtodo.getFirstPropertyValue("summary") || "Untitled Task"; + const description = vtodo.getFirstPropertyValue("description") || null; + const uid = vtodo.getFirstPropertyValue("uid") || crypto.randomUUID(); + + // VTODO can have DUE (due date) or DTSTART (start date) + const dueDate = vtodo.getFirstPropertyValue("due") as ICAL.Time | null; + const startDate = vtodo.getFirstPropertyValue( + "dtstart" + ) as ICAL.Time | null; + + // Use due date if available, otherwise use start date + const taskDate = dueDate || startDate; + + if (!taskDate) { + // If no date is set, skip this task + return null; + } + + const isAllDay = taskDate.isDate; + const jsDate = taskDate.toJSDate(); + + // For tasks, we'll show them as all-day items by default + // or use specific times if they have them + let startTime = "00:00"; + let endTime = "23:59"; + + if (!isAllDay && dueDate) { + // If there's a specific due time, show it as ending at that time + const hours = jsDate.getHours().toString().padStart(2, "0"); + const minutes = jsDate.getMinutes().toString().padStart(2, "0"); + endTime = `${hours}:${minutes}`; + } else if (!isAllDay && startDate) { + // If there's a start time but no due time, show it starting at that time + const hours = jsDate.getHours().toString().padStart(2, "0"); + const minutes = jsDate.getMinutes().toString().padStart(2, "0"); + startTime = `${hours}:${minutes}`; + endTime = "23:59"; + } + + return { + date: jsDate, + startTime, + endTime, + title: summary as string, + notes: description as string | null, + isAllDay, + uid: uid as string, + }; + } catch (error) { + console.error("Error processing VTODO:", error); + return null; + } +}