Skip to content

Commit e4aab5b

Browse files
committed
advance recurring occurrences moved before their original date
A moved occurrence replaces its original recurrence slot, but next-occurrence planning ignored that stored exclusion, so completing or skipping the moved occurrence could schedule the replaced original date again. Treat pending and resolved moved-original dates as processed while calculating the next open occurrence only. Persisted completion and skip history stays keyed to the moved date.
1 parent 8e1476c commit e4aab5b

3 files changed

Lines changed: 219 additions & 2 deletions

File tree

‎docs/releases/unreleased.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
3535
## Fixed
3636

3737
- 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.
38+
- (#2191) Completing or skipping a recurring occurrence that was moved earlier
39+
than its original date no longer schedules that original date again. Thanks to
40+
@martin-forge for the contribution.

‎src/services/task-service/taskRecurringPlanning.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,49 @@ function moveScheduledDateToOccurrence(
149149
return `${occurrenceDateStr}${scheduled.slice(scheduledDatePart.length)}`;
150150
}
151151

152+
function withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
153+
task: TaskInfo,
154+
originalTask: TaskInfo,
155+
actionDate: string
156+
): TaskInfo {
157+
if ((originalTask.recurrence_anchor || "scheduled") !== "scheduled") {
158+
return task;
159+
}
160+
161+
const replacedDates = new Set<string>();
162+
for (const date of originalTask.googleCalendarMovedOriginalDates || []) {
163+
const normalized = getDatePart(date);
164+
if (normalized) {
165+
replacedDates.add(normalized);
166+
}
167+
}
168+
169+
const pendingOriginal = getDatePart(
170+
originalTask.googleCalendarExceptionOriginalScheduled || ""
171+
);
172+
const movedDate = getDatePart(originalTask.scheduled || "");
173+
if (pendingOriginal && pendingOriginal !== actionDate && movedDate === actionDate) {
174+
replacedDates.add(pendingOriginal);
175+
}
176+
177+
// Moved occurrences replace their original series dates. Mark those dates as
178+
// processed only for this calculation; persisted state stays keyed to moved dates.
179+
const completeInstances = new Set(getStringArray(task.complete_instances));
180+
const skippedInstances = getStringArray(task.skipped_instances);
181+
const skippedInstanceSet = new Set(skippedInstances);
182+
const additionalSkippedInstances = Array.from(replacedDates)
183+
.filter((date) => !completeInstances.has(date) && !skippedInstanceSet.has(date))
184+
.sort();
185+
if (additionalSkippedInstances.length === 0) {
186+
return task;
187+
}
188+
189+
return {
190+
...task,
191+
skipped_instances: [...skippedInstances, ...additionalSkippedInstances],
192+
};
193+
}
194+
152195
export function getRecurringTaskActionDate(task: TaskInfo, date?: Date): Date {
153196
if (date) {
154197
return date;
@@ -225,7 +268,7 @@ export function buildRecurringTaskCompletePlan({
225268
// due-date offset calculation and next-occurrence search.
226269
// Pass max(today, instanceDate) as the floor so future-dated tasks advance past
227270
// the current cycle rather than jumping back to today's nearest occurrence.
228-
const taskForNextOccurrence = owningRecurrenceDate
271+
const taskForNextOccurrenceBase = owningRecurrenceDate
229272
? {
230273
...updatedTask,
231274
scheduled: moveScheduledDateToOccurrence(
@@ -234,6 +277,11 @@ export function buildRecurringTaskCompletePlan({
234277
),
235278
}
236279
: updatedTask;
280+
const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
281+
taskForNextOccurrenceBase,
282+
freshTask,
283+
dateStr
284+
);
237285
const todayStr = getTodayString();
238286
const floorDate = dateStr > todayStr ? dateStr : todayStr;
239287
const nextDates = updateToNextScheduledOccurrence(
@@ -353,7 +401,7 @@ export function buildRecurringTaskSkippedPlan({
353401
updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr);
354402
}
355403

356-
const taskForNextOccurrence = owningRecurrenceDate
404+
const taskForNextOccurrenceBase = owningRecurrenceDate
357405
? {
358406
...updatedTask,
359407
scheduled: moveScheduledDateToOccurrence(
@@ -362,6 +410,11 @@ export function buildRecurringTaskSkippedPlan({
362410
),
363411
}
364412
: updatedTask;
413+
const taskForNextOccurrence = withGoogleCalendarReplacedDatesProcessedForNextOccurrence(
414+
taskForNextOccurrenceBase,
415+
freshTask,
416+
dateStr
417+
);
365418
const todayStr = getTodayString();
366419
const floorDate = dateStr > todayStr ? dateStr : todayStr;
367420
const nextDates = updateToNextScheduledOccurrence(

‎tests/unit/services/taskRecurringPlanning.test.ts‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ function createRecurringTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
3030
} as TaskInfo;
3131
}
3232

33+
function createMovedWeeklyTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
34+
return createRecurringTask({
35+
recurrence: "DTSTART:20260406;FREQ=WEEKLY;BYDAY=MO",
36+
scheduled: "2026-04-10",
37+
googleCalendarExceptionOriginalScheduled: "2026-04-13",
38+
...overrides,
39+
});
40+
}
41+
3342
describe("taskRecurringPlanning", () => {
3443
beforeEach(() => {
3544
mockGetTodayString.mockReturnValue("2026-05-19");
@@ -193,4 +202,156 @@ describe("taskRecurringPlanning", () => {
193202
expect(frontmatter.scheduled).toBe("2026-05-20");
194203
expect(frontmatter.dateModified).toBe("2026-05-19T07:15:00+10:00");
195204
});
205+
206+
describe("Google Calendar occurrences moved before their series date", () => {
207+
beforeEach(() => {
208+
mockGetTodayString.mockReturnValue("2026-04-10");
209+
});
210+
211+
it("completes the moved date and advances past the replaced series date", () => {
212+
const plan = buildRecurringTaskCompletePlan({
213+
freshTask: createMovedWeeklyTask(),
214+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
215+
currentTimestamp: "2026-04-10T12:30:00.000Z",
216+
maintainDueDateOffsetInRecurring: true,
217+
});
218+
219+
expect(plan.updatedTask.complete_instances).toEqual(["2026-04-10"]);
220+
expect(plan.updatedTask.skipped_instances).toEqual([]);
221+
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
222+
expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
223+
expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
224+
});
225+
226+
it("skips the moved date and advances past the replaced series date", () => {
227+
const plan = buildRecurringTaskSkippedPlan({
228+
freshTask: createMovedWeeklyTask(),
229+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
230+
currentTimestamp: "2026-04-10T12:30:00.000Z",
231+
maintainDueDateOffsetInRecurring: true,
232+
});
233+
234+
expect(plan.updatedTask.skipped_instances).toEqual(["2026-04-10"]);
235+
expect(plan.updatedTask.complete_instances).toEqual([]);
236+
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
237+
expect(plan.updatedTask.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]);
238+
expect(plan.updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined();
239+
});
240+
241+
it("keeps the replaced date excluded after intervening occurrences", () => {
242+
const movedPlan = buildRecurringTaskCompletePlan({
243+
freshTask: createMovedWeeklyTask({
244+
recurrence: "DTSTART:20260409;FREQ=DAILY",
245+
}),
246+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
247+
currentTimestamp: "2026-04-10T12:30:00.000Z",
248+
maintainDueDateOffsetInRecurring: true,
249+
});
250+
const interveningPlan = buildRecurringTaskCompletePlan({
251+
freshTask: movedPlan.updatedTask,
252+
targetDate: new Date("2026-04-11T12:00:00.000Z"),
253+
currentTimestamp: "2026-04-11T12:30:00.000Z",
254+
maintainDueDateOffsetInRecurring: true,
255+
});
256+
const beforeReplacedDatePlan = buildRecurringTaskCompletePlan({
257+
freshTask: interveningPlan.updatedTask,
258+
targetDate: new Date("2026-04-12T12:00:00.000Z"),
259+
currentTimestamp: "2026-04-12T12:30:00.000Z",
260+
maintainDueDateOffsetInRecurring: true,
261+
});
262+
263+
expect(movedPlan.updatedTask.scheduled).toBe("2026-04-11");
264+
expect(interveningPlan.updatedTask.scheduled).toBe("2026-04-12");
265+
expect(beforeReplacedDatePlan.updatedTask.scheduled).toBe("2026-04-14");
266+
expect(beforeReplacedDatePlan.updatedTask.complete_instances).toEqual([
267+
"2026-04-10",
268+
"2026-04-11",
269+
"2026-04-12",
270+
]);
271+
expect(beforeReplacedDatePlan.updatedTask.skipped_instances).toEqual([]);
272+
expect(beforeReplacedDatePlan.updatedTask.googleCalendarMovedOriginalDates).toEqual([
273+
"2026-04-13",
274+
]);
275+
});
276+
277+
it("does not persist the replaced series date when uncompleting or unskipping", () => {
278+
const completePlan = buildRecurringTaskCompletePlan({
279+
freshTask: createMovedWeeklyTask({ complete_instances: ["2026-04-10"] }),
280+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
281+
currentTimestamp: "2026-04-10T12:30:00.000Z",
282+
maintainDueDateOffsetInRecurring: true,
283+
});
284+
const skipPlan = buildRecurringTaskSkippedPlan({
285+
freshTask: createMovedWeeklyTask({ skipped_instances: ["2026-04-10"] }),
286+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
287+
currentTimestamp: "2026-04-10T12:30:00.000Z",
288+
maintainDueDateOffsetInRecurring: true,
289+
});
290+
291+
expect(completePlan.newComplete).toBe(false);
292+
expect(completePlan.updatedTask.complete_instances).toEqual([]);
293+
expect(completePlan.updatedTask.skipped_instances).toEqual([]);
294+
expect(skipPlan.newSkipped).toBe(false);
295+
expect(skipPlan.updatedTask.complete_instances).toEqual([]);
296+
expect(skipPlan.updatedTask.skipped_instances).toEqual([]);
297+
for (const plan of [completePlan, skipPlan]) {
298+
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
299+
expect(plan.updatedTask.complete_instances).not.toContain("2026-04-13");
300+
expect(plan.updatedTask.skipped_instances).not.toContain("2026-04-13");
301+
}
302+
});
303+
304+
it("preserves the moved date's due offset when advancing", () => {
305+
const plan = buildRecurringTaskCompletePlan({
306+
freshTask: createMovedWeeklyTask({ due: "2026-04-12" }),
307+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
308+
currentTimestamp: "2026-04-10T12:30:00.000Z",
309+
maintainDueDateOffsetInRecurring: true,
310+
});
311+
312+
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
313+
expect(plan.updatedTask.due).toBe("2026-04-22");
314+
});
315+
316+
it("keeps later moved occurrences advancing normally", () => {
317+
const plan = buildRecurringTaskCompletePlan({
318+
freshTask: createMovedWeeklyTask({ scheduled: "2026-04-15" }),
319+
targetDate: new Date("2026-04-15T12:00:00.000Z"),
320+
currentTimestamp: "2026-04-15T12:30:00.000Z",
321+
maintainDueDateOffsetInRecurring: true,
322+
});
323+
324+
expect(plan.updatedTask.complete_instances).toEqual(["2026-04-15"]);
325+
expect(plan.updatedTask.scheduled).toBe("2026-04-20");
326+
});
327+
328+
it("does not change completion-anchored recurrence calculation", () => {
329+
const baseTask = createMovedWeeklyTask({ recurrence_anchor: "completion" });
330+
const withoutGoogleException = {
331+
...baseTask,
332+
googleCalendarExceptionOriginalScheduled: undefined,
333+
};
334+
const input = {
335+
targetDate: new Date("2026-04-10T12:00:00.000Z"),
336+
currentTimestamp: "2026-04-10T12:30:00.000Z",
337+
maintainDueDateOffsetInRecurring: true,
338+
};
339+
340+
const withException = buildRecurringTaskCompletePlan({
341+
freshTask: baseTask,
342+
...input,
343+
});
344+
const withoutException = buildRecurringTaskCompletePlan({
345+
freshTask: withoutGoogleException,
346+
...input,
347+
});
348+
349+
expect(withException.updatedTask.scheduled).toBe(
350+
withoutException.updatedTask.scheduled
351+
);
352+
expect(withException.updatedTask.recurrence).toBe(
353+
withoutException.updatedTask.recurrence
354+
);
355+
});
356+
});
196357
});

0 commit comments

Comments
 (0)