diff --git a/src/core/recurrence.ts b/src/core/recurrence.ts index 642262fa..879f4965 100644 --- a/src/core/recurrence.ts +++ b/src/core/recurrence.ts @@ -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(), }); } diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index 85e2efa7..a3bcd638 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -1,5 +1,6 @@ import { addDTSTARTToRecurrenceRule, + generateRecurringInstances, updateDTSTARTInRecurrenceRule, updateToNextScheduledOccurrence, } from "../../core/recurrence"; @@ -9,6 +10,7 @@ import { formatDateForStorage, getDatePart, getTodayLocal, + getTodayString, parseDateToUTC, } from "../../utils/dateUtils"; import { @@ -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; @@ -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; @@ -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, @@ -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; @@ -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; @@ -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; diff --git a/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts b/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts new file mode 100644 index 00000000..2a86da1e --- /dev/null +++ b/tests/unit/issues/issue-2064-dynamic-scheduled-dates-completion.test.ts @@ -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; + +function task(overrides: Partial = {}): 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"); + }); +});