Skip to content
Open
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
1 change: 1 addition & 0 deletions src/bases/KanbanView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/bases/TaskListView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 24 additions & 2 deletions src/components/RecurrenceContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?)?)/
Expand All @@ -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({
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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();
}
Expand All @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions src/components/TaskContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/modals/TaskModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 29 additions & 5 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export class TaskCalendarSyncService {
/** In-flight sync operations to prevent concurrent syncs for the same task */
private inFlightSyncs: Map<string, Promise<void>> = new Map();

/** Track previous task state for detecting recurrence removal */
private previousTaskState: Map<string, TaskInfo> = new Map();

constructor(plugin: TaskNotesPlugin, googleCalendarService: GoogleCalendarService) {
this.plugin = plugin;
this.googleCalendarService = googleCalendarService;
Expand All @@ -38,6 +41,7 @@ export class TaskCalendarSyncService {
clearTimeout(timer);
}
this.pendingSyncs.clear();
this.previousTaskState.clear();
}

/**
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand All @@ -453,7 +461,7 @@ export class TaskCalendarSyncService {
/**
* Sync a task to Google Calendar (create or update)
*/
async syncTaskToCalendar(task: TaskInfo): Promise<void> {
async syncTaskToCalendar(task: TaskInfo, previous?: TaskInfo): Promise<void> {
if (!this.shouldSyncTask(task)) {
return;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/ui/TaskCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down