mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 00:43:54 +00:00
fix: backdate rundown start when rolling
This commit is contained in:
committed by
Carlos Valente
parent
5ff7861968
commit
6f1f10ccff
@@ -16,6 +16,7 @@ import {
|
||||
Trigger,
|
||||
URLPreset,
|
||||
ViewSettings,
|
||||
Day,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
customFieldLabelToKey,
|
||||
@@ -393,7 +394,7 @@ export function migrateRundown(
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: -1,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
});
|
||||
} else if (entry.type === 'block') {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isOntimeMilestone,
|
||||
OntimeMilestone,
|
||||
OntimeGroup,
|
||||
Day,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
isObjectEmpty,
|
||||
@@ -281,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
|
||||
sanitiseCustomFields(customFields, currentEntry);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays;
|
||||
currentEntry.dayOffset = processedData.totalDays as Day;
|
||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.parent = childOfGroup;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
import { Duration, Instant } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
|
||||
import { Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||
|
||||
import { timeNow } from '../../../utils/time.js';
|
||||
import * as timeCore from '../timeCore.js';
|
||||
@@ -183,3 +183,81 @@ describe('addDuration() moves a point in time by a duration', () => {
|
||||
expect(timeCore.addDuration(instant, [])).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elapsedTime() calculates duration between two times of day', () => {
|
||||
it('calculates elapsed time on the same day', () => {
|
||||
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
|
||||
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('calculates elapsed time when crossing midnight (overnight)', () => {
|
||||
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
|
||||
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
|
||||
// From 23:50 to 00:21 = 31 minutes
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('returns 0 when start and clock are the same', () => {
|
||||
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
|
||||
expect(timeCore.elapsedTime(time, time)).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates correctly for just after midnight', () => {
|
||||
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
||||
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
||||
// From 23:59 to 00:01 = 2 minutes
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
|
||||
it('returns 0 when current epoch equals start epoch', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const epoch = timeCore.now();
|
||||
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when less than one day has elapsed', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 when crossing midnight once', () => {
|
||||
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
|
||||
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 when crossing midnight twice', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
|
||||
});
|
||||
|
||||
it('handles overnight start correctly', () => {
|
||||
// Copenhagen is UTC+1 in winter
|
||||
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
|
||||
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Instant, TimeOfDay, Duration } from 'ontime-types';
|
||||
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
/** Returns the current instant */
|
||||
@@ -58,3 +58,21 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
|
||||
|
||||
return (instant + totalDuration) as Instant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates elapsed time on the clock from a starting time to the current time
|
||||
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
|
||||
*/
|
||||
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
|
||||
return (current < start ? current + dayInMs - start : current - start) as Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of full days elapsed since a start epoch
|
||||
* Uses the start time-of-day to determine day boundaries
|
||||
*/
|
||||
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
|
||||
const startClock = toTimeOfDay(startEpoch);
|
||||
const elapsedMs = currentEpoch - startEpoch;
|
||||
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { DatabaseModel, Day, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
|
||||
export const demoDb: DatabaseModel = {
|
||||
rundowns: {
|
||||
@@ -66,7 +66,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Music plays, holding slide on screens',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1',
|
||||
parent: '7eaf99',
|
||||
@@ -109,7 +109,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Emma Thompson',
|
||||
colour: '#FFCC78',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1.1',
|
||||
parent: '7eaf99',
|
||||
@@ -141,7 +141,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Liam Carter, Sophia Patel + PowerPoint',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1.2',
|
||||
parent: '7eaf99',
|
||||
@@ -199,7 +199,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Buffet in lobby',
|
||||
colour: '#779BE7',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '2.1',
|
||||
parent: 'f60403',
|
||||
@@ -257,7 +257,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Ethan Brooks + PowerPoint + Video playback',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '3.1',
|
||||
parent: '6b0edb',
|
||||
@@ -289,7 +289,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Lucas Bennett',
|
||||
colour: '#FFCC78',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '3.2',
|
||||
parent: '6b0edb',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
||||
import { Day, MaybeNumber, TimerPhase } from 'ontime-types';
|
||||
import { checkIsNow, dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
@@ -188,9 +188,9 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
* Finds the day offset relative to an event start
|
||||
* used byt the runtimeState on first start to get correct offsets
|
||||
*/
|
||||
export function findDayOffset(plannedStart: number, clock: number): number {
|
||||
export function findDayOffset(plannedStart: number, clock: number): Day {
|
||||
const distance = clock - plannedStart;
|
||||
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
|
||||
if (distance < -12 * MILLIS_PER_HOUR) return 1;
|
||||
return 0;
|
||||
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
|
||||
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
||||
return 0 as Day;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Instant, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
import { Instant, PlayableEvent, Playback, SupportedEntry, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
@@ -575,6 +575,95 @@ describe('roll mode', () => {
|
||||
expect(stateAfterMidnight.timer.secondaryTimer).toBe(50 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('rolling into overnight event after midnight has correct offset and expected times', async () => {
|
||||
// Simulates a rundown with:
|
||||
// - A group starting at 13:00, containing an overnight event
|
||||
// - Overnight event: 23:50 to 01:50 (2 hours)
|
||||
// Roll into the event at 00:21 (31 minutes into the event)
|
||||
const groupId = 'group-1';
|
||||
const eventId = 'event-1';
|
||||
const eventStart = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
||||
const eventEnd = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
||||
const eventDuration = 2 * MILLIS_PER_HOUR; // 2 hours
|
||||
const groupStart = 13 * MILLIS_PER_HOUR; // 13:00
|
||||
const groupDuration = 12 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 12h 50m (ends at 01:50)
|
||||
|
||||
const mockRundown = makeRundown({
|
||||
entries: {
|
||||
[groupId]: {
|
||||
id: groupId,
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Test Group',
|
||||
timeStart: groupStart,
|
||||
timeEnd: eventEnd,
|
||||
duration: groupDuration,
|
||||
entries: [eventId],
|
||||
colour: '',
|
||||
note: '',
|
||||
custom: {},
|
||||
revision: 0,
|
||||
isFirstLinked: false,
|
||||
targetDuration: null,
|
||||
},
|
||||
[eventId]: {
|
||||
...mockEvent,
|
||||
id: eventId,
|
||||
timeStart: eventStart,
|
||||
timeEnd: eventEnd,
|
||||
duration: eventDuration,
|
||||
dayOffset: 0,
|
||||
parent: groupId,
|
||||
},
|
||||
},
|
||||
order: [groupId],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { rundown, metadata } = rundownCache.get();
|
||||
|
||||
// Roll into the event AFTER midnight at 00:21 (31 minutes into the 2-hour event)
|
||||
const rollTime = 21 * MILLIS_PER_MINUTE; // 00:21
|
||||
vi.setSystemTime('jan 2 00:21');
|
||||
const result = roll(rundown, metadata);
|
||||
|
||||
expect(result.eventId).toBe(eventId);
|
||||
expect(result.didStart).toBe(true);
|
||||
|
||||
// Call update to recalculate all state (like runtime service does)
|
||||
update();
|
||||
|
||||
const state = getState();
|
||||
|
||||
// currentDay should be 1 (we're on the next day after midnight)
|
||||
expect(state.rundown.currentDay).toBe(1);
|
||||
|
||||
// offset.absolute should be 0 (event started on time, backdated to planned start)
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
|
||||
// Timer should show correct remaining time
|
||||
// Event started at 23:50, duration is 2 hours, clock is 00:21
|
||||
// Time elapsed = 31 minutes, time remaining = 2h - 31m = 1h 29m = 89 minutes
|
||||
const expectedRemaining = eventDuration - (rollTime + (24 * MILLIS_PER_HOUR - eventStart));
|
||||
expect(state.timer.current).toBe(expectedRemaining);
|
||||
|
||||
// expectedFinish should be 01:50 (event end time)
|
||||
expect(state.timer.expectedFinish).toBe(eventEnd);
|
||||
|
||||
// expectedRundownEnd should be 01:50 (same as event end, only one event)
|
||||
expect(state.offset.expectedRundownEnd).toBe(eventEnd);
|
||||
|
||||
// expectedGroupEnd should be 01:50 (group ends when the event ends)
|
||||
expect(state.offset.expectedGroupEnd).toBe(eventEnd);
|
||||
|
||||
// Verify timer started at correct backdated time
|
||||
expect(state.timer.startedAt).toBe(eventStart);
|
||||
|
||||
// Verify actualStart is backdated to planned start
|
||||
expect(state.rundown.actualStart).toBe(eventStart);
|
||||
});
|
||||
|
||||
test('rundown loops back after finishing and resets currentDay when first event starts again', async () => {
|
||||
// Rundown: 09:00-23:00, then overnight event 23:00-01:00
|
||||
const mockRundown = makeRundown({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
Day,
|
||||
Duration,
|
||||
isOntimeEvent,
|
||||
Instant,
|
||||
MaybeNumber,
|
||||
@@ -69,7 +71,7 @@ export type RuntimeState = {
|
||||
_flag: ExpectedMetadata;
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: MaybeNumber;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
@@ -435,7 +437,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
state.timer.elapsed = 0;
|
||||
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset;
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
state.rundown.currentDay = state._startDayOffset;
|
||||
state._startEpoch = epoch;
|
||||
state.rundown.actualStart = state.clock;
|
||||
@@ -688,12 +690,21 @@ export function roll(
|
||||
runtimeState.rundown.actualGroupStart = plannedStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* we need to backdate the actual start and start metadata
|
||||
* to prevent adding unintended offset
|
||||
*/
|
||||
if (runtimeState.rundown.actualStart === null) {
|
||||
runtimeState.rundown.actualStart = plannedStart;
|
||||
runtimeState._startDayOffset =
|
||||
findDayOffset(runtimeState.eventNow.timeStart, runtimeState.clock) + runtimeState.eventNow.dayOffset;
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset;
|
||||
runtimeState._startEpoch = epoch;
|
||||
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||
runtimeState.eventNow.dayOffset) as Day;
|
||||
// backdate _startEpoch to when the event conceptually started
|
||||
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||
// calculate currentDay from the backdated epoch
|
||||
runtimeState.rundown.currentDay =
|
||||
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
}
|
||||
} else {
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||
@@ -789,10 +800,15 @@ export function roll(
|
||||
}
|
||||
|
||||
// update metadata
|
||||
runtimeState._startDayOffset =
|
||||
findDayOffset(runtimeState.eventNow.timeStart, runtimeState.clock) + runtimeState.eventNow.dayOffset;
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset;
|
||||
runtimeState._startEpoch = epoch;
|
||||
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||
runtimeState.eventNow.dayOffset) as Day;
|
||||
// backdate _startEpoch to when the event conceptually started
|
||||
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||
// calculate currentDay from the backdated epoch
|
||||
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
|
||||
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
|
||||
|
||||
return { eventId: runtimeState.eventNow.id, didStart: true };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TimerType } from '../TimerType.type.js';
|
||||
import type { TimeStrategy } from '../TimeStrategy.type.js';
|
||||
import type { Trigger } from './Automation.type.js';
|
||||
import type { EntryCustomFields } from './CustomFields.type.js';
|
||||
import type { Day } from './Temporal.js';
|
||||
|
||||
export type EntryId = string;
|
||||
|
||||
@@ -77,7 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: number;
|
||||
delay: number; // calculated at runtime
|
||||
dayOffset: number; // calculated at runtime
|
||||
dayOffset: Day; // calculated at runtime
|
||||
gap: number; // calculated at runtime
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||
import type { Day, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
@@ -24,7 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
parent: null,
|
||||
revision: 0, // calculated at runtime
|
||||
delay: 0, // calculated at runtime
|
||||
dayOffset: 0, // calculated at runtime
|
||||
dayOffset: 0 as Day, // calculated at runtime
|
||||
gap: 0, // calculated at runtime
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user