Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions app/api/external-syncs/[id]/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
splitMultiDayEvent,
createEventFingerprint,
needsUpdate,
processTodoToShift,
} from "@/lib/external-calendar-utils";
import { rateLimit } from "@/lib/rate-limiter";
import {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
73 changes: 72 additions & 1 deletion lib/external-calendar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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): {

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VTODOs can have recurrence rules (RRULE) just like VEVENTs, but processTodoToShift() doesn't handle recurring tasks. The existing expandRecurringEvents() function works with VEVENT components specifically. Consider whether recurring VTODOs should be supported, and if so, either extend expandRecurringEvents() to work with VTODOs or create similar expansion logic for tasks. Without this, recurring tasks will only appear once instead of generating multiple shift occurrences.

Copilot uses AI. Check for mistakes.
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";
Comment on lines +452 to +462

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic doesn't handle VTODOs that have both DTSTART and DUE dates. When both are present, taskDate is set to dueDate (line 437), so jsDate contains the due date. In the condition at line 452, if a due time exists and it's not all-day, the code extracts hours/minutes from jsDate (the due date) for the endTime, but startTime remains '00:00'. However, if the VTODO has both start and due dates, you should use the start date for startTime and the due date for endTime, not default startTime to '00:00'. This creates shifts that always start at midnight even when a specific start time is provided.

Suggested change
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";
if (!isAllDay) {
if (startDate && dueDate) {
// When both start and due are present, use start for startTime and due for endTime
const startJsDate = startDate.toJSDate();
const startHours = startJsDate.getHours().toString().padStart(2, "0");
const startMinutes = startJsDate.getMinutes().toString().padStart(2, "0");
startTime = `${startHours}:${startMinutes}`;
const dueJsDate = dueDate.toJSDate();
const endHours = dueJsDate.getHours().toString().padStart(2, "0");
const endMinutes = dueJsDate.getMinutes().toString().padStart(2, "0");
endTime = `${endHours}:${endMinutes}`;
} else if (dueDate) {
// If there's a specific due time but no start time, show it as ending at that time
const dueJsDate = dueDate.toJSDate();
const hours = dueJsDate.getHours().toString().padStart(2, "0");
const minutes = dueJsDate.getMinutes().toString().padStart(2, "0");
endTime = `${hours}:${minutes}`;
} else if (startDate) {
// If there's a start time but no due time, show it starting at that time
const startJsDate = startDate.toJSDate();
const hours = startJsDate.getHours().toString().padStart(2, "0");
const minutes = startJsDate.getMinutes().toString().padStart(2, "0");
startTime = `${hours}:${minutes}`;
endTime = "23:59";
}

Copilot uses AI. Check for mistakes.
}

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;
}
}