Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
## Fixed

- Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution.
- (#2191) Completing or skipping a recurring occurrence that was moved earlier
than its original date no longer schedules that original date again. Thanks to
@martin-forge for the contribution.
- (#2193) Google Calendar event descriptions written with formatting now read as
plain text in event details, copied text, and generated notes, instead of
showing raw HTML tags. Paragraph breaks, list structure, and link addresses are
Expand Down
57 changes: 55 additions & 2 deletions src/services/task-service/taskRecurringPlanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,49 @@ function moveScheduledDateToOccurrence(
return `${occurrenceDateStr}${scheduled.slice(scheduledDatePart.length)}`;
}

function withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
task: TaskInfo,
originalTask: TaskInfo,
actionDate: string
): TaskInfo {
if ((originalTask.recurrence_anchor || "scheduled") !== "scheduled") {
return task;
}

const replacedDates = new Set<string>();
for (const date of originalTask.googleCalendarMovedOriginalDates || []) {
const normalized = getDatePart(date);
if (normalized) {
replacedDates.add(normalized);
}
}

const pendingOriginal = getDatePart(
originalTask.googleCalendarExceptionOriginalScheduled || ""
);
const movedDate = getDatePart(originalTask.scheduled || "");
if (pendingOriginal && pendingOriginal !== actionDate && movedDate === actionDate) {
replacedDates.add(pendingOriginal);
}

// Moved occurrences replace their original series dates. Mark those dates as
// processed only for this calculation; persisted state stays keyed to moved dates.
const completeInstances = new Set(getStringArray(task.complete_instances));
const skippedInstances = getStringArray(task.skipped_instances);
const skippedInstanceSet = new Set(skippedInstances);
const additionalSkippedInstances = Array.from(replacedDates)
.filter((date) => !completeInstances.has(date) && !skippedInstanceSet.has(date))
.sort();
if (additionalSkippedInstances.length === 0) {
return task;
}

return {
...task,
skipped_instances: [...skippedInstances, ...additionalSkippedInstances],
};
}

export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date {
if (date) {
return date;
Expand Down Expand Up @@ -225,7 +268,7 @@ export function buildRecurringTaskCompletePlan({
// due-date offset calculation and next-occurrence search.
// Pass max(today, instanceDate) as the floor so future-dated tasks advance past
// the current cycle rather than jumping back to today's nearest occurrence.
const taskForNextOccurrence = owningRecurrenceDate
const taskForNextOccurrenceBase = owningRecurrenceDate
? {
...updatedTask,
scheduled: moveScheduledDateToOccurrence(
Expand All @@ -234,6 +277,11 @@ export function buildRecurringTaskCompletePlan({
),
}
: updatedTask;
const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
taskForNextOccurrenceBase,
freshTask,
dateStr
);
const todayStr = getTodayString();
const floorDate = dateStr > todayStr ? dateStr : todayStr;
const nextDates = updateToNextScheduledOccurrence(
Expand Down Expand Up @@ -353,7 +401,7 @@ export function buildRecurringTaskSkippedPlan({
updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr);
}

const taskForNextOccurrence = owningRecurrenceDate
const taskForNextOccurrenceBase = owningRecurrenceDate
? {
...updatedTask,
scheduled: moveScheduledDateToOccurrence(
Expand All @@ -362,6 +410,11 @@ export function buildRecurringTaskSkippedPlan({
),
}
: updatedTask;
const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
taskForNextOccurrenceBase,
freshTask,
dateStr
);
const todayStr = getTodayString();
const floorDate = dateStr > todayStr ? dateStr : todayStr;
const nextDates = updateToNextScheduledOccurrence(
Expand Down
161 changes: 161 additions & 0 deletions tests/unit/services/taskRecurringPlanning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ function createRecurringTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
} as TaskInfo;
}

function createMovedWeeklyTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
return createRecurringTask({
recurrence: "DTSTART:20260406;FREQ=WEEKLY;BYDAY=MO",
scheduled: "2026-04-10",
googleCalendarExceptionOriginalScheduled: "2026-04-13",
...overrides,
});
}

describe("taskRecurringPlanning", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-05-19");
Expand Down Expand Up @@ -193,4 +202,156 @@ describe("taskRecurringPlanning", () => {
expect(frontmatter.scheduled).toBe("2026-05-20");
expect(frontmatter.dateModified).toBe("2026-05-19T07:15:00+10:00");
});

describe("Google Calendar occurrences moved before their series date", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-04-10");
});

it("completes the moved date and advances past the replaced series date", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: createMovedWeeklyTask(),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.complete_instances).toEqual(["2026-04-10"]);
expect(plan.updatedTask.skipped_instances).toEqual([]);
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
});

it("skips the moved date and advances past the replaced series date", () => {
const plan = buildRecurringTaskSkippedPlan({
freshTask: createMovedWeeklyTask(),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.skipped_instances).toEqual(["2026-04-10"]);
expect(plan.updatedTask.complete_instances).toEqual([]);
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
});

it("keeps the replaced date excluded after intervening occurrences", () => {
const movedPlan = buildRecurringTaskCompletePlan({
freshTask: createMovedWeeklyTask({
recurrence: "DTSTART:20260409;FREQ=DAILY",
}),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});
const interveningPlan = buildRecurringTaskCompletePlan({
freshTask: movedPlan.updatedTask,
targetDate: new Date("2026-04-11T12:00:00.000Z"),
currentTimestamp: "2026-04-11T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});
const beforeReplacedDatePlan = buildRecurringTaskCompletePlan({
freshTask: interveningPlan.updatedTask,
targetDate: new Date("2026-04-12T12:00:00.000Z"),
currentTimestamp: "2026-04-12T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(movedPlan.updatedTask.scheduled).toBe("2026-04-11");
expect(interveningPlan.updatedTask.scheduled).toBe("2026-04-12");
expect(beforeReplacedDatePlan.updatedTask.scheduled).toBe("2026-04-14");
expect(beforeReplacedDatePlan.updatedTask.complete_instances).toEqual([
"2026-04-10",
"2026-04-11",
"2026-04-12",
]);
expect(beforeReplacedDatePlan.updatedTask.skipped_instances).toEqual([]);
expect(beforeReplacedDatePlan.updatedTask.googleCalendarMovedOriginalDates).toEqual([
"2026-04-13",
]);
});

it("does not persist the replaced series date when uncompleting or unskipping", () => {
const completePlan = buildRecurringTaskCompletePlan({
freshTask: createMovedWeeklyTask({ complete_instances: ["2026-04-10"] }),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});
const skipPlan = buildRecurringTaskSkippedPlan({
freshTask: createMovedWeeklyTask({ skipped_instances: ["2026-04-10"] }),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(completePlan.newComplete).toBe(false);
expect(completePlan.updatedTask.complete_instances).toEqual([]);
expect(completePlan.updatedTask.skipped_instances).toEqual([]);
expect(skipPlan.newSkipped).toBe(false);
expect(skipPlan.updatedTask.complete_instances).toEqual([]);
expect(skipPlan.updatedTask.skipped_instances).toEqual([]);
for (const plan of [completePlan, skipPlan]) {
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
expect(plan.updatedTask.complete_instances).not.toContain("2026-04-13");
expect(plan.updatedTask.skipped_instances).not.toContain("2026-04-13");
}
});

it("preserves the moved date's due offset when advancing", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: createMovedWeeklyTask({ due: "2026-04-12" }),
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.scheduled).toBe("2026-04-20");
expect(plan.updatedTask.due).toBe("2026-04-22");
});

it("keeps later moved occurrences advancing normally", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: createMovedWeeklyTask({ scheduled: "2026-04-15" }),
targetDate: new Date("2026-04-15T12:00:00.000Z"),
currentTimestamp: "2026-04-15T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.complete_instances).toEqual(["2026-04-15"]);
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
});

it("does not change completion-anchored recurrence calculation", () => {
const baseTask = createMovedWeeklyTask({ recurrence_anchor: "completion" });
const withoutGoogleException = {
...baseTask,
googleCalendarExceptionOriginalScheduled: undefined,
};
const input = {
targetDate: new Date("2026-04-10T12:00:00.000Z"),
currentTimestamp: "2026-04-10T12:30:00.000Z",
maintainDueDateOffsetInRecurring: true,
};

const withException = buildRecurringTaskCompletePlan({
freshTask: baseTask,
...input,
});
const withoutException = buildRecurringTaskCompletePlan({
freshTask: withoutGoogleException,
...input,
});

expect(withException.updatedTask.scheduled).toBe(
withoutException.updatedTask.scheduled
);
expect(withException.updatedTask.recurrence).toBe(
withoutException.updatedTask.recurrence
);
});
});
});
Loading