From 503eb61f0cba69a0fb2d91115e34bf8eeccd6012 Mon Sep 17 00:00:00 2001 From: panteLx Date: Fri, 16 Jan 2026 20:14:04 +0100 Subject: [PATCH 1/3] feat: Adds VTODO-to-shift sync support Adds support for importing VTODO (tasks/todos) from external iCal feeds as shifts. Updates ICS validation to accept VTODOs, introduces a VTODO processing routine to convert task properties into shift data, and integrates tasks into the existing sync flow with sync-window filtering, fingerprint-based deduplication, and conditional update/insert logic. Also includes task counts in sync metrics to surface imported todo activity. --- app/api/external-syncs/[id]/sync/route.ts | 73 +++++++++++++++++++++++ lib/external-calendar-utils.ts | 71 +++++++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/app/api/external-syncs/[id]/sync/route.ts b/app/api/external-syncs/[id]/sync/route.ts index 3c7f047..c31182b 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 @@ -302,6 +374,7 @@ export async function syncExternalCalendar( updated: shiftsToUpdate.length, deleted: shiftIdsToDelete.length, totalEvents: vevents.length, + totalTodos: vtodos.length, totalOccurrences: shiftsToInsert.length + shiftsToUpdate.length, calendarId: externalSync.calendarId, syncType: externalSync.syncType, diff --git a/lib/external-calendar-utils.ts b/lib/external-calendar-utils.ts index 8614ee4..2e6e362 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,71 @@ 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; + } +} From 21ab4ae9918d76bf105446dc70dcd512c23a258d Mon Sep 17 00:00:00 2001 From: panteLx Date: Fri, 16 Jan 2026 20:14:28 +0100 Subject: [PATCH 2/3] fix: Clean up whitespace in VTODO processing functions --- app/api/external-syncs/[id]/sync/route.ts | 2 +- lib/external-calendar-utils.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/api/external-syncs/[id]/sync/route.ts b/app/api/external-syncs/[id]/sync/route.ts index c31182b..14e3f31 100644 --- a/app/api/external-syncs/[id]/sync/route.ts +++ b/app/api/external-syncs/[id]/sync/route.ts @@ -235,7 +235,7 @@ 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 } diff --git a/lib/external-calendar-utils.ts b/lib/external-calendar-utils.ts index 2e6e362..60ee37b 100644 --- a/lib/external-calendar-utils.ts +++ b/lib/external-calendar-utils.ts @@ -426,27 +426,29 @@ export function processTodoToShift(vtodo: ICAL.Component): { 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; - + 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"); @@ -459,7 +461,7 @@ export function processTodoToShift(vtodo: ICAL.Component): { startTime = `${hours}:${minutes}`; endTime = "23:59"; } - + return { date: jsDate, startTime, From 3576b806b02553398bbf60e0a88a81a1e4cdceef Mon Sep 17 00:00:00 2001 From: panteLx Date: Fri, 16 Jan 2026 20:20:59 +0100 Subject: [PATCH 3/3] fix: Remove totalTodos from sync statistics in external calendar sync --- app/api/external-syncs/[id]/sync/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/api/external-syncs/[id]/sync/route.ts b/app/api/external-syncs/[id]/sync/route.ts index 14e3f31..b09f0f6 100644 --- a/app/api/external-syncs/[id]/sync/route.ts +++ b/app/api/external-syncs/[id]/sync/route.ts @@ -374,7 +374,6 @@ export async function syncExternalCalendar( updated: shiftsToUpdate.length, deleted: shiftIdsToDelete.length, totalEvents: vevents.length, - totalTodos: vtodos.length, totalOccurrences: shiftsToInsert.length + shiftsToUpdate.length, calendarId: externalSync.calendarId, syncType: externalSync.syncType,