fix: scale recurrence look-ahead window with INTERVAL value (#1556)

Recurring tasks with INTERVAL > 30 (DAILY) or ~12 (WEEKLY) failed to
reschedule because the hardcoded lookAheadDays was too small to find
the next occurrence. The window now scales as
Math.max(baseWindow, interval * periodDays * 2).
This commit is contained in:
callumalpass 2026-02-08 18:32:51 +11:00
parent 1289b73a98
commit ae8afbeae1
4 changed files with 127 additions and 13 deletions

View file

@ -27,6 +27,15 @@ Example:
## Fixed
- Fixed [mdbase-spec](https://mdbase.dev) type definition generation not triggering when settings change
- Improved generated `_types/task.md` to use proper multi-line YAML format with field descriptions
- Improved generated `_types/task.md` to use proper multi-line YAML format with field descriptions
- (#1555) Fixed "Folder already exists" error when creating tasks or converting inline tasks
- Used adapter filesystem check instead of in-memory cache for folder existence
- Thanks to @jkune5 for reporting
- (#1532) Fixed expanded task modal buttons being cut off when content exceeds viewport height
- Thanks to @willfanguy for reporting
- (#1556) Fixed completion-based recurring tasks not rescheduling when INTERVAL exceeds 30 (DAILY) or ~12 (WEEKLY)
- The look-ahead window for finding the next occurrence now scales with the INTERVAL value
- Thanks to @kazerniel for reporting

View file

@ -584,6 +584,11 @@ export function getNextUncompletedOccurrence(task: TaskInfo): Date | null {
}
}
function parseIntervalFromRecurrence(recurrence: string): number {
const match = recurrence.match(/INTERVAL=(\d+)/);
return match ? parseInt(match[1], 10) : 1;
}
/**
* Gets next occurrence for scheduled-based (fixed) recurrence
*/
@ -599,15 +604,17 @@ function getNextScheduledBasedOccurrence(task: TaskInfo): Date | null {
// Determine look-ahead period based on recurrence frequency
// This ensures we can find at least one future occurrence
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
const interval = parseIntervalFromRecurrence(task.recurrence);
let lookAheadDays = 365; // Default: 1 year
if (task.recurrence.includes("FREQ=DAILY")) {
lookAheadDays = 30; // 30 days for daily tasks
lookAheadDays = Math.max(30, interval * 1 * 2);
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
lookAheadDays = 90; // ~13 weeks for weekly tasks
lookAheadDays = Math.max(90, interval * 7 * 2);
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
lookAheadDays = 400; // ~13 months for monthly tasks
lookAheadDays = Math.max(400, interval * 31 * 2);
} else if (task.recurrence.includes("FREQ=YEARLY")) {
lookAheadDays = 800; // ~2.2 years for yearly tasks to ensure we find the next occurrence
lookAheadDays = Math.max(800, interval * 366 * 2);
}
// Start from the DTSTART (or earlier) to ensure we catch all occurrences
@ -674,15 +681,17 @@ function getNextCompletionBasedOccurrence(task: TaskInfo): Date | null {
// Determine look-ahead period based on recurrence frequency
// This ensures we can find at least one future occurrence
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
const interval = parseIntervalFromRecurrence(task.recurrence);
let lookAheadDays = 365; // Default: 1 year
if (task.recurrence.includes("FREQ=DAILY")) {
lookAheadDays = 30; // 30 days for daily tasks
lookAheadDays = Math.max(30, interval * 1 * 2);
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
lookAheadDays = 90; // ~13 weeks for weekly tasks
lookAheadDays = Math.max(90, interval * 7 * 2);
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
lookAheadDays = 400; // ~13 months for monthly tasks
lookAheadDays = Math.max(400, interval * 31 * 2);
} else if (task.recurrence.includes("FREQ=YEARLY")) {
lookAheadDays = 800; // ~2.2 years for yearly tasks to ensure we find the next occurrence
lookAheadDays = Math.max(800, interval * 366 * 2);
}
// Extract DTSTART date from the RRULE

View file

@ -92,13 +92,33 @@ export class RRule {
current.setUTCDate(current.getUTCDate() + 1);
}
} else if (freq === Frequency.DAILY) {
// For daily recurrence, generate all dates in the range
const current = new Date(start);
// For daily recurrence, generate dates respecting the interval
const dailyInterval = this.options.interval || 1;
const current = new Date(dtstart);
current.setUTCHours(0, 0, 0, 0);
// Advance to the first occurrence at or after start
while (current < start) {
current.setUTCDate(current.getUTCDate() + dailyInterval);
}
while (current <= end) {
dates.push(new Date(current));
current.setUTCDate(current.getUTCDate() + 1);
current.setUTCDate(current.getUTCDate() + dailyInterval);
}
} else if (freq === Frequency.WEEKLY && (!byweekday || byweekday.length === 0)) {
// Weekly recurrence without specific weekdays
const weeklyInterval = this.options.interval || 1;
const current = new Date(dtstart);
current.setUTCHours(0, 0, 0, 0);
while (current < start) {
current.setUTCDate(current.getUTCDate() + weeklyInterval * 7);
}
while (current <= end) {
dates.push(new Date(current));
current.setUTCDate(current.getUTCDate() + weeklyInterval * 7);
}
}

View file

@ -0,0 +1,76 @@
import { getNextUncompletedOccurrence } from "../../../src/utils/helpers";
import { TaskInfo } from "../../../src/types";
import * as dateUtils from "../../../src/utils/dateUtils";
// Mock dateUtils to control "today"
jest.mock("../../../src/utils/dateUtils", () => {
const actual = jest.requireActual("../../../src/utils/dateUtils");
return {
...actual,
getTodayString: jest.fn(() => "2026-02-08"),
};
});
describe("Issue #1556: Recurring task with INTERVAL > 30 should reschedule", () => {
it("schedules completion-based DAILY task with INTERVAL=60", () => {
const task: TaskInfo = {
title: "Every 60 days task",
status: "open",
path: "tasks/test.md",
recurrence: "DTSTART:20260208;FREQ=DAILY;INTERVAL=60",
recurrence_anchor: "completion",
};
const next = getNextUncompletedOccurrence(task);
expect(next).not.toBeNull();
const nextStr = dateUtils.formatDateForStorage(next!);
expect(nextStr).toBe("2026-04-09");
});
it("schedules scheduled-based DAILY task with INTERVAL=60", () => {
const task: TaskInfo = {
title: "Every 60 days scheduled task",
status: "open",
path: "tasks/test.md",
recurrence: "DTSTART:20260101;FREQ=DAILY;INTERVAL=60",
recurrence_anchor: "scheduled",
scheduled: "2026-01-01",
complete_instances: ["2026-01-01"],
};
const next = getNextUncompletedOccurrence(task);
expect(next).not.toBeNull();
const nextStr = dateUtils.formatDateForStorage(next!);
expect(nextStr).toBe("2026-03-02");
});
it("schedules completion-based WEEKLY task with INTERVAL=20", () => {
const task: TaskInfo = {
title: "Every 20 weeks task",
status: "open",
path: "tasks/test.md",
recurrence: "DTSTART:20260208;FREQ=WEEKLY;INTERVAL=20",
recurrence_anchor: "completion",
};
const next = getNextUncompletedOccurrence(task);
expect(next).not.toBeNull();
const nextStr = dateUtils.formatDateForStorage(next!);
expect(nextStr).toBe("2026-06-28");
});
it("still works for DAILY with INTERVAL=1 (no regression)", () => {
const task: TaskInfo = {
title: "Daily task",
status: "open",
path: "tasks/test.md",
recurrence: "DTSTART:20260208;FREQ=DAILY;INTERVAL=1",
recurrence_anchor: "completion",
};
const next = getNextUncompletedOccurrence(task);
expect(next).not.toBeNull();
const nextStr = dateUtils.formatDateForStorage(next!);
expect(nextStr).toBe("2026-02-09");
});
});