mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
1289b73a98
commit
ae8afbeae1
4 changed files with 127 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue