From 05c000118b7a52a60085dffbe04be510fd5fc944 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 09:37:11 +0000 Subject: [PATCH] fix(timer): keep day offset on count-to-end scheduled end getExpectedEnd compared the offset-laden expectedStart against a raw `timeStart + duration + delay` planned end. That planned end omits the `(dayOffset - currentDay) * dayInMs` day shift that expectedStart already carries, so for a count-to-end event on a later day the Math.max guard returned the day-shifted start instead of the day-shifted end (wrong by one duration). Compute the scheduled end in the same day-normalised space before the guard. The guard itself (finish on schedule unless the start is compromised) is unchanged. Add unit tests for the multi-day case, the compromised-start guard, and relative mode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm --- .../utils/src/date-utils/getExpected.test.ts | 53 +++++++++++++++++++ packages/utils/src/date-utils/getExpected.ts | 9 ++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/utils/src/date-utils/getExpected.test.ts b/packages/utils/src/date-utils/getExpected.test.ts index 51352dd15..de6a8a07c 100644 --- a/packages/utils/src/date-utils/getExpected.test.ts +++ b/packages/utils/src/date-utils/getExpected.test.ts @@ -381,4 +381,57 @@ describe('getExpectedEnd()', () => { expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration); }); + + test('a countToEnd event drifts when the start is compromised', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + // the offset pushes the start (160) past the scheduled end (150) so it can no longer + // finish on schedule - the end follows the compromised start + expect(getExpectedEnd(testEvent, { ...baseState, offset: 60 })).toBe(160); + }); + + test('a countToEnd event on a later day keeps the day offset on the end', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 1 as Day, + countToEnd: true, + }; + + // the scheduled end must include the day offset (delayedStart + dayInMs + duration), + // not collapse to the day-shifted start + expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs); + // when the running event is already on the same day, no extra day is added + expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150); + }); + + test('a countToEnd event is anchored to its wall-clock end in relative mode', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + const relativeState = { + ...baseState, + mode: OffsetMode.Relative, + actualStart: 30, + plannedStart: 0, + offset: 0, + }; + + // a regular event in the same state is shifted by the relative-start offset to 180 + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180); + // the countToEnd event stays pinned to its wall-clock end (150), not shifted + expect(getExpectedEnd(testEvent, relativeState)).toBe(150); + }); }); diff --git a/packages/utils/src/date-utils/getExpected.ts b/packages/utils/src/date-utils/getExpected.ts index f6e6359a9..f89869ac1 100644 --- a/packages/utils/src/date-utils/getExpected.ts +++ b/packages/utils/src/date-utils/getExpected.ts @@ -85,9 +85,12 @@ export function getExpectedEnd( // count to end events should finish on schedule unlesss the start is compromised if (event.countToEnd) { - // count to end take scheduled delays into consideration - const plannedEnd = event.timeStart + event.duration + event.delay; - return Math.max(expectedStart, plannedEnd); + // the scheduled end, normalised to the same day-space as expectedStart + // (a raw timeStart + duration would miss the day offset on multi-day rundowns) + const delayedStart = Math.max(0, event.timeStart + event.delay); + const relativeDayOffset = event.dayOffset - state.currentDay; + const scheduledEnd = delayedStart + relativeDayOffset * dayInMs + event.duration; + return Math.max(expectedStart, scheduledEnd); } return expectedStart + event.duration;