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
5 changes: 3 additions & 2 deletions src/core/recurrence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ export function getNextUncompletedOccurrence(task: RecurringTaskInput): Date | n

export function updateToNextScheduledOccurrence(
task: RecurrenceUpdateTaskInput,
maintainDueOffset = true
maintainDueOffset = true,
options?: { minOccurrenceDate?: string }
): RecurrenceUpdateResult {
return updateToNextScheduledOccurrenceModel(task, maintainDueOffset, {
today: getTodayString(),
today: options?.minOccurrenceDate ?? getTodayString(),
});
}
91 changes: 83 additions & 8 deletions src/services/task-service/taskRecurringPlanning.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
addDTSTARTToRecurrenceRule,
generateRecurringInstances,
updateDTSTARTInRecurrenceRule,
updateToNextScheduledOccurrence,
} from "../../core/recurrence";
Expand All @@ -9,6 +10,7 @@ import {
formatDateForStorage,
getDatePart,
getTodayLocal,
getTodayString,
parseDateToUTC,
} from "../../utils/dateUtils";
import {
Expand Down Expand Up @@ -76,6 +78,47 @@ function getStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
}

function findOwningRecurrenceDate(task: TaskInfo, targetDate: Date): Date | null {
if (!task.recurrence) return null;

// If targetDate is itself a recurrence occurrence, return it as-is.
// This keeps toggle-off (un-complete) working correctly when an explicit
// recurrence date is passed.
const exactOccurrences = generateRecurringInstances(task, targetDate, targetDate);
const targetDateStr = formatDateForStorage(targetDate);
if (exactOccurrences.some((occ) => formatDateForStorage(occ) === targetDateStr)) {
return targetDate;
}

// targetDate is a shifted scheduled date — find the recurrence occurrence it belongs to.
const processedInstances = new Set([
...(task.complete_instances || []),
...(task.skipped_instances || []),
]);

// Prefer the most recent UNCOMPLETED occurrence at or before the target date.
const lookBackStart = new Date(targetDate.getTime() - 400 * 24 * 60 * 60 * 1000);
const pastOccurrences = generateRecurringInstances(task, lookBackStart, targetDate);
for (let i = pastOccurrences.length - 1; i >= 0; i--) {
if (!processedInstances.has(formatDateForStorage(pastOccurrences[i]))) {
return pastOccurrences[i];
}
}

// All occurrences up to the target are already completed/skipped.
// The scheduled date was shifted to prepare for the next cycle — find its first
// uncompleted occurrence after the target date.
const lookAheadEnd = new Date(targetDate.getTime() + 400 * 24 * 60 * 60 * 1000);
const futureOccurrences = generateRecurringInstances(task, targetDate, lookAheadEnd);
for (const occ of futureOccurrences) {
if (!processedInstances.has(formatDateForStorage(occ))) {
return occ;
}
}

return null;
}

export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date {
if (date) {
return date;
Expand All @@ -99,7 +142,19 @@ export function buildRecurringTaskCompletePlan({
throw new Error("Task is not recurring");
}

const dateStr = formatDateForStorage(targetDate);
const recurrenceAnchor = freshTask.recurrence_anchor || "scheduled";
// For scheduled-anchor tasks, find the recurrence date that owns the target date.
// The scheduled field may have been manually shifted forward from the actual recurrence
// date; storing the recurrence date in complete_instances ensures the model's exact-match
// check correctly marks this instance as done.
// Skip this for Google Calendar moved exceptions: the exception's scheduled date is
// distinct from the original series date and must be tracked as-is.
const owningRecurrenceDate =
recurrenceAnchor !== "completion" && !freshTask.googleCalendarExceptionOriginalScheduled
? findOwningRecurrenceDate(freshTask, targetDate)
: null;
const instanceDate = owningRecurrenceDate ?? targetDate;
const dateStr = formatDateForStorage(instanceDate);
const completeInstances = getStringArray(freshTask.complete_instances);
const currentComplete = completeInstances.includes(dateStr);
const newComplete = !currentComplete;
Expand All @@ -123,8 +178,6 @@ export function buildRecurringTaskCompletePlan({
}

if (newComplete && typeof updatedTask.recurrence === "string") {
const recurrenceAnchor = updatedTask.recurrence_anchor || "scheduled";

if (recurrenceAnchor === "completion") {
const updatedRecurrence = updateDTSTARTInRecurrenceRule(
updatedTask.recurrence,
Expand All @@ -141,9 +194,19 @@ export function buildRecurringTaskCompletePlan({
}
}

// Use the recurrence date (not the shifted scheduled date) as the reference for
// 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
? { ...updatedTask, scheduled: formatDateForStorage(owningRecurrenceDate) }
: updatedTask;
const todayStr = getTodayString();
const floorDate = dateStr > todayStr ? dateStr : todayStr;
const nextDates = updateToNextScheduledOccurrence(
updatedTask,
maintainDueDateOffsetInRecurring
taskForNextOccurrence,
maintainDueDateOffsetInRecurring,
{ minOccurrenceDate: floorDate }
);
if (nextDates.scheduled) {
updatedTask.scheduled = nextDates.scheduled;
Expand Down Expand Up @@ -235,7 +298,13 @@ export function buildRecurringTaskSkippedPlan({
throw new Error("Task is not recurring");
}

const dateStr = formatDateForStorage(targetDate);
const recurrenceAnchor = freshTask.recurrence_anchor || "scheduled";
const owningRecurrenceDate =
recurrenceAnchor !== "completion"
? findOwningRecurrenceDate(freshTask, targetDate)
: null;
const instanceDate = owningRecurrenceDate ?? targetDate;
const dateStr = formatDateForStorage(instanceDate);
const skippedInstances = getStringArray(freshTask.skipped_instances);
const currentlySkipped = skippedInstances.includes(dateStr);
const newSkipped = !currentlySkipped;
Expand All @@ -255,9 +324,15 @@ export function buildRecurringTaskSkippedPlan({
updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr);
}

const taskForNextOccurrence = owningRecurrenceDate
? { ...updatedTask, scheduled: formatDateForStorage(owningRecurrenceDate) }
: updatedTask;
const todayStr = getTodayString();
const floorDate = dateStr > todayStr ? dateStr : todayStr;
const nextDates = updateToNextScheduledOccurrence(
updatedTask,
maintainDueDateOffsetInRecurring
taskForNextOccurrence,
maintainDueDateOffsetInRecurring,
{ minOccurrenceDate: floorDate }
);
if (nextDates.scheduled) {
updatedTask.scheduled = nextDates.scheduled;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Tests for the "Dynamic Scheduled Dates" bug fix.
*
* When a recurring task's scheduled date is manually shifted forward from the
* actual recurrence date, completing the task previously stored the shifted
* date in complete_instances and calculated the due-date offset from the shifted
* date. This caused:
* 1. The task to jump backward (or to today) instead of advancing to the next cycle.
* 2. The due-date offset to drift each cycle.
* 3. Future-dated tasks to jump to the first occurrence after today, not after
* their own scheduled date.
*/

import {
buildRecurringTaskCompletePlan,
buildRecurringTaskSkippedPlan,
} from "../../../src/services/task-service/taskRecurringPlanning";
import type { TaskInfo } from "../../../src/types";
import { getTodayString } from "../../../src/utils/dateUtils";

jest.mock("../../../src/utils/dateUtils", () => ({
...jest.requireActual("../../../src/utils/dateUtils"),
getTodayString: jest.fn(),
}));

const mockGetTodayString = getTodayString as jest.MockedFunction<typeof getTodayString>;

function task(overrides: Partial<TaskInfo> = {}): TaskInfo {
return {
title: "Recurring task",
status: "open",
priority: "normal",
path: "TaskNotes/test.md",
archived: false,
complete_instances: [],
skipped_instances: [],
...overrides,
} as TaskInfo;
}

describe("dynamic scheduled dates — shifted scheduled date on completion", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-07-01");
});

afterEach(() => {
jest.useRealTimers();
});

it("stores the recurrence date in complete_instances when scheduled is shifted forward", () => {
// Recurrence: quarterly on the 1st, DTSTART 2026-04-01.
// User shifted scheduled 2 days to 2026-07-03. Completing should mark 2026-07-01.
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2026-07-03",
due: "2026-08-07",
complete_instances: ["2026-04-01"],
}),
targetDate: new Date("2026-07-03T00:00:00.000Z"),
currentTimestamp: "2026-07-03T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

// Instance key should be the recurrence date, not the shifted scheduled date.
expect(plan.dateStr).toBe("2026-07-01");
expect(plan.updatedTask.complete_instances).toContain("2026-07-01");
expect(plan.updatedTask.complete_instances).not.toContain("2026-07-03");
});

it("advances to the next quarterly cycle, not backward, when scheduled is shifted", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2026-07-03",
due: "2026-08-07",
complete_instances: ["2026-04-01"],
}),
targetDate: new Date("2026-07-03T00:00:00.000Z"),
currentTimestamp: "2026-07-03T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.scheduled).toBe("2026-10-01");
});

it("calculates due-date offset from the recurrence date, not the shifted scheduled date", () => {
// due (2026-08-07) is 37 days after the recurrence date (2026-07-01), not 35.
// Next due should be 2026-10-01 + 37 days = 2026-11-07.
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2026-07-03",
due: "2026-08-07",
complete_instances: ["2026-04-01"],
}),
targetDate: new Date("2026-07-03T00:00:00.000Z"),
currentTimestamp: "2026-07-03T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.updatedTask.due).toBe("2026-11-07");
});

it("works the same when scheduled date matches the recurrence date (regression guard)", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2026-07-01",
due: "2026-08-07",
complete_instances: ["2026-04-01"],
}),
targetDate: new Date("2026-07-01T00:00:00.000Z"),
currentTimestamp: "2026-07-01T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.dateStr).toBe("2026-07-01");
expect(plan.updatedTask.scheduled).toBe("2026-10-01");
// 37-day offset: 2026-10-01 + 37 = 2026-11-07
expect(plan.updatedTask.due).toBe("2026-11-07");
});
});

describe("dynamic scheduled dates — future-dated task completes to correct cycle", () => {
beforeEach(() => {
// Today is mid-2026, but the task is scheduled in 2027.
mockGetTodayString.mockReturnValue("2026-06-22");
});

it("advances to the occurrence after the 2027 scheduled date, not to 2026", () => {
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2027-04-03",
due: "2027-05-07",
complete_instances: [],
}),
targetDate: new Date("2027-04-03T00:00:00.000Z"),
currentTimestamp: "2027-04-03T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

// Should advance to 2027-07-01, not jump back to 2026-07-01.
expect(plan.updatedTask.scheduled).toBe("2027-07-01");
expect(plan.updatedTask.complete_instances).toContain("2027-04-01");
});
});

describe("dynamic scheduled dates — all past occurrences already completed", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-06-22");
});

it("does not toggle off an already-completed recurrence when scheduled is shifted past it", () => {
// Scenario from bug report:
// The 2028-01-01 recurrence was already completed.
// The next cycle (2028-04-01) has not started yet.
// User shifted scheduled from 2028-04-01 to 2028-03-01 as an early reminder.
// Clicking "done" should mark 2028-04-01 as complete, NOT toggle 2028-01-01 off.
const plan = buildRecurringTaskCompletePlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2028-03-01",
due: "2028-05-07",
complete_instances: ["2027-04-01", "2027-07-01", "2027-10-01", "2028-01-01"],
}),
targetDate: new Date("2028-03-01T00:00:00.000Z"),
currentTimestamp: "2028-03-01T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.newComplete).toBe(true);
// All previously-completed instances must still be present.
expect(plan.updatedTask.complete_instances).toContain("2027-04-01");
expect(plan.updatedTask.complete_instances).toContain("2027-07-01");
expect(plan.updatedTask.complete_instances).toContain("2027-10-01");
expect(plan.updatedTask.complete_instances).toContain("2028-01-01");
// The next recurrence (2028-04-01) should be the newly completed instance.
expect(plan.dateStr).toBe("2028-04-01");
expect(plan.updatedTask.complete_instances).toContain("2028-04-01");
// And the task should advance past 2028-04-01.
expect(plan.updatedTask.scheduled).toBe("2028-07-01");
});
});

describe("dynamic scheduled dates — skipped instance uses recurrence date", () => {
beforeEach(() => {
mockGetTodayString.mockReturnValue("2026-07-01");
});

it("stores the recurrence date in skipped_instances when scheduled is shifted", () => {
const plan = buildRecurringTaskSkippedPlan({
freshTask: task({
recurrence: "DTSTART:20260401;FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
scheduled: "2026-07-03",
due: "2026-08-07",
complete_instances: ["2026-04-01"],
}),
targetDate: new Date("2026-07-03T00:00:00.000Z"),
currentTimestamp: "2026-07-03T00:00:00.000Z",
maintainDueDateOffsetInRecurring: true,
});

expect(plan.dateStr).toBe("2026-07-01");
expect(plan.updatedTask.skipped_instances).toContain("2026-07-01");
expect(plan.updatedTask.skipped_instances).not.toContain("2026-07-03");
expect(plan.updatedTask.scheduled).toBe("2026-10-01");
});
});