fix: align PAST/NEXT relative date filter for WEEK/MONTH/YEAR to use calendar-aligned periods like QUARTER

https://sonarly.com/issue/26866?type=bug

The PAST and NEXT relative date filter directions use two different calculation strategies: QUARTER uses calendar-aligned periods (start of quarter boundary), while WEEK, MONTH, and YEAR use a rolling window from the current day. This makes it impossible to filter by "last calendar week" or "last calendar month".

Fix: ## What changed

Aligned PAST and NEXT relative date filter directions to use calendar-aligned periods for all units (WEEK, MONTH, YEAR, QUARTER), consistent with how THIS direction already worked and how QUARTER was specially handled.

### `resolveRelativeDateFilter.ts`
- **NEXT direction**: Replaced the QUARTER-specific special case and the rolling-window fallback with a single unified path using `getNextPeriodStart()` for all units. For example, "Next 1 Week" now returns the start of next calendar week to the end of next calendar week, instead of tomorrow to tomorrow+7 days.
- **PAST direction**: Same approach — replaced QUARTER special case and rolling fallback with `getPeriodStart()` for all units. "Past 1 Week" now returns previous calendar week boundaries instead of the last 7 days.
- **DAY behavior is preserved**: `getPeriodStart('DAY')` returns `startOfDay()` and `getNextPeriodStart('DAY')` returns `startOfDay() + 1 day`, producing identical results to the old rolling-window code.

### `resolveRelativeDateTimeFilter.ts`
- Same fix applied, but preserving the sub-day unit (SECOND, MINUTE, HOUR) rolling behavior since those don't have calendar period boundaries.
- Removed QUARTER special case and replaced the non-sub-day fallback with `getNextPeriodStart()`/`getPeriodStart()` calls.

### Tests
- Added test cases for PAST 1 WEEK, PAST 1 MONTH, PAST 1 YEAR, NEXT 1 WEEK, NEXT 1 MONTH, NEXT 1 YEAR in both test files to verify calendar-aligned behavior.
- Existing DAY and QUARTER tests remain unchanged and continue to pass.

### Not changed
- The workflow filter utility (`parse-and-evaluate-relative-date-filter.util.ts`) has the same conceptual bug but uses `date-fns` and already has a TODO to merge with the shared logic. It is not part of this fix to keep scope focused.
This commit is contained in:
Sonarly Claude Code 2026-04-16 00:58:08 +00:00
parent cddc47b61f
commit 337685e759
4 changed files with 157 additions and 108 deletions

View file

@ -16,6 +16,37 @@ describe('resolveRelativeDateFilter', () => {
expect(result.end).toBe('2024-03-23');
});
it('should compute calendar-aligned start and end for NEXT 1 WEEK', () => {
const result = resolveRelativeDateFilter(
{ direction: 'NEXT', amount: 1, unit: 'WEEK' },
referenceZdt,
);
// Mar 15 is Friday, next week starts Monday Mar 18
expect(result.start).toBe('2024-03-18');
expect(result.end).toBe('2024-03-25');
});
it('should compute calendar-aligned start and end for NEXT 1 MONTH', () => {
const result = resolveRelativeDateFilter(
{ direction: 'NEXT', amount: 1, unit: 'MONTH' },
referenceZdt,
);
expect(result.start).toBe('2024-04-01');
expect(result.end).toBe('2024-05-01');
});
it('should compute calendar-aligned start and end for NEXT 1 YEAR', () => {
const result = resolveRelativeDateFilter(
{ direction: 'NEXT', amount: 1, unit: 'YEAR' },
referenceZdt,
);
expect(result.start).toBe('2025-01-01');
expect(result.end).toBe('2026-01-01');
});
it('should throw if amount is undefined', () => {
expect(() =>
resolveRelativeDateFilter(
@ -37,6 +68,39 @@ describe('resolveRelativeDateFilter', () => {
expect(result.end).toBe('2024-03-15');
});
it('should compute calendar-aligned start and end for PAST 1 WEEK', () => {
const result = resolveRelativeDateFilter(
{ direction: 'PAST', amount: 1, unit: 'WEEK' },
referenceZdt,
);
// Mar 15 is Friday, current week starts Monday Mar 11, past 1 week = Mar 4-11
expect(result.start).toBe('2024-03-04');
expect(result.end).toBe('2024-03-11');
});
it('should compute calendar-aligned start and end for PAST 1 MONTH', () => {
const result = resolveRelativeDateFilter(
{ direction: 'PAST', amount: 1, unit: 'MONTH' },
referenceZdt,
);
// Current month starts Mar 1, past 1 month = Feb 1-Mar 1
expect(result.start).toBe('2024-02-01');
expect(result.end).toBe('2024-03-01');
});
it('should compute calendar-aligned start and end for PAST 1 YEAR', () => {
const result = resolveRelativeDateFilter(
{ direction: 'PAST', amount: 1, unit: 'YEAR' },
referenceZdt,
);
// Current year starts Jan 1 2024, past 1 year = Jan 1 2023-Jan 1 2024
expect(result.start).toBe('2023-01-01');
expect(result.end).toBe('2024-01-01');
});
it('should throw if amount is undefined', () => {
expect(() =>
resolveRelativeDateFilter(

View file

@ -26,6 +26,31 @@ describe('resolveRelativeDateTimeFilter', () => {
expect(result.end?.day).toBe(23);
});
it('should compute calendar-aligned for NEXT 1 WEEK', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'NEXT', amount: 1, unit: 'WEEK' },
referenceZdt,
);
// Mar 15 is Friday, next week starts Monday Mar 18
expect(result.start?.month).toBe(3);
expect(result.start?.day).toBe(18);
expect(result.end?.month).toBe(3);
expect(result.end?.day).toBe(25);
});
it('should compute calendar-aligned for NEXT 1 MONTH', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'NEXT', amount: 1, unit: 'MONTH' },
referenceZdt,
);
expect(result.start?.month).toBe(4);
expect(result.start?.day).toBe(1);
expect(result.end?.month).toBe(5);
expect(result.end?.day).toBe(1);
});
it('should compute for NEXT 1 QUARTER', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'NEXT', amount: 1, unit: 'QUARTER' },
@ -70,6 +95,32 @@ describe('resolveRelativeDateTimeFilter', () => {
expect(result.start?.day).toBe(12);
});
it('should compute calendar-aligned for PAST 1 WEEK', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'PAST', amount: 1, unit: 'WEEK' },
referenceZdt,
);
// Mar 15 is Friday, current week starts Monday Mar 11, past 1 week = Mar 4-11
expect(result.start?.month).toBe(3);
expect(result.start?.day).toBe(4);
expect(result.end?.month).toBe(3);
expect(result.end?.day).toBe(11);
});
it('should compute calendar-aligned for PAST 1 MONTH', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'PAST', amount: 1, unit: 'MONTH' },
referenceZdt,
);
// Current month starts Mar 1, past 1 month = Feb 1-Mar 1
expect(result.start?.month).toBe(2);
expect(result.start?.day).toBe(1);
expect(result.end?.month).toBe(3);
expect(result.end?.day).toBe(1);
});
it('should compute for PAST 1 QUARTER', () => {
const result = resolveRelativeDateTimeFilter(
{ direction: 'PAST', amount: 1, unit: 'QUARTER' },

View file

@ -19,47 +19,20 @@ export const resolveRelativeDateFilter = (
throw new Error('Amount is required');
}
if (unit === 'QUARTER') {
const startOfCurrentQuarter = getPeriodStart(
referenceTodayZonedDateTime,
'QUARTER',
firstDayOfTheWeek,
);
const startOfNextPeriod = getNextPeriodStart(
referenceTodayZonedDateTime,
unit,
firstDayOfTheWeek,
);
const startOfNextPeriod = addUnitToZonedDateTime(
startOfCurrentQuarter,
'QUARTER',
1,
);
const endOfNextPeriod = addUnitToZonedDateTime(
startOfNextPeriod,
'QUARTER',
amount,
);
const start = startOfNextPeriod.toPlainDate().toString();
const end = endOfNextPeriod.toPlainDate().toString();
return {
...relativeDateFilter,
start,
end,
};
}
const startOfNextDay = referenceTodayZonedDateTime
.startOfDay()
.add({ days: 1 });
const startOfNextPeriod = addUnitToZonedDateTime(
startOfNextDay,
const endOfNextPeriod = addUnitToZonedDateTime(
startOfNextPeriod,
unit,
amount,
);
const start = startOfNextDay.toPlainDate().toString();
const end = startOfNextPeriod?.toPlainDate().toString();
const start = startOfNextPeriod.toPlainDate().toString();
const end = endOfNextPeriod.toPlainDate().toString();
return {
...relativeDateFilter,
@ -72,39 +45,20 @@ export const resolveRelativeDateFilter = (
throw new Error('Amount is required');
}
if (unit === 'QUARTER') {
const startOfCurrentQuarter = getPeriodStart(
referenceTodayZonedDateTime,
'QUARTER',
firstDayOfTheWeek,
);
const startOfCurrentPeriod = getPeriodStart(
referenceTodayZonedDateTime,
unit,
firstDayOfTheWeek,
);
const startOfPastPeriod = subUnitFromZonedDateTime(
startOfCurrentQuarter,
'QUARTER',
amount,
);
const start = startOfPastPeriod.toPlainDate().toString();
const end = startOfCurrentQuarter.toPlainDate().toString();
return {
...relativeDateFilter,
start,
end,
};
}
const startOfDay = referenceTodayZonedDateTime.startOfDay();
const startOfNextPeriod = subUnitFromZonedDateTime(
startOfDay,
const startOfPastPeriod = subUnitFromZonedDateTime(
startOfCurrentPeriod,
unit,
amount,
);
const start = startOfNextPeriod?.toPlainDate().toString();
const end = startOfDay.toPlainDate().toString();
const start = startOfPastPeriod.toPlainDate().toString();
const end = startOfCurrentPeriod.toPlainDate().toString();
return {
...relativeDateFilter,

View file

@ -20,70 +20,50 @@ export const resolveRelativeDateTimeFilter = (
throw new Error('Amount is required');
}
if (unit === 'QUARTER') {
const startOfNextQuarter = getNextPeriodStart(
referenceZonedDateTime,
'QUARTER',
);
return {
...relativeDateFilter,
start: startOfNextQuarter,
end: addUnitToZonedDateTime(startOfNextQuarter, unit, amount),
};
}
if (isSubDayUnit) {
return {
...relativeDateFilter,
start: referenceZonedDateTime,
end: addUnitToZonedDateTime(referenceZonedDateTime, unit, amount),
};
} else {
const startOfNextDay = referenceZonedDateTime
.startOfDay()
.add({ days: 1 });
return {
...relativeDateFilter,
start: startOfNextDay,
end: addUnitToZonedDateTime(startOfNextDay, unit, amount),
};
}
const startOfNextPeriod = getNextPeriodStart(
referenceZonedDateTime,
unit,
firstDayOfTheWeek,
);
return {
...relativeDateFilter,
start: startOfNextPeriod,
end: addUnitToZonedDateTime(startOfNextPeriod, unit, amount),
};
}
case 'PAST': {
if (!isDefined(amount)) {
throw new Error('Amount is required');
}
if (unit === 'QUARTER') {
const startOfCurrentQuarter = getPeriodStart(
referenceZonedDateTime,
'QUARTER',
);
return {
...relativeDateFilter,
start: subUnitFromZonedDateTime(startOfCurrentQuarter, unit, amount),
end: startOfCurrentQuarter,
};
}
if (isSubDayUnit) {
return {
...relativeDateFilter,
start: subUnitFromZonedDateTime(referenceZonedDateTime, unit, amount),
end: referenceZonedDateTime,
};
} else {
const startOfDay = referenceZonedDateTime.startOfDay();
return {
...relativeDateFilter,
start: subUnitFromZonedDateTime(startOfDay, unit, amount),
end: startOfDay,
};
}
const startOfCurrentPeriod = getPeriodStart(
referenceZonedDateTime,
unit,
firstDayOfTheWeek,
);
return {
...relativeDateFilter,
start: subUnitFromZonedDateTime(startOfCurrentPeriod, unit, amount),
end: startOfCurrentPeriod,
};
}
case 'THIS':
return {