fix(tasks): correct biweekly recurring task week boundary calculation (#6298)

getDiffInWeeks used Math.round(ms / weekMs) which rounded Friday (day 4
of 7) into the next week. Switch to day-first calculation with
Math.floor(days / 7) to respect week boundaries while preserving DST
safety via Math.round on the day count.
This commit is contained in:
Johannes Millan 2026-02-02 14:36:18 +01:00
parent 4eb06b3db6
commit 875341f04a
3 changed files with 103 additions and 4 deletions

View file

@ -335,6 +335,42 @@ describe('getNewestPossibleDueDate()', () => {
);
});
describe('Biweekly with weekdays (#6298)', () => {
// Issue #6298: "Every 2 weeks Mon-Fri" starting Monday Feb 2, 2026
const START_DATE = new Date(2026, 1, 2); // Monday Feb 2, 2026
const biweeklyCfg = (lastCreationDay: string): TaskRepeatCfg =>
dummyRepeatable('BIWEEKLY', {
repeatCycle: 'WEEKLY',
repeatEvery: 2,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
lastTaskCreationDay: lastCreationDay,
});
it('should find Friday of week 0 when searching backward from that Friday', () => {
// Friday Feb 6 is in week 0 (active) — backward search should find it
const cfg = biweeklyCfg('1970-01-01');
testCase(cfg, new Date(2026, 1, 6), START_DATE, new Date(2026, 1, 6)); // Fri Feb 6
});
it('should NOT find Friday of week 1 (off-week)', () => {
// Friday Feb 13 is in week 1 (off) — backward search should find Thu Feb 5 at most
// but since last creation is already Feb 5, it returns null
const cfg = biweeklyCfg(getDbDateStr(new Date(2026, 1, 6)));
testCase(cfg, new Date(2026, 1, 13), START_DATE, null);
});
it('should find Monday of week 2 when today is that Monday', () => {
// Monday Feb 16 is in week 2 (active)
const cfg = biweeklyCfg(getDbDateStr(new Date(2026, 1, 6)));
testCase(cfg, new Date(2026, 1, 16), START_DATE, new Date(2026, 1, 16));
});
});
describe('MONTHLY', () => {
const testCases = [
{

View file

@ -717,6 +717,69 @@ describe('getNextRepeatOccurrence()', () => {
});
});
describe('Biweekly with weekdays (#6298)', () => {
// Issue #6298: "Every 2 weeks Mon-Fri" starting Monday Feb 2, 2026
// Friday was incorrectly shifted to the next week due to Math.round in getDiffInWeeks
const START_DATE = new Date(2026, 1, 2); // Monday Feb 2, 2026
const biweeklyCfg = (lastCreationDay: Date): TaskRepeatCfg =>
dummyRepeatable('BIWEEKLY', {
repeatCycle: 'WEEKLY',
repeatEvery: 2,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
lastTaskCreationDay: getDbDateStr(lastCreationDay),
});
it('should include Friday of week 0 (same week as start)', () => {
// After Thursday Feb 5, next should be Friday Feb 6 (still week 0)
const cfg = biweeklyCfg(new Date(2026, 1, 5)); // Thu Feb 5
testCase(cfg, new Date(2026, 1, 5), START_DATE, new Date(2026, 1, 6)); // Fri Feb 6
});
it('should NOT include any day in week 1 (off-week)', () => {
// After Friday Feb 6 (end of week 0), next should jump to Monday Feb 16 (week 2)
const cfg = biweeklyCfg(new Date(2026, 1, 6)); // Fri Feb 6
testCase(cfg, new Date(2026, 1, 6), START_DATE, new Date(2026, 1, 16)); // Mon Feb 16
});
it('should produce correct 4-week schedule from Monday start', () => {
// Week 0 (active): Mon Feb 2 - Fri Feb 6
// Week 1 (off): no tasks
// Week 2 (active): Mon Feb 16 - Fri Feb 20
// Week 3 (off): no tasks
// Starting from the start date, walk through the schedule
let cfg = biweeklyCfg(new Date(2026, 0, 1)); // before start
testCase(cfg, new Date(2026, 1, 1), START_DATE, new Date(2026, 1, 2)); // Mon Feb 2
cfg = biweeklyCfg(new Date(2026, 1, 2));
testCase(cfg, new Date(2026, 1, 2), START_DATE, new Date(2026, 1, 3)); // Tue Feb 3
cfg = biweeklyCfg(new Date(2026, 1, 3));
testCase(cfg, new Date(2026, 1, 3), START_DATE, new Date(2026, 1, 4)); // Wed Feb 4
cfg = biweeklyCfg(new Date(2026, 1, 4));
testCase(cfg, new Date(2026, 1, 4), START_DATE, new Date(2026, 1, 5)); // Thu Feb 5
cfg = biweeklyCfg(new Date(2026, 1, 5));
testCase(cfg, new Date(2026, 1, 5), START_DATE, new Date(2026, 1, 6)); // Fri Feb 6
// Skip week 1 entirely, jump to week 2
cfg = biweeklyCfg(new Date(2026, 1, 6));
testCase(cfg, new Date(2026, 1, 6), START_DATE, new Date(2026, 1, 16)); // Mon Feb 16
cfg = biweeklyCfg(new Date(2026, 1, 16));
testCase(cfg, new Date(2026, 1, 16), START_DATE, new Date(2026, 1, 17)); // Tue Feb 17
cfg = biweeklyCfg(new Date(2026, 1, 20));
testCase(cfg, new Date(2026, 1, 20), START_DATE, new Date(2026, 2, 2)); // Mon Mar 2 (week 4)
});
});
describe('Edge cases', () => {
it('should return null for unknown repeat cycle', () => {
const cfg = dummyRepeatable('ID1', {

View file

@ -4,8 +4,8 @@ export const getDiffInWeeks = (d1: Date, d2: Date): number => {
// NOTE we want the diff regarding the dates not the absolute one
d1Copy.setHours(0, 0, 0, 0);
d2Copy.setHours(0, 0, 0, 0);
let diff = (d2Copy.getTime() - d1Copy.getTime()) / 1000;
diff /= 60 * 60 * 24 * 7;
// NOTE: we use math round to avoid JS math weirdness and summer winter time problems
return Math.round(diff);
const diffMs = d2Copy.getTime() - d1Copy.getTime();
// Round days to handle DST (167h vs 168h), then floor weeks to respect week boundaries
const diffInDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
return Math.floor(diffInDays / 7);
};