diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 9f47c2d9..c5e8f23f 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -26,5 +26,8 @@ Example: ## Fixed +- (#1696) Fixed Google Calendar export for scheduled recurring tasks when a single occurrence is moved to a different date. + - Keeps the recurring master event on its original rule, excludes the original occurrence date, and syncs the moved occurrence as a detached event. + - (#1911) Fixed recurrence choices starting from today instead of the selected calendar date when creating a task from Calendar view. - Thanks to @mikhailmarka for reporting. diff --git a/src/bootstrap/pluginBootstrap.ts b/src/bootstrap/pluginBootstrap.ts index 741ed71b..13627c4b 100644 --- a/src/bootstrap/pluginBootstrap.ts +++ b/src/bootstrap/pluginBootstrap.ts @@ -353,14 +353,27 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void { const eventIdKey = plugin.fieldMapper.toUserField("googleCalendarEventId"); + const exceptionEventIdKey = plugin.fieldMapper.toUserField( + "googleCalendarExceptionEventId" + ); const prevCache = data.prevCache as | { frontmatter?: Record } | undefined; const eventId = prevCache?.frontmatter?.[eventIdKey]; + const exceptionEventId = prevCache?.frontmatter?.[exceptionEventIdKey]; - if (typeof eventId === "string" && eventId.length > 0) { + if ( + (typeof eventId === "string" && eventId.length > 0) || + (typeof exceptionEventId === "string" && exceptionEventId.length > 0) + ) { plugin.taskCalendarSyncService - .deleteTaskFromCalendarByPath(data.path, eventId) + .deleteTaskFromCalendarByPath( + data.path, + typeof eventId === "string" ? eventId : undefined, + typeof exceptionEventId === "string" + ? exceptionEventId + : undefined + ) .catch((error) => { tasknotesLogger.warn( "Failed to delete task from Google Calendar on file deletion:", diff --git a/src/core/defaultFieldMapping.ts b/src/core/defaultFieldMapping.ts index 7e89b711..b62087c9 100644 --- a/src/core/defaultFieldMapping.ts +++ b/src/core/defaultFieldMapping.ts @@ -23,6 +23,9 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { icsEventId: "icsEventId", icsEventTag: "ics_event", googleCalendarEventId: "googleCalendarEventId", + googleCalendarExceptionEventId: "googleCalendarExceptionEventId", + googleCalendarExceptionOriginalScheduled: "googleCalendarExceptionOriginalScheduled", + googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", reminders: "reminders", sortOrder: "tasknotes_manual_order", }; diff --git a/src/core/fieldMapping.ts b/src/core/fieldMapping.ts index dafe10a6..baa2d6f4 100644 --- a/src/core/fieldMapping.ts +++ b/src/core/fieldMapping.ts @@ -273,6 +273,24 @@ export function mapTaskFromFrontmatter( ); } + if (frontmatter[mapping.googleCalendarExceptionEventId] !== undefined) { + mapped.googleCalendarExceptionEventId = normalizeStringValue( + frontmatter[mapping.googleCalendarExceptionEventId] + ); + } + + if (frontmatter[mapping.googleCalendarExceptionOriginalScheduled] !== undefined) { + mapped.googleCalendarExceptionOriginalScheduled = normalizeStringValue( + frontmatter[mapping.googleCalendarExceptionOriginalScheduled] + ); + } + + if (frontmatter[mapping.googleCalendarMovedOriginalDates] !== undefined) { + mapped.googleCalendarMovedOriginalDates = normalizeStringArrayValue( + frontmatter[mapping.googleCalendarMovedOriginalDates] + ); + } + if (frontmatter[mapping.reminders] !== undefined) { mapped.reminders = normalizeReminders(frontmatter[mapping.reminders]); } @@ -413,6 +431,28 @@ export function mapTaskToFrontmatter( frontmatter[mapping.icsEventId] = taskData.icsEventId; } + if (taskData.googleCalendarEventId !== undefined) { + frontmatter[mapping.googleCalendarEventId] = taskData.googleCalendarEventId; + } + + if (taskData.googleCalendarExceptionEventId !== undefined) { + frontmatter[mapping.googleCalendarExceptionEventId] = + taskData.googleCalendarExceptionEventId; + } + + if (taskData.googleCalendarExceptionOriginalScheduled !== undefined) { + frontmatter[mapping.googleCalendarExceptionOriginalScheduled] = + taskData.googleCalendarExceptionOriginalScheduled; + } + + if ( + taskData.googleCalendarMovedOriginalDates !== undefined && + taskData.googleCalendarMovedOriginalDates.length > 0 + ) { + frontmatter[mapping.googleCalendarMovedOriginalDates] = + taskData.googleCalendarMovedOriginalDates; + } + if (taskData.reminders !== undefined && taskData.reminders.length > 0) { frontmatter[mapping.reminders] = taskData.reminders; } diff --git a/src/services/MdbaseSpecService.ts b/src/services/MdbaseSpecService.ts index 122357ec..9ff8e357 100644 --- a/src/services/MdbaseSpecService.ts +++ b/src/services/MdbaseSpecService.ts @@ -313,6 +313,12 @@ export class MdbaseSpecService { items: { type: "string" }, }); this.addRoleField(lines, "googleCalendarEventId", { type: "string" }); + this.addRoleField(lines, "googleCalendarExceptionEventId", { type: "string" }); + this.addRoleField(lines, "googleCalendarExceptionOriginalScheduled", { type: "date" }); + this.addRoleField(lines, "googleCalendarMovedOriginalDates", { + type: "list", + items: { type: "date" }, + }); // User-defined fields if (settings.userFields && settings.userFields.length > 0) { diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 999504af..228dcf33 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -10,6 +10,7 @@ import { } from "../types"; import { convertToGoogleRecurrence } from "../utils/rruleConverter"; import { stringifyUnknown } from "../utils/stringUtils"; +import { getDatePart } from "../utils/dateUtils"; import { TokenRefreshError } from "./errors"; import { GOOGLE_CALENDAR_CONSTANTS } from "./constants"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; @@ -116,6 +117,9 @@ export class TaskCalendarSyncService { /** Event IDs written during this session, used while Obsidian metadata catches up */ private taskEventIdCache: Map = new Map(); + /** Detached recurring exception event IDs written during this session */ + private taskExceptionEventIdCache: Map = new Map(); + constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; this.googleCalendarService = googleCalendarService; @@ -138,6 +142,7 @@ export class TaskCalendarSyncService { this.pendingTasks.clear(); this.pendingEventCreates.clear(); this.taskEventIdCache.clear(); + this.taskExceptionEventIdCache.clear(); } /** @@ -741,6 +746,16 @@ export class TaskCalendarSyncService { return task.googleCalendarEventId || this.taskEventIdCache.get(task.path); } + /** + * Get the detached recurring exception event ID from task frontmatter/cache. + */ + getTaskExceptionEventId(task: TaskInfo): string | undefined { + return ( + task.googleCalendarExceptionEventId || + this.taskExceptionEventIdCache.get(task.path) + ); + } + /** * Determines if a task should be synced as a Google Calendar recurring event. * Only scheduled-based recurring tasks are synced as recurring events. @@ -757,6 +772,35 @@ export class TaskCalendarSyncService { return anchor === "scheduled"; } + private hasStoredRecurringExceptionMetadata(task: TaskInfo): boolean { + return Boolean( + this.getTaskExceptionEventId(task) || + task.googleCalendarExceptionOriginalScheduled || + (task.googleCalendarMovedOriginalDates && + task.googleCalendarMovedOriginalDates.length > 0) + ); + } + + private getAdditionalRecurringExdates(task: TaskInfo): string[] { + const excludedDates = new Set(); + + for (const date of task.googleCalendarMovedOriginalDates || []) { + const normalized = getDatePart(date); + if (normalized) { + excludedDates.add(normalized); + } + } + + const pendingOriginal = getDatePart( + task.googleCalendarExceptionOriginalScheduled || "" + ); + if (pendingOriginal) { + excludedDates.add(pendingOriginal); + } + + return Array.from(excludedDates).sort(); + } + /** * Save the Google Calendar event ID to the task's frontmatter */ @@ -805,6 +849,96 @@ export class TaskCalendarSyncService { await this.removeEventIndexForTask(taskPath); } + private async saveTaskExceptionMetadata( + taskPath: string, + updates: Partial< + Pick< + TaskInfo, + | "googleCalendarExceptionEventId" + | "googleCalendarExceptionOriginalScheduled" + | "googleCalendarMovedOriginalDates" + > + > + ): Promise { + const file = this.plugin.app.vault.getAbstractFileByPath(taskPath); + if (!(file instanceof TFile)) { + tasknotesLogger.warn( + `Cannot save recurring exception metadata: file not found at ${taskPath}`, + { category: "provider", operation: "save-exception-metadata-file-not-found" } + ); + return; + } + + const exceptionEventIdField = this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionEventId" + ); + const exceptionOriginalField = this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionOriginalScheduled" + ); + const movedOriginalDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ); + + await processVaultFrontMatter(this.plugin.app, file, (frontmatter) => { + this.writeOptionalFrontmatterField( + frontmatter, + exceptionEventIdField, + updates.googleCalendarExceptionEventId, + "googleCalendarExceptionEventId" in updates + ); + this.writeOptionalFrontmatterField( + frontmatter, + exceptionOriginalField, + updates.googleCalendarExceptionOriginalScheduled, + "googleCalendarExceptionOriginalScheduled" in updates + ); + this.writeOptionalFrontmatterField( + frontmatter, + movedOriginalDatesField, + updates.googleCalendarMovedOriginalDates, + "googleCalendarMovedOriginalDates" in updates + ); + }); + + if ("googleCalendarExceptionEventId" in updates) { + if (updates.googleCalendarExceptionEventId) { + this.taskExceptionEventIdCache.set( + taskPath, + updates.googleCalendarExceptionEventId + ); + } else { + this.taskExceptionEventIdCache.delete(taskPath); + } + } + } + + private writeOptionalFrontmatterField( + frontmatter: Record, + fieldName: string, + value: unknown, + shouldWrite: boolean + ): void { + if (!shouldWrite) { + return; + } + + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + delete frontmatter[fieldName]; + return; + } + + frontmatter[fieldName] = value; + } + + private async clearTaskGoogleCalendarMetadata(taskPath: string): Promise { + await this.removeTaskEventId(taskPath); + await this.saveTaskExceptionMetadata(taskPath, { + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + googleCalendarMovedOriginalDates: undefined, + }); + } + /** * Apply the title template to generate the event title */ @@ -1330,6 +1464,7 @@ export class TaskCalendarSyncService { const recurrenceData = convertToGoogleRecurrence(task.recurrence, { completedInstances: task.complete_instances, skippedInstances: task.skipped_instances, + additionalExcludedDates: this.getAdditionalRecurringExdates(task), }); if (recurrenceData) { @@ -1394,6 +1529,147 @@ export class TaskCalendarSyncService { return eventId; } + private shouldCreateDetachedRecurringException(task: TaskInfo): boolean { + if (!this.shouldSyncAsRecurring(task)) { + return false; + } + + const movedScheduled = getDatePart(task.scheduled || ""); + const originalScheduled = getDatePart( + task.googleCalendarExceptionOriginalScheduled || "" + ); + + return Boolean(movedScheduled && originalScheduled && movedScheduled !== originalScheduled); + } + + private buildRecurringExceptionEvent(task: TaskInfo): CalendarEventPayload | null { + if (!task.scheduled) { + return null; + } + + const settings = this.plugin.settings.googleCalendarExport; + const startInfo = this.parseDateForEvent(task.scheduled); + + let start: { date?: string; dateTime?: string; timeZone?: string }; + if (settings.createAsAllDay && !startInfo.isAllDay) { + const localDate = new Date(task.scheduled); + start = { date: format(localDate, "yyyy-MM-dd") }; + } else if (startInfo.isAllDay) { + start = { date: startInfo.date }; + } else { + start = { dateTime: startInfo.dateTime, timeZone: startInfo.timeZone }; + } + + const adjustedStartInfo = { + ...startInfo, + isAllDay: settings.createAsAllDay || startInfo.isAllDay, + date: start.date, + dateTime: start.dateTime, + }; + const end = this.getEventEnd(adjustedStartInfo, task); + + const event: CalendarEventPayload = { + summary: this.applyTitleTemplate(task), + start, + end, + }; + + if (settings.includeDescription) { + event.description = this.buildEventDescription(task); + } + + if (settings.eventColorId) { + event.colorId = settings.eventColorId; + } + + const taskReminders = this.convertTaskRemindersToGoogleFormat( + task, + task.scheduled, + "scheduled" + ); + + if (taskReminders && taskReminders.length > 0) { + event.reminders = { + useDefault: false, + overrides: taskReminders, + }; + } + + return event; + } + + private async syncRecurringExceptionEvent( + task: TaskInfo, + targetCalendarId: string + ): Promise { + const hasActiveException = this.shouldCreateDetachedRecurringException(task); + const existingExceptionEventId = this.getTaskExceptionEventId(task); + + if (!hasActiveException) { + if (existingExceptionEventId) { + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + existingExceptionEventId + ); + if (!deleted) { + throw new Error( + `Failed to delete detached recurring exception event: ${task.path}` + ); + } + } + + await this.saveTaskExceptionMetadata(task.path, { + googleCalendarExceptionEventId: undefined, + googleCalendarExceptionOriginalScheduled: undefined, + }); + return; + } + + const eventData = this.buildRecurringExceptionEvent(task); + if (!eventData) { + return; + } + + if (existingExceptionEventId) { + try { + await this.withGoogleRateLimit(() => + this.googleCalendarService.updateEvent( + targetCalendarId, + existingExceptionEventId, + eventData + ) + ); + return; + } catch (error: unknown) { + if (getErrorStatus(error) !== 404) { + throw error; + } + await this.saveTaskExceptionMetadata(task.path, { + googleCalendarExceptionEventId: undefined, + }); + } + } + + const createdEvent = await this.withGoogleRateLimit(() => + this.googleCalendarService.createEvent(targetCalendarId, { + ...eventData, + isAllDay: !!eventData.start.date, + }) + ); + const prefix = `google-${targetCalendarId}-`; + const eventId = createdEvent.id.startsWith(prefix) + ? createdEvent.id.slice(prefix.length) + : createdEvent.id; + + await this.saveTaskExceptionMetadata(task.path, { + googleCalendarExceptionEventId: eventId, + googleCalendarExceptionOriginalScheduled: getDatePart( + task.googleCalendarExceptionOriginalScheduled || "" + ), + }); + } + /** * Sync a task to Google Calendar (create or update) */ @@ -1470,24 +1746,27 @@ export class TaskCalendarSyncService { await this.withGoogleRateLimit(() => this.googleCalendarService.updateEvent(targetCalendarId, eventId, eventData) ); - return true; - } - - const createPromise = this.createCalendarEventForTask( - task, - eventData, - targetCalendarId - ); - this.pendingEventCreates.set(task.path, createPromise); - try { - await createPromise; - } finally { - if (this.pendingEventCreates.get(task.path) === createPromise) { - this.pendingEventCreates.delete(task.path); + } else { + const createPromise = this.createCalendarEventForTask( + task, + eventData, + targetCalendarId + ); + this.pendingEventCreates.set(task.path, createPromise); + try { + await createPromise; + } finally { + if (this.pendingEventCreates.get(task.path) === createPromise) { + this.pendingEventCreates.delete(task.path); + } } } } + if (this.shouldSyncAsRecurring(task) || this.hasStoredRecurringExceptionMetadata(task)) { + await this.syncRecurringExceptionEvent(task, targetCalendarId); + } + return true; } catch (error: unknown) { // Check if it's a 404 error (event was deleted externally) @@ -1624,7 +1903,7 @@ export class TaskCalendarSyncService { // If task no longer meets sync criteria, delete the event if (!this.isTaskCalendarEligible(task)) { - if (existingEventId) { + if (existingEventId || this.hasStoredRecurringExceptionMetadata(task)) { const deleted = await this.deleteTaskFromCalendar(task); if (!deleted) { tasknotesLogger.warn(`Google Calendar deletion queued for ${task.path}`, { @@ -1735,6 +2014,7 @@ export class TaskCalendarSyncService { const recurrenceData = convertToGoogleRecurrence(task.recurrence, { completedInstances: task.complete_instances, skippedInstances: task.skipped_instances, + additionalExcludedDates: this.getAdditionalRecurringExdates(task), }); if (recurrenceData) { @@ -1743,6 +2023,7 @@ export class TaskCalendarSyncService { recurrence: recurrenceData.recurrence, }) ); + await this.syncRecurringExceptionEvent(task, settings.targetCalendarId); } } catch (error: unknown) { if (getErrorStatus(error) === 404) { @@ -1771,7 +2052,8 @@ export class TaskCalendarSyncService { const settings = this.plugin.settings.googleCalendarExport; const existingEventId = this.getTaskEventId(task); - if (!existingEventId) { + const exceptionEventId = this.getTaskExceptionEventId(task); + if (!existingEventId && !this.hasStoredRecurringExceptionMetadata(task)) { return true; } @@ -1788,17 +2070,23 @@ export class TaskCalendarSyncService { return false; } - const deleted = await this.deleteOrQueueCalendarEvent( - task.path, - targetCalendarId, - existingEventId - ); - if (!deleted) { - return false; + for (const eventId of [existingEventId, exceptionEventId]) { + if (!eventId) { + continue; + } + + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + eventId + ); + if (!deleted) { + return false; + } } - // Only remove the event ID when deletion succeeded or the event is already gone - await this.removeTaskEventId(task.path); + // Only remove metadata when deletion succeeded or events are already gone. + await this.clearTaskGoogleCalendarMetadata(task.path); return true; } @@ -1930,11 +2218,10 @@ export class TaskCalendarSyncService { let unlinkedCount = 0; for (const task of tasks) { - if (!task.googleCalendarEventId) { + if (!task.googleCalendarEventId && !this.hasStoredRecurringExceptionMetadata(task)) { continue; } - const eventId = task.googleCalendarEventId; if (deleteEvents) { const targetCalendarId = settings.targetCalendarId; if (!targetCalendarId) { @@ -1945,22 +2232,36 @@ export class TaskCalendarSyncService { continue; } - const deleted = await this.deleteOrQueueCalendarEvent( - task.path, - targetCalendarId, - eventId - ); - if (!deleted) { + let deletionComplete = true; + for (const eventId of [ + task.googleCalendarEventId, + this.getTaskExceptionEventId(task), + ]) { + if (!eventId) { + continue; + } + + const deleted = await this.deleteOrQueueCalendarEvent( + task.path, + targetCalendarId, + eventId + ); + if (!deleted) { + deletionComplete = false; + } + } + + if (!deletionComplete) { tasknotesLogger.warn( - `[TaskCalendarSync] Event deletion queued; keeping link for ${task.path}`, + `[TaskCalendarSync] Event deletion queued; keeping links for ${task.path}`, { category: "provider", operation: "event-deletion-queued-keeping-link" } ); continue; } } - // Remove the event ID from task frontmatter - await this.removeTaskEventId(task.path); + // Remove Google Calendar metadata from task frontmatter. + await this.clearTaskGoogleCalendarMetadata(task.path); unlinkedCount++; } diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 9aff549a..542ec5d8 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -55,6 +55,10 @@ import { buildRecurringTaskSkippedPlan, getRecurringTaskActionDate, } from "./task-service/taskRecurringPlanning"; +import { + applyGoogleCalendarRecurringExceptionCleanup, + applyGoogleCalendarRecurringExceptionForScheduledChange, +} from "./task-service/googleCalendarRecurringExceptions"; import { buildBlockedByTaskUpdate, buildBlockingRelationshipPathChanges, @@ -92,19 +96,41 @@ export class TaskService { }); } - private hasGoogleCalendarLink(task: TaskInfo): boolean { - return !!task.googleCalendarEventId; + private hasGoogleCalendarLinks(task: TaskInfo): boolean { + return Boolean(task.googleCalendarEventId || task.googleCalendarExceptionEventId); } private createArchiveCalendarDeletionTask(task: TaskInfo, updatedTask: TaskInfo): TaskInfo { return { ...updatedTask, googleCalendarEventId: task.googleCalendarEventId, + googleCalendarExceptionEventId: task.googleCalendarExceptionEventId, + googleCalendarExceptionOriginalScheduled: + task.googleCalendarExceptionOriginalScheduled, + googleCalendarMovedOriginalDates: task.googleCalendarMovedOriginalDates + ? [...task.googleCalendarMovedOriginalDates] + : undefined, }; } private clearGoogleCalendarMetadata(task: TaskInfo): void { task.googleCalendarEventId = undefined; + task.googleCalendarExceptionEventId = undefined; + task.googleCalendarExceptionOriginalScheduled = undefined; + task.googleCalendarMovedOriginalDates = undefined; + } + + private writeOptionalFrontmatterField( + frontmatter: Record, + fieldName: string, + value: unknown + ): void { + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + delete frontmatter[fieldName]; + return; + } + + frontmatter[fieldName] = value; } private async deleteArchivedTaskFromCalendar(task: TaskInfo): Promise { @@ -370,6 +396,17 @@ export class TaskService { isCompletedStatus: (status) => this.plugin.statusManager.isCompletedStatus(status), }); + if (property === "scheduled") { + applyGoogleCalendarRecurringExceptionForScheduledChange( + freshTask, + updatePlan.normalizedValue, + updatePlan.updatedTask + ); + } + if (property === "recurrence" || property === "recurrence_anchor") { + applyGoogleCalendarRecurringExceptionCleanup(updatePlan.updatedTask); + } + // Step 2: Persist to file await this.plugin.app.fileManager.processFrontMatter(file, (frontmatter) => { // Use field mapper to get the correct frontmatter property name @@ -394,6 +431,19 @@ export class TaskService { this.plugin.statusManager.isCompletedStatus(status), currentDateString: getCurrentDateString(), }); + + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField( + "googleCalendarExceptionOriginalScheduled" + ), + updatePlan.updatedTask.googleCalendarExceptionOriginalScheduled + ); + this.writeOptionalFrontmatterField( + frontmatter, + this.plugin.fieldMapper.toUserField("googleCalendarMovedOriginalDates"), + updatePlan.updatedTask.googleCalendarMovedOriginalDates + ); }); // Step 3: Run post-write side effects (cache, events, webhooks, calendar, auto-archive) @@ -551,7 +601,7 @@ export class TaskService { let archiveCalendarCleanupComplete = true; if (this.plugin.taskCalendarSyncService?.isEnabled() && updatedTask.archived) { - if (this.hasGoogleCalendarLink(task)) { + if (this.hasGoogleCalendarLinks(task)) { const archiveCalendarTask = this.createArchiveCalendarDeletionTask( task, updatedTask @@ -622,7 +672,7 @@ export class TaskService { error: error, }); }); - } else if (!archiveCalendarCleanupComplete && this.hasGoogleCalendarLink(updatedTask)) { + } else if (!archiveCalendarCleanupComplete && this.hasGoogleCalendarLinks(updatedTask)) { tasknotesLogger.warn( "Archived task still has Google Calendar links and will need retry cleanup:", { @@ -894,11 +944,12 @@ export class TaskService { } // Delete from Google Calendar first (before file deletion, so we have the event ID) - if (this.plugin.taskCalendarSyncService && task.googleCalendarEventId) { + if (this.plugin.taskCalendarSyncService && this.hasGoogleCalendarLinks(task)) { try { await this.plugin.taskCalendarSyncService.deleteTaskFromCalendarByPath( task.path, - task.googleCalendarEventId + task.googleCalendarEventId, + task.googleCalendarExceptionEventId ); } catch (error) { tasknotesLogger.warn("Failed to delete task from Google Calendar:", { @@ -996,6 +1047,11 @@ export class TaskService { const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); const recurrenceField = this.plugin.fieldMapper.toUserField("recurrence"); + const googleCalendarExceptionOriginalScheduledField = + this.plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled"); + const googleCalendarMovedOriginalDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ); applyRecurringTaskCompleteFrontmatterChange({ frontmatter, @@ -1005,6 +1061,8 @@ export class TaskService { scheduledField, dueField, recurrenceField, + googleCalendarExceptionOriginalScheduledField, + googleCalendarMovedOriginalDatesField, plan: recurringPlan, }); }); @@ -1138,6 +1196,11 @@ export class TaskService { const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); const scheduledField = this.plugin.fieldMapper.toUserField("scheduled"); const dueField = this.plugin.fieldMapper.toUserField("due"); + const googleCalendarExceptionOriginalScheduledField = + this.plugin.fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled"); + const googleCalendarMovedOriginalDatesField = this.plugin.fieldMapper.toUserField( + "googleCalendarMovedOriginalDates" + ); applyRecurringTaskSkippedFrontmatterChange({ frontmatter, @@ -1146,6 +1209,8 @@ export class TaskService { dateModifiedField, scheduledField, dueField, + googleCalendarExceptionOriginalScheduledField, + googleCalendarMovedOriginalDatesField, plan: recurringPlan, }); }); diff --git a/src/services/task-service/googleCalendarRecurringExceptions.ts b/src/services/task-service/googleCalendarRecurringExceptions.ts new file mode 100644 index 00000000..bcc1102b --- /dev/null +++ b/src/services/task-service/googleCalendarRecurringExceptions.ts @@ -0,0 +1,85 @@ +import type { TaskInfo } from "../../types"; +import { getDatePart } from "../../utils/dateUtils"; + +export function shouldTrackGoogleCalendarRecurringException(task: TaskInfo): boolean { + const hasGoogleCalendarProjection = Boolean( + task.googleCalendarEventId || + task.googleCalendarExceptionEventId || + task.googleCalendarExceptionOriginalScheduled || + (task.googleCalendarMovedOriginalDates && + task.googleCalendarMovedOriginalDates.length > 0) + ); + + return ( + hasGoogleCalendarProjection && + Boolean(task.recurrence) && + (task.recurrence_anchor || "scheduled") === "scheduled" + ); +} + +export function getRecurringExceptionOriginalDate(task: TaskInfo): string | undefined { + const original = task.googleCalendarExceptionOriginalScheduled || task.scheduled || task.due; + const normalized = getDatePart(original || ""); + return normalized || undefined; +} + +export function applyGoogleCalendarRecurringExceptionForScheduledChange( + originalTask: TaskInfo, + nextScheduledValue: unknown, + updatedTask: TaskInfo +): void { + if (!shouldTrackGoogleCalendarRecurringException(originalTask)) { + return; + } + + if (nextScheduledValue === originalTask.scheduled) { + return; + } + + const originalDate = getRecurringExceptionOriginalDate(originalTask); + if (!originalDate) { + return; + } + + const nextScheduled = + typeof nextScheduledValue === "string" ? getDatePart(nextScheduledValue) : ""; + + updatedTask.googleCalendarExceptionOriginalScheduled = + nextScheduled && nextScheduled !== originalDate ? originalDate : undefined; +} + +export function applyGoogleCalendarRecurringExceptionCleanup( + updatedTask: TaskInfo +): void { + if (shouldTrackGoogleCalendarRecurringException(updatedTask)) { + return; + } + + updatedTask.googleCalendarExceptionOriginalScheduled = undefined; + updatedTask.googleCalendarMovedOriginalDates = undefined; +} + +export function resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + originalTask: TaskInfo, + actionDate: string, + updatedTask: TaskInfo +): void { + if (!shouldTrackGoogleCalendarRecurringException(originalTask)) { + return; + } + + const originalDate = getDatePart(originalTask.googleCalendarExceptionOriginalScheduled || ""); + if (!originalDate) { + return; + } + + const currentScheduled = getDatePart(originalTask.scheduled || ""); + if (!currentScheduled || actionDate !== currentScheduled) { + return; + } + + updatedTask.googleCalendarMovedOriginalDates = Array.from( + new Set([...(originalTask.googleCalendarMovedOriginalDates || []), originalDate]) + ).sort(); + updatedTask.googleCalendarExceptionOriginalScheduled = undefined; +} diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index 508f54a7..85e2efa7 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -11,6 +11,10 @@ import { getTodayLocal, parseDateToUTC, } from "../../utils/dateUtils"; +import { + applyGoogleCalendarRecurringExceptionCleanup, + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction, +} from "./googleCalendarRecurringExceptions"; export interface BuildRecurringTaskCompletePlanInput { freshTask: TaskInfo; @@ -51,6 +55,8 @@ export interface ApplyRecurringTaskCompleteFrontmatterInput { scheduledField: string; dueField: string; recurrenceField: string; + googleCalendarExceptionOriginalScheduledField: string; + googleCalendarMovedOriginalDatesField: string; plan: RecurringTaskCompletePlan; } @@ -61,6 +67,8 @@ export interface ApplyRecurringTaskSkippedFrontmatterInput { dateModifiedField: string; scheduledField: string; dueField: string; + googleCalendarExceptionOriginalScheduledField: string; + googleCalendarMovedOriginalDatesField: string; plan: RecurringTaskSkippedPlan; } @@ -143,6 +151,12 @@ export function buildRecurringTaskCompletePlan({ if (nextDates.due) { updatedTask.due = nextDates.due; } + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + freshTask, + dateStr, + updatedTask + ); + applyGoogleCalendarRecurringExceptionCleanup(updatedTask); return { updatedTask, @@ -162,6 +176,8 @@ export function applyRecurringTaskCompleteFrontmatterChange({ scheduledField, dueField, recurrenceField, + googleCalendarExceptionOriginalScheduledField, + googleCalendarMovedOriginalDatesField, plan, }: ApplyRecurringTaskCompleteFrontmatterInput): void { if (!frontmatter[completeInstancesField]) { @@ -195,6 +211,17 @@ export function applyRecurringTaskCompleteFrontmatterChange({ frontmatter[dueField] = plan.updatedTask.due; } + writeOptionalFrontmatterField( + frontmatter, + googleCalendarExceptionOriginalScheduledField, + plan.updatedTask.googleCalendarExceptionOriginalScheduled + ); + writeOptionalFrontmatterField( + frontmatter, + googleCalendarMovedOriginalDatesField, + plan.updatedTask.googleCalendarMovedOriginalDates + ); + frontmatter[dateModifiedField] = plan.dateModified; } @@ -238,6 +265,12 @@ export function buildRecurringTaskSkippedPlan({ if (nextDates.due) { updatedTask.due = nextDates.due; } + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + freshTask, + dateStr, + updatedTask + ); + applyGoogleCalendarRecurringExceptionCleanup(updatedTask); return { updatedTask, @@ -255,6 +288,8 @@ export function applyRecurringTaskSkippedFrontmatterChange({ dateModifiedField, scheduledField, dueField, + googleCalendarExceptionOriginalScheduledField, + googleCalendarMovedOriginalDatesField, plan, }: ApplyRecurringTaskSkippedFrontmatterInput): void { if (!frontmatter[skippedField]) { @@ -274,5 +309,29 @@ export function applyRecurringTaskSkippedFrontmatterChange({ frontmatter[dueField] = plan.updatedTask.due; } + writeOptionalFrontmatterField( + frontmatter, + googleCalendarExceptionOriginalScheduledField, + plan.updatedTask.googleCalendarExceptionOriginalScheduled + ); + writeOptionalFrontmatterField( + frontmatter, + googleCalendarMovedOriginalDatesField, + plan.updatedTask.googleCalendarMovedOriginalDates + ); + frontmatter[dateModifiedField] = plan.dateModified; } + +function writeOptionalFrontmatterField( + frontmatter: Record, + fieldName: string, + value: unknown +): void { + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + delete frontmatter[fieldName]; + return; + } + + frontmatter[fieldName] = value; +} diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index b1a3056b..85a287a5 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -3,6 +3,10 @@ import { addDTSTARTToRecurrenceRule, updateToNextScheduledOccurrence, } from "../../core/recurrence"; +import { + applyGoogleCalendarRecurringExceptionCleanup, + applyGoogleCalendarRecurringExceptionForScheduledChange, +} from "./googleCalendarRecurringExceptions"; import { applyPropertyTaskIdentifier, getFrontmatterTags, @@ -161,6 +165,23 @@ export function buildTaskUpdateRecurrenceUpdates({ } } + if (Object.prototype.hasOwnProperty.call(updates, "scheduled")) { + const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; + applyGoogleCalendarRecurringExceptionForScheduledChange( + originalTask, + updates.scheduled, + nextTask + ); + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = + nextTask.googleCalendarExceptionOriginalScheduled; + } + + const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; + applyGoogleCalendarRecurringExceptionCleanup(nextTask); + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = + nextTask.googleCalendarExceptionOriginalScheduled; + recurrenceUpdates.googleCalendarMovedOriginalDates = nextTask.googleCalendarMovedOriginalDates; + return recurrenceUpdates; } @@ -212,7 +233,7 @@ export function applyTaskUpdateFrontmatterChange({ }); } - removeUnsetMappedFields(frontmatter, updates, fieldMapper); + removeUnsetMappedFields(frontmatter, { ...updates, ...recurrenceUpdates }, fieldMapper); if (storeTitleInFilename) { delete frontmatter[fieldMapper.toUserField("title")]; @@ -300,6 +321,22 @@ function removeUnsetMappedFields( ) { delete frontmatter[fieldMapper.toUserField("recurrence")]; } + if ( + Object.prototype.hasOwnProperty.call( + updates, + "googleCalendarExceptionOriginalScheduled" + ) && + updates.googleCalendarExceptionOriginalScheduled === undefined + ) { + delete frontmatter[fieldMapper.toUserField("googleCalendarExceptionOriginalScheduled")]; + } + if ( + Object.prototype.hasOwnProperty.call(updates, "googleCalendarMovedOriginalDates") && + (!Array.isArray(updates.googleCalendarMovedOriginalDates) || + updates.googleCalendarMovedOriginalDates.length === 0) + ) { + delete frontmatter[fieldMapper.toUserField("googleCalendarMovedOriginalDates")]; + } if ( Object.prototype.hasOwnProperty.call(updates, "blockedBy") && updates.blockedBy === undefined diff --git a/src/types.ts b/src/types.ts index 9538eea1..2638063e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -474,6 +474,9 @@ export interface TaskInfo { dateModified?: string; // Last modification date (ISO timestamp) icsEventId?: string[]; // Links to ICS calendar event IDs googleCalendarEventId?: string; // Google Calendar event ID for sync + googleCalendarExceptionEventId?: string; // Detached Google Calendar event ID for a moved recurring occurrence + googleCalendarExceptionOriginalScheduled?: string; // Original recurring date replaced by the current moved occurrence + googleCalendarMovedOriginalDates?: string[]; // Original recurring dates excluded after moved occurrences are resolved reminders?: Reminder[]; // Task reminders customProperties?: Record; // Custom properties from Bases or other sources basesData?: unknown; // Raw Bases data for formula computation (internal use) @@ -695,6 +698,9 @@ export interface FieldMapping { icsEventId: string; // For linking to ICS calendar events (stored as array in frontmatter) icsEventTag: string; // Tag used for ICS event-related content googleCalendarEventId: string; // For Google Calendar sync (stores event ID) + googleCalendarExceptionEventId: string; // Detached Google Calendar event ID for moved recurring occurrences + googleCalendarExceptionOriginalScheduled: string; // Original recurring date replaced by the current moved occurrence + googleCalendarMovedOriginalDates: string; // Historical moved recurring dates excluded from the master event reminders: string; // For task reminders sortOrder: string; // Numeric ordering within column (lower = higher) } diff --git a/src/utils/rruleConverter.ts b/src/utils/rruleConverter.ts index cf189cad..eec54b75 100644 --- a/src/utils/rruleConverter.ts +++ b/src/utils/rruleConverter.ts @@ -26,6 +26,8 @@ export interface ConversionOptions { completedInstances?: string[]; /** Skipped instances to exclude via EXDATE (YYYY-MM-DD format) */ skippedInstances?: string[]; + /** Additional dates to exclude via EXDATE (YYYY-MM-DD format) */ + additionalExcludedDates?: string[]; } /** @@ -75,6 +77,7 @@ export function convertToGoogleRecurrence( const exdates = formatExdates([ ...(options?.completedInstances || []), ...(options?.skippedInstances || []), + ...(options?.additionalExcludedDates || []), ]); recurrence.push(...exdates); diff --git a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts index 724c9d36..3ebc83c3 100644 --- a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts +++ b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts @@ -1,41 +1,287 @@ -/** - * Reproduction tests for issue #1696. - * - * Reported behavior: - * - When a recurring task's next occurrence is rescheduled (scheduled date - * changed without modifying the recurrence pattern), the Google Calendar - * export uses DTSTART from the recurrence rule instead of the rescheduled - * scheduled date. - */ - -describe('Issue #1696: Google Calendar export ignores rescheduled next occurrence', () => { - it.skip('reproduces issue #1696 - event start uses DTSTART, not rescheduled scheduled date', () => { - // Simulate the data scenario from the bug report: - // - scheduled: 2026-03-16 (rescheduled next occurrence) - // - recurrence: DTSTART:20260313;FREQ=WEEKLY;INTERVAL=4;BYDAY=FR - // - recurrence_anchor: scheduled +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { TFile } from "obsidian"; +import { TaskCalendarSyncService } from "../../../src/services/TaskCalendarSyncService"; +import { TaskService } from "../../../src/services/TaskService"; +import { TaskInfo } from "../../../src/types"; + +jest.mock("obsidian", () => ({ + Notice: jest.fn(), + TFile: class MockTFile { + path: string; + + constructor(path = "") { + this.path = path; + } + }, +})); + +function createGoogleSyncPlugin(frontmatter: Record = {}) { + const pluginData: Record = {}; + + return { + settings: { + storeTitleInFilename: false, + taskIdentificationMethod: "property", + taskPropertyName: "type", + taskPropertyValue: "task", + maintainDueDateOffsetInRecurring: false, + resetCheckboxesOnRecurrence: false, + googleCalendarExport: { + enabled: true, + targetCalendarId: "primary", + syncOnTaskCreate: true, + syncOnTaskUpdate: true, + syncOnTaskComplete: true, + syncOnTaskDelete: true, + eventTitleTemplate: "{{title}}", + includeDescription: false, + eventColorId: null, + syncTrigger: "scheduled", + createAsAllDay: false, + defaultEventDuration: 60, + includeObsidianLink: false, + defaultReminderMinutes: null, + }, + }, + app: { + vault: { + getAbstractFileByPath: jest + .fn() + .mockImplementation((path: string) => new TFile(path)), + getName: jest.fn().mockReturnValue("Example Vault"), + read: jest.fn().mockResolvedValue(""), + modify: jest.fn().mockResolvedValue(undefined), + }, + fileManager: { + processFrontMatter: jest + .fn() + .mockImplementation( + async (_file: TFile, fn: (fm: Record) => void) => { + fn(frontmatter); + } + ), + }, + }, + fieldMapper: { + toUserField: jest.fn((field: string) => field), + mapToFrontmatter: jest.fn((taskData: Record) => { + const mapped: Record = {}; + for (const [key, value] of Object.entries(taskData)) { + if (key === "details" || value === undefined) { + continue; + } + mapped[key] = value; + } + return mapped; + }), + }, + priorityManager: { + getPriorityConfig: jest.fn().mockReturnValue(null), + }, + statusManager: { + getStatusConfig: jest.fn().mockReturnValue(null), + isCompletedStatus: jest.fn().mockImplementation((status: string) => status === "done"), + }, + i18n: { + translate: jest.fn((key: string) => key), + }, + cacheManager: { + getTaskInfo: jest.fn().mockResolvedValue(null), + getAllTasks: jest.fn().mockResolvedValue([]), + getBlockedTaskPaths: jest.fn().mockReturnValue([]), + updateTaskInfoInCache: jest.fn(), + waitForFreshTaskData: jest.fn().mockResolvedValue(undefined), + clearCacheEntry: jest.fn(), + }, + emitter: { + trigger: jest.fn(), + }, + loadData: jest.fn().mockImplementation(async () => pluginData), + saveData: jest.fn().mockImplementation(async (data: Record) => { + for (const key of Object.keys(pluginData)) { + delete pluginData[key]; + } + Object.assign(pluginData, data); + }), + } as any; +} + +describe("Issue #1696: Google Calendar recurring reschedule sync", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("stores the original recurring date when a scheduled occurrence is moved", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); const task = { - scheduled: '2026-03-16', - recurrence: 'DTSTART:20260313;FREQ=WEEKLY;INTERVAL=4;BYDAY=FR', - recurrence_anchor: 'scheduled', + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-13", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + } as TaskInfo; + + const updatedTask = await taskService.updateTask(task, { + scheduled: "2026-04-15", + }); + + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + }); + + it("adds moved original dates to the recurring master's EXDATE list", () => { + const plugin = createGoogleSyncPlugin(); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn(), + updateEvent: jest.fn(), + deleteEvent: jest.fn(), }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-05-11", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: ["2026-04-15"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarMovedOriginalDates: ["2026-04-13"], + } as TaskInfo; + + const event = (syncService as any).taskToCalendarEvent(task); + + expect(event?.recurrence).toContain("EXDATE;VALUE=DATE:20260413"); + expect(event?.recurrence).toContain("EXDATE;VALUE=DATE:20260415"); + }); - // Simulate what convertToGoogleRecurrence returns - const recurrenceData = { - recurrence: ['RRULE:FREQ=WEEKLY;INTERVAL=4;BYDAY=FR'], - dtstart: '2026-03-13', // From DTSTART in recurrence rule - hasTime: false, - time: null, + it("creates a detached exception event for a pending moved occurrence", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest + .fn() + .mockResolvedValue({ id: "google-primary-detached-exception-id" }), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-15", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + } as TaskInfo; + + await syncService.syncTaskToCalendar(task); + + expect(googleCalendarService.updateEvent).toHaveBeenCalledWith( + "primary", + "master-event-id", + expect.objectContaining({ + recurrence: expect.arrayContaining(["EXDATE;VALUE=DATE:20260413"]), + }) + ); + expect(googleCalendarService.createEvent).toHaveBeenCalledWith( + "primary", + expect.objectContaining({ + summary: "Collect medication", + start: { date: "2026-04-15" }, + end: { date: "2026-04-16" }, + isAllDay: true, + }) + ); + expect(frontmatter.googleCalendarExceptionEventId).toBe("detached-exception-id"); + }); + + it("deletes stale detached exception events once a moved occurrence is resolved", async () => { + const frontmatter: Record = { + googleCalendarExceptionEventId: "detached-exception-id", + }; + const plugin = createGoogleSyncPlugin(frontmatter); + const googleCalendarService = { + getAvailableCalendars: jest.fn().mockReturnValue([{ id: "primary", name: "Primary" }]), + createEvent: jest.fn(), + updateEvent: jest.fn().mockResolvedValue(undefined), + deleteEvent: jest.fn().mockResolvedValue(undefined), + }; + const syncService = new TaskCalendarSyncService(plugin, googleCalendarService as any); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-05-11", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: ["2026-04-15"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionEventId: "detached-exception-id", + googleCalendarMovedOriginalDates: ["2026-04-13"], + } as TaskInfo; + + await syncService.syncTaskToCalendar(task); + + expect(googleCalendarService.deleteEvent).toHaveBeenCalledWith( + "primary", + "detached-exception-id" + ); + expect(frontmatter.googleCalendarExceptionEventId).toBeUndefined(); + }); + + it("preserves the original series date when a moved occurrence is completed", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-15", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionEventId: "detached-exception-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + } as TaskInfo; + + plugin.cacheManager.getTaskInfo.mockResolvedValue(task); - // Simulate buildCalendarEvent behavior (lines 634-638): - // The event start is overridden with recurrenceData.dtstart - const eventStart = recurrenceData.dtstart; // '2026-03-13' + const updatedTask = await taskService.toggleRecurringTaskComplete(task); - // BUG: The event should use the rescheduled date (2026-03-16) - // but instead uses DTSTART from the recurrence rule (2026-03-13) - expect(eventStart).toBe('2026-03-13'); // Documents the bug - expect(eventStart).not.toBe(task.scheduled); // The scheduled date is ignored + expect(updatedTask.complete_instances).toContain("2026-04-15"); + expect(updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(updatedTask.googleCalendarExceptionEventId).toBe("detached-exception-id"); + expect(frontmatter.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); }); });