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 docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
24 changes: 15 additions & 9 deletions src/services/task-service/TaskCreationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,6 +82,7 @@ type TaskCreationSettings = Pick<
| "inlineTaskConvertFolder"
| "tasksFolder"
| "googleCalendarExport"
| "maintainDueDateOffsetInRecurring"
> &
TaskFilenameSettings;

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

Expand Down
121 changes: 111 additions & 10 deletions src/services/task-service/taskUpdatePlanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
addDTSTARTToRecurrenceRule,
updateToNextScheduledOccurrence,
} from "../../core/recurrence";
import { getDatePart } from "../../utils/dateUtils";
import {
applyGoogleCalendarRecurringExceptionCleanup,
applyGoogleCalendarRecurringExceptionForScheduledChange,
Expand Down Expand Up @@ -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<TaskInfo, "due">,
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,
Expand Down Expand Up @@ -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 (
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/services/taskUpdatePlanning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
applyTaskUpdateFrontmatterChange,
buildTaskUpdateRecurrenceUpdates,
buildUpdatedTaskFromPlan,
getRecurrenceSchedulingDefaults,
resolveScheduledDateForRecurrence,
normalizeTaskUpdateDetails,
normalizeTaskUpdateInput,
type TaskUpdateFieldMapper,
Expand Down Expand Up @@ -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");

Expand Down
Loading