Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 6d7cd416dc fix: count to end ccrossing midnight 2026-07-25 17:47:13 +02:00
5 changed files with 151 additions and 35 deletions
@@ -152,10 +152,13 @@ export function computeScopedRundown(
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
// each event only contributes the time it pushes past the latest event so far
// NOTE: timeFromPrevious is negative on overlap, so adding it removes the overlapping part
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
@@ -309,15 +309,18 @@ function processEntry<T extends OntimeEntry>(
entry.gap = getTimeFrom(entry, rundownMetadata.latestEvent);
/**
* The rundown duration is the union of all event intervals, so each event
* only contributes the time it pushes past the latest event so far.
* - gap >= 0: the event contributes its gap plus its full duration
* - gap < 0: the event overlaps, only the part after the latest end counts
* (clamped at 0 for events which are fully contained in a previous one)
* NOTE: gap is negative on overlap, so adding it removes the overlapping part
*/
rundownMetadata.totalDuration += Math.max(entry.duration + entry.gap, 0);
if (entry.gap === 0) {
// event starts on previous finish, we add its duration
rundownMetadata.totalDuration += entry.duration;
} else if (entry.gap > 0) {
// event has a gap, we add the gap and the duration
rundownMetadata.totalDuration += entry.gap + entry.duration;
} else {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
rundownMetadata.totalDuration += Math.max(entry.duration + entry.gap, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10);
});
describe('on timers of type time-to-end', () => {
it('finish time is as schedule + added time', () => {
it('finish time is the fixed end, ignoring added time', () => {
const state = {
eventNow: {
timeEnd: 30,
@@ -245,6 +245,29 @@ describe('getExpectedFinish()', () => {
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
});
it('finish time is the fixed end, including paused time', () => {
const state = {
eventNow: {
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(35);
});
it('handles events that finish the day after', () => {
const state = {
eventNow: {
@@ -404,6 +427,9 @@ describe('getCurrent()', () => {
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -429,6 +455,9 @@ describe('getCurrent()', () => {
it('current time is the time to end', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -451,10 +480,13 @@ describe('getCurrent()', () => {
expect(current).toBe(70);
});
it('current time is the time to end + added time', () => {
it('current time is the time to end, ignoring added time', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
timeEnd: 100,
dayOffset: 0,
countToEnd: true,
},
clock: 30,
@@ -480,8 +512,10 @@ describe('getCurrent()', () => {
const state = {
eventNow: {
timeStart: 79200000, // 22:00:00
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
timeEnd: 600000, // 00:10:00
countToEnd: true,
dayOffset: 0,
},
clock: 79500000, // 22:05:00
timer: {
@@ -492,6 +526,7 @@ describe('getCurrent()', () => {
rundown: {
actualStart: 79200000,
plannedEnd: 600000,
currentDay: 0,
},
_timer: {
pausedAt: null,
@@ -503,6 +538,36 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000);
});
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
duration: 2 * MILLIS_PER_HOUR, // 2h
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -511,6 +576,7 @@ describe('getCurrent()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
timer: {
addedTime: 0,
@@ -1073,7 +1139,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
it('with time-to-end, offset combines overtime and added time', () => {
const state = {
clock: 82000000, // 22:46:40
eventNow: {
@@ -1126,7 +1192,45 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
// overtime (400000) plus the operator's added time (-200000)
expect(absolute).toBe(200000);
});
it('with time-to-end', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
});
it('handles time-to-end started after the end time', () => {
+12 -12
View File
@@ -62,34 +62,34 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/
export function getCurrent(state: RuntimeState): number {
const { timer, eventNow } = state;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
if (eventNow === null || timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received');
}
}
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
if (eventNow.countToEnd) {
const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs;
const eventDayOffset = eventNow.dayOffset * dayInMs;
return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected;
}
if (startedAt === null) {
return duration;
if (timer.startedAt === null) {
return timer.duration;
}
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
return timer.startedAt + timer.duration + timer.addedTime - pausedAt;
}
const hasPassedMidnight = startedAt > clock;
const hasPassedMidnight = timer.startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay;
}
/**
+12 -6
View File
@@ -17,11 +17,17 @@ export function getTimeFrom(
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
/**
* The distance between the current start and the previous end
* - positive: there is a gap between the events
* - zero: the current event starts on the previous end
* - negative: the events overlap
*/
// event is linked to previous
if (normalisedCurrentStart === normalisedPreviousEnd) {
return 0;
}
// event has a gap from previous
if (normalisedCurrentStart > normalisedPreviousEnd) {
// time from previous is difference between start and previous end
return normalisedCurrentStart - normalisedPreviousEnd;
}
// event overlaps with previous
return normalisedCurrentStart - normalisedPreviousEnd;
}