diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 2c0c0918..c9cd0e69 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -2302,6 +2302,7 @@ export class KanbanView extends BasesViewBase { const menu = new RecurrenceContextMenu({ currentValue: typeof task.recurrence === "string" ? task.recurrence : undefined, currentAnchor: task.recurrence_anchor || "scheduled", + scheduledDate: task.scheduled, onSelect: async (newRecurrence: string | null, anchor?: "scheduled" | "completion") => { try { await this.plugin.updateTaskProperty( diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 73f1cd0a..ca583cbb 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -1101,6 +1101,7 @@ export class TaskListView extends BasesViewBase { const menu = new RecurrenceContextMenu({ currentValue: typeof task.recurrence === "string" ? task.recurrence : undefined, currentAnchor: task.recurrence_anchor || 'scheduled', + scheduledDate: task.scheduled, onSelect: async (newRecurrence: string | null, anchor?: 'scheduled' | 'completion') => { try { await this.plugin.updateTaskProperty( diff --git a/src/components/RecurrenceContextMenu.ts b/src/components/RecurrenceContextMenu.ts index 84ef45c2..e31ff118 100644 --- a/src/components/RecurrenceContextMenu.ts +++ b/src/components/RecurrenceContextMenu.ts @@ -13,6 +13,7 @@ export interface RecurrenceOption { export interface RecurrenceContextMenuOptions { currentValue?: string; currentAnchor?: 'scheduled' | 'completion'; + scheduledDate?: string; // Task's scheduled date to extract time from onSelect: (value: string | null, anchor?: 'scheduled' | 'completion') => void; app: App; plugin: TaskNotesPlugin; @@ -115,7 +116,7 @@ export class RecurrenceContextMenu { // Format today as DTSTART, preserving existing time if available let todayDTSTART = this.formatDateForDTSTART(today); - // If there's an existing recurrence with time, preserve the time component + // Priority 1: Preserve time from existing recurrence if (this.options.currentValue) { const existingDtstartMatch = this.options.currentValue.match( /DTSTART:(\d{8}(?:T\d{6}Z?)?)/ @@ -126,6 +127,16 @@ export class RecurrenceContextMenu { todayDTSTART = `${todayDTSTART}T${existingTime}`; } } + // Priority 2: If no existing recurrence time, check task's scheduled date + else if (this.options.scheduledDate && this.options.scheduledDate.includes("T")) { + // Extract time from scheduled date (format: YYYY-MM-DDTHH:mm or similar) + const timeMatch = this.options.scheduledDate.match(/T(\d{2}):(\d{2})/); + if (timeMatch) { + const hours = timeMatch[1]; + const minutes = timeMatch[2]; + todayDTSTART = `${todayDTSTART}T${hours}${minutes}00Z`; + } + } // Daily options.push({ @@ -242,6 +253,7 @@ export class RecurrenceContextMenu { this.options.app, this.options.currentValue || "", this.options.currentAnchor || 'scheduled', + this.options.scheduledDate, (result, anchor) => { if (result) { this.options.onSelect(result, anchor); @@ -257,6 +269,7 @@ export class RecurrenceContextMenu { class CustomRecurrenceModal extends Modal { private currentValue: string; + private scheduledDate?: string; private onSubmit: (result: string | null, anchor?: 'scheduled' | 'completion') => void; private frequency = "DAILY"; private interval = 1; @@ -271,10 +284,11 @@ class CustomRecurrenceModal extends Modal { private dtstartTime = ""; private recurrenceAnchor: 'scheduled' | 'completion' = 'scheduled'; // NEW: Recurrence anchor - constructor(app: App, currentValue: string, currentAnchor: 'scheduled' | 'completion', onSubmit: (result: string | null, anchor?: 'scheduled' | 'completion') => void) { + constructor(app: App, currentValue: string, currentAnchor: 'scheduled' | 'completion', scheduledDate: string | undefined, onSubmit: (result: string | null, anchor?: 'scheduled' | 'completion') => void) { super(app); this.currentValue = currentValue; this.recurrenceAnchor = currentAnchor; + this.scheduledDate = scheduledDate; this.onSubmit = onSubmit; this.parseCurrentValue(); } @@ -283,6 +297,14 @@ class CustomRecurrenceModal extends Modal { if (!this.currentValue) { // Set default DTSTART to today this.dtstart = this.formatTodayForInput(); + + // Check if we should preserve time from scheduled date + if (this.scheduledDate && this.scheduledDate.includes("T")) { + const timeMatch = this.scheduledDate.match(/T(\d{2}):(\d{2})/); + if (timeMatch) { + this.dtstartTime = `${timeMatch[1]}:${timeMatch[2]}`; + } + } return; } diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 30745d2b..0f43b54d 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1365,6 +1365,7 @@ export class TaskContextMenu { const recurrenceMenu = new RecurrenceContextMenu({ currentValue: typeof currentValue === "string" ? currentValue : undefined, currentAnchor: this.options.task.recurrence_anchor || 'scheduled', + scheduledDate: this.options.task.scheduled, onSelect: onSelect, app: plugin.app, plugin: plugin, diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index ecc85887..14bc0149 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -1350,6 +1350,7 @@ export abstract class TaskModal extends Modal { const menu = new RecurrenceContextMenu({ currentValue: this.recurrenceRule, currentAnchor: this.recurrenceAnchor, + scheduledDate: this.scheduledDate, onSelect: (value, anchor) => { this.recurrenceRule = value || ""; if (anchor !== undefined) { diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 87177041..597d0b8a 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -25,6 +25,9 @@ export class TaskCalendarSyncService { /** In-flight sync operations to prevent concurrent syncs for the same task */ private inFlightSyncs: Map> = new Map(); + /** Track previous task state for detecting recurrence removal */ + private previousTaskState: Map = new Map(); + constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) { this.plugin = plugin; this.googleCalendarService = googleCalendarService; @@ -38,6 +41,7 @@ export class TaskCalendarSyncService { clearTimeout(timer); } this.pendingSyncs.clear(); + this.previousTaskState.clear(); } /** @@ -335,7 +339,7 @@ export class TaskCalendarSyncService { /** * Convert a task to a Google Calendar event payload */ - private taskToCalendarEvent(task: TaskInfo): { + private taskToCalendarEvent(task: TaskInfo, clearRecurrence?: boolean): { summary: string; description?: string; start: { date?: string; dateTime?: string; timeZone?: string }; @@ -445,6 +449,10 @@ export class TaskCalendarSyncService { } } } + } else if (clearRecurrence) { + // Explicitly clear recurrence when it was removed from the task + // Google Calendar API requires an empty array to remove recurrence + event.recurrence = []; } return event; @@ -453,7 +461,7 @@ export class TaskCalendarSyncService { /** * Sync a task to Google Calendar (create or update) */ - async syncTaskToCalendar(task: TaskInfo): Promise { + async syncTaskToCalendar(task: TaskInfo, previous?: TaskInfo): Promise { if (!this.shouldSyncTask(task)) { return; } @@ -462,7 +470,10 @@ export class TaskCalendarSyncService { const existingEventId = this.getTaskEventId(task); try { - const eventData = this.taskToCalendarEvent(task); + // Check if recurrence was removed (previous had recurrence, current doesn't) + const clearRecurrence = !!(previous?.recurrence && !task.recurrence); + + const eventData = this.taskToCalendarEvent(task, clearRecurrence); if (!eventData) { console.warn("[TaskCalendarSync] Could not convert task to event:", task.path); return; @@ -503,7 +514,7 @@ export class TaskCalendarSyncService { // Retry without the link - refetch task to get updated version const updatedTask = await this.plugin.cacheManager.getTaskInfo(task.path); if (updatedTask) { - return this.syncTaskToCalendar(updatedTask); + return this.syncTaskToCalendar(updatedTask, previous); } } @@ -523,6 +534,11 @@ export class TaskCalendarSyncService { const taskPath = task.path; + // Store previous state for recurrence change detection + if (previous) { + this.previousTaskState.set(taskPath, previous); + } + // Cancel any pending debounced sync for this task const existingTimer = this.pendingSyncs.get(taskPath); if (existingTimer) { @@ -575,11 +591,19 @@ export class TaskCalendarSyncService { if (existingEventId) { await this.deleteTaskFromCalendar(task); } + // Clean up previous state + this.previousTaskState.delete(task.path); return; } + // Get previous state for recurrence change detection + const previousState = this.previousTaskState.get(task.path); + // Sync the updated task - await this.syncTaskToCalendar(task); + await this.syncTaskToCalendar(task, previousState); + + // Update previous state with current task + this.previousTaskState.set(task.path, task); } /** diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 3aeb88d1..9b79e7d0 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -248,6 +248,7 @@ function createRecurrenceClickHandler( const menu = new RecurrenceContextMenu({ currentValue: typeof task.recurrence === "string" ? task.recurrence : undefined, currentAnchor: task.recurrence_anchor || "scheduled", + scheduledDate: task.scheduled, onSelect: async (newRecurrence, anchor) => { try { await plugin.updateTaskProperty(task, "recurrence", newRecurrence || undefined);