From e543ef43368930fdc61cecd47a6d3fbaa0d037ca Mon Sep 17 00:00:00 2001 From: Julian Maurer Date: Thu, 6 Nov 2025 09:26:05 +0100 Subject: [PATCH] Fix DST causing wrong date to be returned for retrospective switching Additional test cases --- src/__tests__/deadlines-calculator.test.ts | 338 ++++++++++++++++++++- src/calendar-provider.ts | 20 +- src/deadlines-calculator.ts | 6 +- src/utils.ts | 20 +- 4 files changed, 363 insertions(+), 21 deletions(-) diff --git a/src/__tests__/deadlines-calculator.test.ts b/src/__tests__/deadlines-calculator.test.ts index d3a9eb1..9c52add 100644 --- a/src/__tests__/deadlines-calculator.test.ts +++ b/src/__tests__/deadlines-calculator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { DeadlineCalculator } from '../deadlines-calculator' import { Commodity, UseCase } from '../rules/types' import type { SwitchingCase } from '../types' @@ -10,8 +10,8 @@ describe('DeadlineCalculator', () => { calculator = new DeadlineCalculator() }) - describe('Power Deadlines (24h switching)', () => { - it('should calculate power relocation (1 working day)', () => { + describe('Power Deadlines (24h switching) - calculate from input date', () => { + it('should calculate power relocation (1 working day) (input is string)', () => { const switchingCase: SwitchingCase = { commodity: Commodity.POWER, useCase: UseCase.RELOCATION, @@ -24,12 +24,36 @@ describe('DeadlineCalculator', () => { ) expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(1) expect(result.isRetrospective).toBe(false) expect(result.ruleApplied.id).toBe('power_relocation') }) - it('should calculate power switch without termination (1 working day)', () => { + it('should calculate power relocation (1 working day) (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.RELOCATION, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(1) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_relocation') + }) + + it('should calculate power switch without termination (1 working day) (input is string)', () => { const switchingCase: SwitchingCase = { commodity: Commodity.POWER, useCase: UseCase.SWITCH, @@ -42,12 +66,36 @@ describe('DeadlineCalculator', () => { ) expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(1) expect(result.isRetrospective).toBe(false) expect(result.ruleApplied.id).toBe('power_switch_no_termination') }) - it('should calculate power switch with termination (2 working days)', () => { + it('should calculate power switch without termination (1 working day) (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.SWITCH, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(1) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_switch_no_termination') + }) + + it('should calculate power switch with termination (2 working days) (input is string)', () => { const switchingCase: SwitchingCase = { commodity: Commodity.POWER, useCase: UseCase.SWITCH, @@ -61,14 +109,104 @@ describe('DeadlineCalculator', () => { // Should skip Oct 3 (holiday), Oct 4-5 (weekend), so earliest is Oct 7 expect(result.earliestStartDateString).toBe('2025-10-07') // Tuesday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-07T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(2) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_switch_with_termination') + }) + + it('should calculate power switch with termination (2 working days) (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.SWITCH, + requiresTermination: true + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + // Should skip Oct 3 (holiday), Oct 4-5 (weekend), so earliest is Oct 7 + expect(result.earliestStartDateString).toBe('2025-10-07') // Tuesday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-07T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(2) expect(result.isRetrospective).toBe(false) expect(result.ruleApplied.id).toBe('power_switch_with_termination') }) }) - describe('Gas Deadlines', () => { - it('should calculate gas switch without termination (10 working days)', () => { + describe('Power Deadlines (24h switching) - calculate for today', () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(new Date('2025-10-01')) // Wednesday + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should calculate power relocation (1 working day)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.RELOCATION, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(1) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_relocation') + }) + + it('should calculate power switch without termination (1 working day)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.SWITCH, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + expect(result.earliestStartDateString).toBe('2025-10-03') // Friday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-03T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(1) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_switch_no_termination') + }) + + it('should calculate power switch with termination (2 working days)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.POWER, + useCase: UseCase.SWITCH, + requiresTermination: true + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + // Should skip Oct 3 (holiday), Oct 4-5 (weekend), so earliest is Oct 7 + expect(result.earliestStartDateString).toBe('2025-10-07') // Tuesday + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-07T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(2) + expect(result.isRetrospective).toBe(false) + expect(result.ruleApplied.id).toBe('power_switch_with_termination') + }) + }) + + describe('Gas Deadlines - calculate from input date', () => { + it('should calculate gas switch without termination (10 working days) (input is string)', () => { const switchingCase: SwitchingCase = { commodity: Commodity.GAS, useCase: UseCase.SWITCH, @@ -81,6 +219,138 @@ describe('DeadlineCalculator', () => { ) expect(result.earliestStartDateString).toBe('2025-10-17') // Friday (10 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-17T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(10) + expect(result.ruleApplied.id).toBe('gas_switch_no_termination') + }) + + it('should calculate gas switch without termination (10 working days) (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.SWITCH, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + expect(result.earliestStartDateString).toBe('2025-10-17') // Friday (10 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-17T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(10) + expect(result.ruleApplied.id).toBe('gas_switch_no_termination') + }) + + it('should calculate gas switch with termination (13 working days) (input is string)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.SWITCH, + requiresTermination: true + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + '2025-10-01' // Wednesday + ) + + expect(result.earliestStartDateString).toBe('2025-10-22') // Wednesday (13 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-22T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(13) + expect(result.ruleApplied.id).toBe('gas_switch_with_termination') + }) + + it('should calculate gas switch with termination (13 working days) (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.SWITCH, + requiresTermination: true + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + expect(result.earliestStartDateString).toBe('2025-10-22') // Wednesday (13 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-22T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(13) + expect(result.ruleApplied.id).toBe('gas_switch_with_termination') + }) + + it('should allow retrospective switch for gas relocation within 6 weeks (input is string)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.RELOCATION, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + '2025-10-01' // Wednesday + ) + + expect(result.isRetrospective).toBe(true) + expect(result.earliestStartDateString).toBe('2025-08-20') + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-08-20T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(0) + expect(result.ruleApplied.allowsRetrospective).toBe(true) + }) + + it('should allow retrospective switch for gas relocation within 6 weeks (input is date)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.RELOCATION, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate( + switchingCase, + new Date('2025-10-01') // Wednesday + ) + + expect(result.isRetrospective).toBe(true) + expect(result.earliestStartDateString).toBe('2025-08-20') + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-08-20T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(0) + expect(result.ruleApplied.allowsRetrospective).toBe(true) + }) + }) + + describe('Gas Deadlines - calculate for today', () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(new Date('2025-10-01')) // Wednesday + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should calculate gas switch without termination (10 working days)', () => { + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.SWITCH, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + expect(result.earliestStartDateString).toBe('2025-10-17') // Friday (10 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-17T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(10) expect(result.ruleApplied.id).toBe('gas_switch_no_termination') }) @@ -98,6 +368,9 @@ describe('DeadlineCalculator', () => { ) expect(result.earliestStartDateString).toBe('2025-10-22') // Wednesday (13 working days later) + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-10-22T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(13) expect(result.ruleApplied.id).toBe('gas_switch_with_termination') }) @@ -116,6 +389,9 @@ describe('DeadlineCalculator', () => { expect(result.isRetrospective).toBe(true) expect(result.earliestStartDateString).toBe('2025-08-20') + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-08-20T00:00:00+00:00') + ) expect(result.workingDaysApplied).toBe(0) expect(result.ruleApplied.allowsRetrospective).toBe(true) }) @@ -206,6 +482,54 @@ describe('DeadlineCalculator', () => { }) }) + describe('Daylight Saving Time', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should handle the transition from normal time to daylight saving time', () => { + vi.setSystemTime(new Date('2025-04-25')) // Tuesday + + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.SWITCH, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + expect(result.earliestStartDateString).toBe('2025-05-14') + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-05-14T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(10) + }) + + it('should handle the transition from daylight saving to normal time', () => { + vi.setSystemTime(new Date('2025-11-03')) // Monday + + const switchingCase: SwitchingCase = { + commodity: Commodity.GAS, + useCase: UseCase.RELOCATION, + requiresTermination: false + } + + const result = calculator.calculateEarliestStartDate(switchingCase) + + expect(result.isRetrospective).toBe(true) + expect(result.earliestStartDateString).toBe('2025-09-22') + expect(result.earliestStartDate).toStrictEqual( + new Date('2025-09-22T00:00:00+00:00') + ) + expect(result.workingDaysApplied).toBe(0) + expect(result.ruleApplied.allowsRetrospective).toBe(true) + }) + }) + describe('Edge Cases', () => { it('should handle year boundary transitions', () => { const switchingCase: SwitchingCase = { diff --git a/src/calendar-provider.ts b/src/calendar-provider.ts index 9a5a880..d01ffeb 100644 --- a/src/calendar-provider.ts +++ b/src/calendar-provider.ts @@ -1,4 +1,4 @@ -import { addDays, isSunday, isWeekend } from 'date-fns' +import { isSunday, isWeekend } from 'date-fns' import type { Holiday, DayInfo, CustomHolidayConfig } from './holidays' import { getFixedHolidays, @@ -8,7 +8,7 @@ import { filterHolidaysForYear } from './holidays' import type { CalendarVersion } from './types' -import { normalizeDate, toISODateString } from './utils' +import { addDaysDstSafe, normalizeDate, toISODateString } from './utils' import versionInfo from './version.json' with { type: 'json' } type HolidayMap = Map @@ -297,14 +297,14 @@ export class CalendarProvider { let workingDaysAdded = 0 while (workingDaysAdded < workingDays) { - date = addDays(date, 1) + date = addDaysDstSafe(date, 1) if (this.isWorkingDay(date)) { workingDaysAdded++ } } // return the next day - date = addDays(date, 1) + date = addDaysDstSafe(date, 1) return date } @@ -340,7 +340,7 @@ export class CalendarProvider { if (dayInfo.isWorkingDay) { days.push(dayInfo) } - current = addDays(current, 1) + current = addDaysDstSafe(current, 1) } return days @@ -379,7 +379,7 @@ export class CalendarProvider { if (!dayInfo.isWorkingDay) { days.push(dayInfo) } - current = addDays(current, 1) + current = addDaysDstSafe(current, 1) } return days @@ -426,10 +426,10 @@ export class CalendarProvider { */ public getNextWorkingDay(date: Date | string): Date { let nextDay = normalizeDate(date) - nextDay = addDays(nextDay, 1) + nextDay = addDaysDstSafe(nextDay, 1) while (!this.isWorkingDay(nextDay)) { - nextDay = addDays(nextDay, 1) + nextDay = addDaysDstSafe(nextDay, 1) } return nextDay @@ -453,10 +453,10 @@ export class CalendarProvider { */ public getPreviousWorkingDay(date: Date | string): Date { let prevDay = normalizeDate(date) - prevDay = addDays(prevDay, -1) + prevDay = addDaysDstSafe(prevDay, -1) while (!this.isWorkingDay(prevDay)) { - prevDay = addDays(prevDay, -1) + prevDay = addDaysDstSafe(prevDay, -1) } return prevDay diff --git a/src/deadlines-calculator.ts b/src/deadlines-calculator.ts index e76128c..125ae50 100644 --- a/src/deadlines-calculator.ts +++ b/src/deadlines-calculator.ts @@ -1,4 +1,4 @@ -import { differenceInDays, subDays } from 'date-fns' +import { differenceInDays } from 'date-fns' import { CalendarProvider } from './calendar-provider' import { DEFAULT_DEADLINE_RULES, @@ -6,7 +6,7 @@ import { type DeadlineRule } from './rules' import type { SwitchingCase } from './types' -import { normalizeDate, toISODateString } from './utils' +import { normalizeDate, subDaysDstSafe, toISODateString } from './utils' /** * Options for configuring a {@link DeadlineCalculator}. @@ -129,7 +129,7 @@ export class DeadlineCalculator { if (rule.allowsRetrospective) { // Handle switching cases with retrospective switching isRetrospective = true - earliestStartDate = subDays(from, rule.maxRetrospectiveDays ?? 0) + earliestStartDate = subDaysDstSafe(from, rule.maxRetrospectiveDays ?? 0) } else { // Normal forward calculation isRetrospective = false diff --git a/src/utils.ts b/src/utils.ts index d8f30e6..dc555c7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { addDays, addMinutes, subDays } from 'date-fns' + export const normalizeDate = (date: Date | string): Date => { const result = typeof date === 'string' @@ -12,5 +14,21 @@ export const normalizeDate = (date: Date | string): Date => { } export const toISODateString = (date: Date): string => { - return date.toISOString().split('T')[0] + return normalizeDate(date).toISOString().split('T')[0] +} + +export function addDaysDstSafe(date: Date, amount: number) { + const endDate = addDays(date, amount) + return addMinutes( + endDate, + date.getTimezoneOffset() - endDate.getTimezoneOffset() + ) +} + +export function subDaysDstSafe(date: Date, amount: number) { + const endDate = subDays(date, amount) + return addMinutes( + endDate, + date.getTimezoneOffset() - endDate.getTimezoneOffset() + ) }