diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts index 73317f144..599e40835 100644 --- a/apps/server/src/api-data/db/migration/db.migration.v3.ts +++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts @@ -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') { diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index ed96e9027..b7069d2b2 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -16,6 +16,7 @@ import { isOntimeMilestone, OntimeMilestone, OntimeGroup, + Day, } from 'ontime-types'; import { isObjectEmpty, @@ -281,7 +282,7 @@ function processEntry( 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; 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 60a8350cd..c8ce754a2 100644 --- a/apps/server/src/lib/time-core/__tests__/timeCore.test.ts +++ b/apps/server/src/lib/time-core/__tests__/timeCore.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/lib/time-core/timeCore.ts b/apps/server/src/lib/time-core/timeCore.ts index 25869826e..8f5056c2f 100644 --- a/apps/server/src/lib/time-core/timeCore.ts +++ b/apps/server/src/lib/time-core/timeCore.ts @@ -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; +} diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 9671a754c..b46d2afeb 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -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', diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 055b6247e..cf4ba87e2 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -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; } diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 4c55c8e7c..965bf0a97 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -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({ diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index a7c77fdb1..9775b28d4 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,4 +1,6 @@ import { + Day, + Duration, isOntimeEvent, Instant, MaybeNumber, @@ -69,7 +71,7 @@ export type RuntimeState = { _flag: ExpectedMetadata; _end: ExpectedMetadata; _startEpoch: Maybe; - _startDayOffset: MaybeNumber; + _startDayOffset: Maybe; }; 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 }; } diff --git a/packages/types/src/definitions/core/OntimeEntry.ts b/packages/types/src/definitions/core/OntimeEntry.ts index 4eee0c18e..7d87e017d 100644 --- a/packages/types/src/definitions/core/OntimeEntry.ts +++ b/packages/types/src/definitions/core/OntimeEntry.ts @@ -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 }; diff --git a/packages/utils/src/rundown-utils/entryDefinitions.ts b/packages/utils/src/rundown-utils/entryDefinitions.ts index 2ea9ab0a2..76c410bf4 100644 --- a/packages/utils/src/rundown-utils/entryDefinitions.ts +++ b/packages/utils/src/rundown-utils/entryDefinitions.ts @@ -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 = { @@ -24,7 +24,7 @@ export const event: Omit = { 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 };