Skip to content
Open
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
171 changes: 168 additions & 3 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,150 @@ export class TaskCalendarSyncService {
}
}

/**
* Parse ISO 8601 duration format and return milliseconds.
* Based on the parser from NotificationService.
*/
private parseISO8601Duration(duration: string): number | null {
// Parse ISO 8601 duration format (e.g., "-PT15M", "P2D", "-PT1H30M")
const match = duration.match(
/^(-?)P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
);

if (!match) {
return null;
}

const [, sign, years, months, weeks, days, hours, minutes, seconds] = match;

let totalMs = 0;

// Note: For simplicity, we treat months as 30 days and years as 365 days
if (years) totalMs += parseInt(years) * 365 * 24 * 60 * 60 * 1000;
if (months) totalMs += parseInt(months) * 30 * 24 * 60 * 60 * 1000;
if (weeks) totalMs += parseInt(weeks) * 7 * 24 * 60 * 60 * 1000;
if (days) totalMs += parseInt(days) * 24 * 60 * 60 * 1000;
if (hours) totalMs += parseInt(hours) * 60 * 60 * 1000;
if (minutes) totalMs += parseInt(minutes) * 60 * 1000;
if (seconds) totalMs += parseInt(seconds) * 1000;

// Apply sign for negative durations (before the anchor date)
return sign === "-" ? -totalMs : totalMs;
}

/**
* Convert task reminders to Google Calendar reminder format.
* Returns an array of reminder overrides in the format Google Calendar API expects.
*
* @param task - The task with reminders
* @param eventStartTime - The event start time (ISO string or date string)
* @param eventDateSource - Which date field was used for the event ('due' or 'scheduled')
* @returns Array of { method: string; minutes: number } or null if no valid reminders
*/
private convertTaskRemindersToGoogleFormat(
task: TaskInfo,
eventStartTime: string,
eventDateSource: 'due' | 'scheduled'
): Array<{ method: string; minutes: number }> | null {
if (!task.reminders || !Array.isArray(task.reminders) || task.reminders.length === 0) {
return null;
}

const googleReminders: Array<{ method: string; minutes: number }> = [];
const GOOGLE_MAX_REMINDER_MINUTES = 40320; // 4 weeks in minutes (Google Calendar API limit)

// Parse event start time to get a timestamp
let eventStartMs: number;
try {
// Handle both ISO timestamps and date-only strings
if (eventStartTime.includes('T')) {
eventStartMs = new Date(eventStartTime).getTime();
} else {
// Date-only string - assume start of day in local timezone
eventStartMs = new Date(eventStartTime + 'T00:00:00').getTime();
}

if (isNaN(eventStartMs)) {
console.warn('[TaskCalendarSync] Invalid event start time:', eventStartTime);
return null;
}
} catch (error) {
console.warn('[TaskCalendarSync] Error parsing event start time:', error);
return null;
}

for (const reminder of task.reminders) {
if (!reminder.type) continue;

if (reminder.type === 'relative') {
// Only include relative reminders that match the event's date source
if (reminder.relatedTo !== eventDateSource) {
continue;
}

// Parse the ISO 8601 duration
if (!reminder.offset) continue;
const durationMs = this.parseISO8601Duration(reminder.offset);
if (durationMs === null) {
console.warn('[TaskCalendarSync] Invalid duration format:', reminder.offset);
continue;
}

// Convert to minutes before the event
// Negative duration means "before", which is what we want
// Zero duration means "at event time"
// Positive duration means "after", which Google Calendar doesn't support for reminders
const minutesBefore = Math.abs(Math.round(durationMs / (60 * 1000)));

// Skip if reminder is after the event (positive duration without negative sign)
if (durationMs > 0) {
console.warn('[TaskCalendarSync] Skipping reminder after event:', reminder);
continue;
}

// Cap at Google Calendar's limit
const cappedMinutes = Math.min(minutesBefore, GOOGLE_MAX_REMINDER_MINUTES);

// Include 0-minute reminders (at event time)
if (cappedMinutes >= 0) {
googleReminders.push({ method: 'popup', minutes: cappedMinutes });
}
} else if (reminder.type === 'absolute') {
// Calculate minutes before event start
if (!reminder.absoluteTime) continue;

try {
const reminderTimeMs = new Date(reminder.absoluteTime).getTime();
if (isNaN(reminderTimeMs)) {
console.warn('[TaskCalendarSync] Invalid absolute time:', reminder.absoluteTime);
continue;
}

// Calculate difference in minutes
const diffMs = eventStartMs - reminderTimeMs;
const minutesBefore = Math.round(diffMs / (60 * 1000));

// Skip if reminder is after the event start
if (minutesBefore < 0) {
console.warn('[TaskCalendarSync] Skipping absolute reminder after event:', reminder);
continue;
}

// Cap at Google Calendar's limit
const cappedMinutes = Math.min(minutesBefore, GOOGLE_MAX_REMINDER_MINUTES);
// Include 0-minute reminders (at event time)
googleReminders.push({ method: 'popup', minutes: cappedMinutes });
} catch (error) {
console.warn('[TaskCalendarSync] Error parsing absolute reminder time:', error);
continue;
}
}
}

console.log('[TaskCalendarSync] Final reminders to sync:', googleReminders);
return googleReminders.length > 0 ? googleReminders : null;
}

/**
* Convert a task to a Google Calendar event payload
*/
Expand Down Expand Up @@ -401,8 +545,29 @@ export class TaskCalendarSyncService {
event.colorId = settings.eventColorId;
}

// Add reminder if configured
if (settings.defaultReminderMinutes !== null && settings.defaultReminderMinutes > 0) {
// Determine which date field was used for the event (for reminder conversion)
let eventDateSource: 'due' | 'scheduled';
if (settings.syncTrigger === 'scheduled' || (settings.syncTrigger === 'both' && task.scheduled)) {
eventDateSource = 'scheduled';
} else {
eventDateSource = 'due';
}

// Add reminders - prioritize task reminders, fall back to default
const taskReminders = this.convertTaskRemindersToGoogleFormat(
task,
eventDate,
eventDateSource
);

if (taskReminders && taskReminders.length > 0) {
// Use task-specific reminders
event.reminders = {
useDefault: false,
overrides: taskReminders,
};
} else if (settings.defaultReminderMinutes !== null && settings.defaultReminderMinutes > 0) {
// Fall back to default reminder setting
event.reminders = {
useDefault: false,
overrides: [{ method: "popup", minutes: settings.defaultReminderMinutes }],
Expand Down Expand Up @@ -537,7 +702,7 @@ export class TaskCalendarSyncService {
// Wait for any in-flight sync to complete before starting a new one
const inFlight = this.inFlightSyncs.get(taskPath);
if (inFlight) {
await inFlight.catch(() => {}); // Ignore errors from previous sync
await inFlight.catch(() => { }); // Ignore errors from previous sync
}

// Re-fetch the task to get the latest state after debounce
Expand Down