diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ca144a1ba..1193957ae 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -76,6 +76,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- Fixed recurring tasks without a scheduled date not syncing to Google Calendar by ensuring a scheduled date is set whenever recurrence is saved, even when the custom recurrence start date is left empty. - (#2182) Checklist progress on task cards now excludes cancelled markdown checklist items such as `[-]` from the completed/total count. Thanks to @ctrl-q for reporting this. diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 0f46a74ae..65e8a6560 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -1275,11 +1275,11 @@ export class TaskCalendarSyncService { shouldSyncTask(task: TaskInfo): boolean { if (!this.isEnabled()) return false; - const settings = this.plugin.settings.googleCalendarExport; - // Don't sync archived tasks if (task.archived) return false; + const settings = this.plugin.settings.googleCalendarExport; + // Check if task has the required date(s) based on sync trigger setting switch (settings.syncTrigger) { case "scheduled": diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index a60445c3b..c8a9c94aa 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -7,7 +7,7 @@ import { TaskInfo, } from "../../types"; import type { TaskNotesSettings } from "../../types/settings"; -import { addDTSTARTToRecurrenceRule } from "../../core/recurrence"; +import { getRecurrenceSchedulingDefaults } from "./taskUpdatePlanning"; import { FilenameContext, type TaskFilenameSettings, @@ -82,6 +82,7 @@ type TaskCreationSettings = Pick< | "inlineTaskConvertFolder" | "tasksFolder" | "googleCalendarExport" + | "maintainDueDateOffsetInRecurring" > & TaskFilenameSettings; @@ -259,11 +260,7 @@ export class TaskCreationService { } } - if ( - completeTaskData.recurrence && - typeof completeTaskData.recurrence === "string" && - !completeTaskData.recurrence.includes("DTSTART:") - ) { + if (completeTaskData.recurrence && typeof completeTaskData.recurrence === "string") { const tempTaskInfo: TaskInfo = { ...completeTaskData, title, @@ -272,9 +269,18 @@ export class TaskCreationService { path: "", archived: false, }; - const recurrenceWithDtstart = addDTSTARTToRecurrenceRule(tempTaskInfo); - if (recurrenceWithDtstart) { - completeTaskData.recurrence = recurrenceWithDtstart; + const schedulingDefaults = getRecurrenceSchedulingDefaults( + tempTaskInfo, + runtime.settings.maintainDueDateOffsetInRecurring + ); + if (schedulingDefaults.recurrence) { + completeTaskData.recurrence = schedulingDefaults.recurrence; + } + if (schedulingDefaults.scheduled) { + completeTaskData.scheduled = schedulingDefaults.scheduled; + } + if (schedulingDefaults.due) { + completeTaskData.due = schedulingDefaults.due; } } diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..0e3556a9c 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -3,6 +3,7 @@ import { addDTSTARTToRecurrenceRule, updateToNextScheduledOccurrence, } from "../../core/recurrence"; +import { getDatePart } from "../../utils/dateUtils"; import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, @@ -98,6 +99,92 @@ export function normalizeTaskUpdateDetails(updates: TaskUpdateInput): string | n return typeof updates.details === "string" ? updates.details.replace(/\r\n/g, "\n") : ""; } +function formatScheduledFromTimestamp(timestamp: string): string { + const datePart = getDatePart(timestamp); + const timeMatch = timestamp.match(/T(\d{2}:\d{2})/); + return timeMatch ? `${datePart}T${timeMatch[1]}` : datePart; +} + +type RecurrenceSchedulingTask = Pick< + TaskInfo, + | "recurrence" + | "scheduled" + | "dateCreated" + | "recurrence_anchor" + | "complete_instances" + | "skipped_instances" +>; + +export function resolveScheduledDateForRecurrence( + task: RecurrenceSchedulingTask, + maintainDueDateOffset = true, + updateToNextScheduledOccurrenceFn: typeof updateToNextScheduledOccurrence = updateToNextScheduledOccurrence +): string | undefined { + if (task.scheduled) { + return task.scheduled; + } + + if (!task.recurrence || typeof task.recurrence !== "string") { + return undefined; + } + + const nextDates = updateToNextScheduledOccurrenceFn(task, maintainDueDateOffset); + if (nextDates.scheduled) { + return nextDates.scheduled; + } + + if (task.dateCreated) { + return formatScheduledFromTimestamp(task.dateCreated); + } + + return undefined; +} + +export function getRecurrenceSchedulingDefaults( + task: RecurrenceSchedulingTask & Pick, + maintainDueDateOffset = true, + updateToNextScheduledOccurrenceFn: typeof updateToNextScheduledOccurrence = updateToNextScheduledOccurrence, + addDTSTARTToRecurrenceRuleFn: typeof addDTSTARTToRecurrenceRule = addDTSTARTToRecurrenceRule +): { recurrence?: string; scheduled?: string; due?: string } { + if (!task.recurrence || typeof task.recurrence !== "string") { + return {}; + } + + const updates: { recurrence?: string; scheduled?: string; due?: string } = {}; + let recurrence = task.recurrence; + + if (!recurrence.includes("DTSTART:")) { + const recurrenceWithDtstart = addDTSTARTToRecurrenceRuleFn(task); + if (recurrenceWithDtstart) { + recurrence = recurrenceWithDtstart; + updates.recurrence = recurrenceWithDtstart; + } + } + + if (!task.scheduled) { + const scheduled = resolveScheduledDateForRecurrence( + { ...task, recurrence, ...updates }, + maintainDueDateOffset, + updateToNextScheduledOccurrenceFn + ); + if (scheduled) { + updates.scheduled = scheduled; + } + + if (!task.due) { + const nextDates = updateToNextScheduledOccurrenceFn( + { ...task, recurrence, scheduled, ...updates }, + maintainDueDateOffset + ); + if (nextDates.due) { + updates.due = nextDates.due; + } + } + } + + return updates; +} + export function buildTaskUpdateRecurrenceUpdates({ originalTask, updates, @@ -134,21 +221,35 @@ export function buildTaskUpdateRecurrenceUpdates({ recurrenceUpdates.recurrence = updatedRecurrence; } } - } else if ( - updates.recurrence !== undefined && - !originalTask.recurrence && - updates.recurrence - ) { + if ( typeof updates.recurrence === "string" && - !updates.recurrence.includes("DTSTART:") + updates.recurrence && + !recurrenceUpdates.scheduled && + !originalTask.scheduled && + updates.scheduled === undefined ) { - const tempTask: TaskInfo = { ...originalTask, ...updates }; - const updatedRecurrence = addDTSTARTToRecurrenceRuleFn(tempTask); - if (updatedRecurrence) { - recurrenceUpdates.recurrence = updatedRecurrence; + const scheduled = resolveScheduledDateForRecurrence( + { ...originalTask, ...updates, ...recurrenceUpdates }, + maintainDueDateOffsetInRecurring, + updateToNextScheduledOccurrenceFn + ); + if (scheduled) { + recurrenceUpdates.scheduled = scheduled; } } + } else if ( + updates.recurrence !== undefined && + !originalTask.recurrence && + updates.recurrence + ) { + const schedulingDefaults = getRecurrenceSchedulingDefaults( + { ...originalTask, ...updates }, + maintainDueDateOffsetInRecurring, + updateToNextScheduledOccurrenceFn, + addDTSTARTToRecurrenceRuleFn + ); + Object.assign(recurrenceUpdates, schedulingDefaults); } if ( diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts index 31215bfc9..fb54cdd54 100644 --- a/tests/unit/services/taskUpdatePlanning.test.ts +++ b/tests/unit/services/taskUpdatePlanning.test.ts @@ -3,6 +3,8 @@ import { applyTaskUpdateFrontmatterChange, buildTaskUpdateRecurrenceUpdates, buildUpdatedTaskFromPlan, + getRecurrenceSchedulingDefaults, + resolveScheduledDateForRecurrence, normalizeTaskUpdateDetails, normalizeTaskUpdateInput, type TaskUpdateFieldMapper, @@ -137,6 +139,74 @@ describe("taskUpdatePlanning", () => { }); }); + it("sets scheduled when recurrence is saved without a start date", () => { + const updateToNextScheduledOccurrenceFn = jest.fn(() => ({ + scheduled: "2026-08-04", + due: null, + })); + const addDTSTARTToRecurrenceRuleFn = jest.fn( + () => "DTSTART:20260804T125706Z;FREQ=MONTHLY;BYMONTHDAY=4" + ); + + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + dateCreated: "2026-08-04T12:57:06.490+02:00", + }), + updates: { recurrence: "FREQ=MONTHLY;BYMONTHDAY=4" }, + maintainDueDateOffsetInRecurring: true, + updateToNextScheduledOccurrenceFn, + addDTSTARTToRecurrenceRuleFn, + }); + + expect(result).toEqual({ + scheduled: "2026-08-04", + recurrence: "DTSTART:20260804T125706Z;FREQ=MONTHLY;BYMONTHDAY=4", + }); + }); + + it("falls back to dateCreated when recurrence has no start date and no next occurrence", () => { + const updateToNextScheduledOccurrenceFn = jest.fn(() => ({ + scheduled: null, + due: null, + })); + + const scheduled = resolveScheduledDateForRecurrence( + { + recurrence: "FREQ=MONTHLY;BYMONTHDAY=4", + dateCreated: "2026-08-04T12:57:06.490+02:00", + }, + true, + updateToNextScheduledOccurrenceFn + ); + + expect(scheduled).toBe("2026-08-04T12:57"); + }); + + it("adds scheduled defaults when recurrence is set without a scheduled date", () => { + const updateToNextScheduledOccurrenceFn = jest.fn(() => ({ + scheduled: "2026-08-04", + due: null, + })); + const addDTSTARTToRecurrenceRuleFn = jest.fn( + () => "DTSTART:20260804T125706Z;FREQ=MONTHLY;BYMONTHDAY=4" + ); + + const result = getRecurrenceSchedulingDefaults( + { + recurrence: "FREQ=MONTHLY;BYMONTHDAY=4", + dateCreated: "2026-08-04T12:57:06.490+02:00", + }, + true, + updateToNextScheduledOccurrenceFn, + addDTSTARTToRecurrenceRuleFn + ); + + expect(result).toEqual({ + recurrence: "DTSTART:20260804T125706Z;FREQ=MONTHLY;BYMONTHDAY=4", + scheduled: "2026-08-04", + }); + }); + it("adds DTSTART when a scheduled recurring task moves and the rule lacks DTSTART", () => { const addDTSTARTToRecurrenceRuleFn = jest.fn(() => "DTSTART:20260521;FREQ=DAILY");