diff --git a/apps/server/src/lib/time-core/__tests__/timeCore.test.ts b/apps/server/src/lib/time-core/__tests__/timeCore.test.ts index efe36f97c..1bfa5352f 100644 --- a/apps/server/src/lib/time-core/__tests__/timeCore.test.ts +++ b/apps/server/src/lib/time-core/__tests__/timeCore.test.ts @@ -260,4 +260,62 @@ describe('daysSinceStart() calculates full days elapsed since a start epoch', () expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1); }); + + describe('handles DST transitions in Europe/Copenhagen', () => { + it('returns 1 when crossing midnight during spring forward (23h day)', () => { + // 2025-03-30: clocks spring forward at 02:00 → 03:00 (23h day) + // Start at 23:00 local on March 29 (22:00 UTC) + vi.setSystemTime('2025-03-29T22:00:00Z'); // 23:00 local CET + const startEpoch = timeCore.now(); + + // Current at 01:00 local on March 30 (00:00 UTC, still before DST) + // This is only 2 hours elapsed, but crosses midnight + vi.setSystemTime('2025-03-30T00:00:00Z'); // 01:00 local CET + const currentEpoch = timeCore.now(); + + // Should be 1 day (crossed midnight) even though <24h elapsed + expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1); + }); + + it('returns 0 when same calendar day spans 25h during fall back', () => { + // 2025-10-26: clocks fall back at 03:00 → 02:00 (25h day) + // Start at 01:00 local on October 26 (23:00 UTC Oct 25, CEST still) + vi.setSystemTime('2025-10-25T23:00:00Z'); // 01:00 local CEST on Oct 26 + const startEpoch = timeCore.now(); + + // Current at 23:00 local on October 26 (22:00 UTC, now CET) + // This is 23 hours elapsed, but same calendar day + vi.setSystemTime('2025-10-26T22:00:00Z'); // 23:00 local CET on Oct 26 + const currentEpoch = timeCore.now(); + + // Should be 0 days (same calendar day) even though 23h elapsed + expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0); + }); + + it('returns 1 when crossing midnight after fall back day', () => { + // Start at 23:00 local on October 26 (22:00 UTC, CET) + vi.setSystemTime('2025-10-26T22:00:00Z'); // 23:00 local CET + const startEpoch = timeCore.now(); + + // Current at 01:00 local on October 27 (00:00 UTC) + vi.setSystemTime('2025-10-27T00:00:00Z'); // 01:00 local CET + const currentEpoch = timeCore.now(); + + expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1); + }); + + it('handles starting just before spring forward', () => { + // Start at 01:58 local on March 30 (00:58 UTC, CET) + vi.setSystemTime('2025-03-30T00:58:00Z'); // 01:58 local CET + const startEpoch = timeCore.now(); + + // Current at 03:02 local on March 30 (01:02 UTC, CEST) + // Clock jumped from ~02:00 to 03:00, only 4 real minutes passed + vi.setSystemTime('2025-03-30T01:02:00Z'); // 03:02 local CEST + const currentEpoch = timeCore.now(); + + // Same calendar day + expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0); + }); + }); }); diff --git a/apps/server/src/lib/time-core/timeCore.ts b/apps/server/src/lib/time-core/timeCore.ts index 4b319f352..269dd2d76 100644 --- a/apps/server/src/lib/time-core/timeCore.ts +++ b/apps/server/src/lib/time-core/timeCore.ts @@ -68,11 +68,15 @@ export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration { } /** - * Calculates the number of full days elapsed since a start epoch - * Uses the start time-of-day to determine day boundaries + * Calculates the number of calendar days elapsed since a start epoch + * Uses local midnight as day boundaries (DST-safe via timezone offset) */ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day { - const startClock = toTimeOfDay(startEpoch); - const elapsedMs = currentEpoch - startEpoch; - return Math.floor((elapsedMs + startClock) / dayInMs) as Day; + const startOffset = new Date(startEpoch).getTimezoneOffset() * MILLIS_PER_MINUTE; + const currentOffset = new Date(currentEpoch).getTimezoneOffset() * MILLIS_PER_MINUTE; + + const startDaySerial = Math.floor((startEpoch - startOffset) / dayInMs); + const currentDaySerial = Math.floor((currentEpoch - currentOffset) / dayInMs); + + return (currentDaySerial - startDaySerial) as Day; } diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 35e48b7f5..37b168c02 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -557,6 +557,36 @@ describe('hasCrossedMidnight()', () => { const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00 expect(hasCrossedMidnight(time, time)).toBe(false); }); + + describe('DST transitions', () => { + it('returns false during DST fall back (~1h backward jump)', () => { + // During fall back, clock goes from 02:59 → 02:00 (1h backward) + const previous = (2 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 02:59 + const current = (2 * MILLIS_PER_HOUR) as TimeOfDay; // 02:00 + expect(hasCrossedMidnight(previous, current)).toBe(false); + }); + + it('returns true at actual midnight (~23h backward jump)', () => { + // Actual midnight crossing: 23:59 → 00:01 (~23h58m backward) + const previous = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59 + const current = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01 + expect(hasCrossedMidnight(previous, current)).toBe(true); + }); + + it('returns false for small backward jumps near midnight boundary', () => { + // Edge case: 12h backward is NOT a midnight cross + const previous = (12 * MILLIS_PER_HOUR) as TimeOfDay; // 12:00 + const current = (0 * MILLIS_PER_HOUR) as TimeOfDay; // 00:00 + expect(hasCrossedMidnight(previous, current)).toBe(false); + }); + + it('returns true for backward jumps exceeding 12h', () => { + // 12h + 1ms backward IS a midnight cross + const previous = (12 * MILLIS_PER_HOUR + 1) as TimeOfDay; // 12:00:00.001 + const current = (0 * MILLIS_PER_HOUR) as TimeOfDay; // 00:00 + expect(hasCrossedMidnight(previous, current)).toBe(true); + }); + }); }); describe('skippedOutOfEvent()', () => { diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 311c187d8..e3764cc7b 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -10,11 +10,12 @@ export const normaliseEndTime = (start: number, end: number) => (end < start ? e /** * Checks whether the local wall clock wrapped into a new day - * This currently uses a simple wrap heuristic and is centralized - * so day-boundary behavior can be evolved in one place later. + * Uses a threshold to distinguish midnight wrap (~23h backward jump) + * from DST fall back (~1h backward jump) */ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boolean { - return previous > current; + const backwardJump = previous - current; + return backwardJump > 12 * MILLIS_PER_HOUR; } /**