From c169c419c32c40c9d2cdb1a0d40c3857f7c7346d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 08:07:33 +0000 Subject: [PATCH] fix(timer): correct count-to-end expected end across modes and days The first pass pinned the count-to-end end with `Math.max(expectedStart, plannedEnd)`, but those operands live in different coordinate spaces: `getExpectedStart` bakes in the day shift, the relative-start shift and the live runtime offset, while `plannedEnd` was raw time-of-day. The result was only correct in single-day Absolute mode and drifted in Relative mode and across day boundaries. A count-to-end event is anchored to its fixed wall-clock end, so its expected end is the scheduled end normalised for the day, with no runtime offset and no relative-start shift (matching getExpectedFinish, which returns the raw timeEnd). Compute it directly as `normalisedTimeStart + duration`. Add relative-mode and multi-day unit tests covering the cases the previous formula got wrong. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm --- .../src/date-utils/getExpectedStart.test.ts | 40 +++++++++++++++++++ .../utils/src/date-utils/getExpectedStart.ts | 18 +++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/utils/src/date-utils/getExpectedStart.test.ts b/packages/utils/src/date-utils/getExpectedStart.test.ts index b97e78ac0..887773fd6 100644 --- a/packages/utils/src/date-utils/getExpectedStart.test.ts +++ b/packages/utils/src/date-utils/getExpectedStart.test.ts @@ -381,4 +381,44 @@ describe('getExpectedEnd()', () => { expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration); }); + + test('a countToEnd event is NOT shifted by the relative-start offset', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 0 as Day, + countToEnd: true, + }; + + // in relative mode a regular event would be shifted by actualStart - plannedStart (+30), + // but a countToEnd event is anchored to its wall-clock end and stays at 150 + const relativeState = { + ...baseState, + mode: OffsetMode.Relative, + actualStart: 30, + plannedStart: 0, + offset: 0, + }; + + // sanity: a regular event in the same state is shifted to 180 + expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180); + // the countToEnd event is not shifted + expect(getExpectedEnd(testEvent, relativeState)).toBe(150); + }); + + test('a countToEnd event on a later day adds the day offset', () => { + const testEvent = { + timeStart: 100, + duration: 50, + delay: 0, + dayOffset: 1 as Day, + countToEnd: true, + }; + + // dayOffset 1 with currentDay 0 -> end normalised one day forward + 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); + }); }); diff --git a/packages/utils/src/date-utils/getExpectedStart.ts b/packages/utils/src/date-utils/getExpectedStart.ts index 1bf46a598..30f8ddd0a 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpectedStart.ts @@ -63,17 +63,21 @@ export function getExpectedStart( /** * Computes the normalised expected end of an event. - * A countToEnd event ends at its fixed end time regardless of how much overtime - * precedes it, so its expected end is pinned to the planned end - it absorbs the - * accumulated offset (mirrors getExpectedFinish in the running timer). The end can - * only drift later if the event is projected to start after its own end time. + * A countToEnd event is anchored to its fixed wall-clock end: it absorbs the accumulated + * runtime offset and is not shifted by the relative-start offset, so its expected end is the + * scheduled end normalised for the day (mirrors getExpectedFinish in the running timer, which + * returns the raw timeEnd). A regular event's end moves with the runtime offset. * The result lives in the same day-normalised space as getExpectedStart (it may exceed dayInMs). */ export function getExpectedEnd( event: Pick, state: Parameters[1], ): number { - const expectedStart = getExpectedStart(event, state); - const plannedEnd = event.timeStart + event.duration + event.delay; - return event.countToEnd ? Math.max(expectedStart, plannedEnd) : expectedStart + event.duration; + if (!event.countToEnd) { + return getExpectedStart(event, state) + event.duration; + } + + const delayedStart = Math.max(0, event.timeStart + event.delay); + const relativeDayOffset = event.dayOffset - state.currentDay; + return delayedStart + relativeDayOffset * dayInMs + event.duration; }