diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 254f6a814..0373f077f 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -164,24 +164,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({ timeDanger: state.eventNow?.timeDanger ?? null, })); -export const useRundownOverview = createSelector((state: RuntimeStore) => ({ - plannedStart: state.rundown.plannedStart, - actualStart: state.rundown.actualStart, - plannedEnd: state.rundown.plannedEnd, - expectedEnd: state.offset.expectedRundownEnd, -})); - -export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({ - playback: state.timer.playback, - clock: state.clock, - - numEvents: state.rundown.numEvents, - selectedEventIndex: state.rundown.selectedEventIndex, - offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, - - groupExpectedEnd: state.offset.expectedGroupEnd, -})); - export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ clock: state.clock, offset: state.offset.absolute, @@ -190,7 +172,7 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({ offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, mode: state.offset.mode, - currentDay: state.eventNow?.dayOffset ?? 0, + currentDay: state.rundown.currentDay ?? 0, actualStart: state.rundown.actualStart, plannedStart: state.rundown.plannedStart, clock: state.clock, @@ -227,6 +209,46 @@ export const usePlayback = () => { return useRuntimeStore(featureSelector); }; +/* ======================= Overview data subscriptions ======================= */ + +export const useRundownOverview = createSelector((state: RuntimeStore) => ({ + plannedStart: state.rundown.plannedStart, + actualStart: state.rundown.actualStart, + plannedEnd: state.rundown.plannedEnd, + expectedEnd: state.offset.expectedRundownEnd, +})); + +export const useProgressOverview = createSelector((state: RuntimeStore) => ({ + numEvents: state.rundown.numEvents, + selectedEventIndex: state.rundown.selectedEventIndex, +})); + +export const useOffsetOverview = createSelector((state: RuntimeStore) => ({ + offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, + playback: state.timer.playback, +})); + +export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({ + clock: state.clock, + offset: state.offset.mode === OffsetMode.Absolute ? state.offset.absolute : state.offset.relative, + mode: state.offset.mode, + groupExpectedEnd: state.offset.expectedGroupEnd, + // we can force these numbers to 0 fo this use case to avoid null checks + actualGroupStart: state.rundown.actualGroupStart ?? 0, + currentDay: state.eventNow?.dayOffset ?? 0, + playback: state.timer.playback, +})); + +export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({ + clock: state.clock, + mode: state.offset.mode, + // we can force these numbers to 0 fo this use case to avoid null checks + actualStart: state.rundown.actualStart ?? 0, + plannedStart: state.rundown.plannedStart ?? 0, + currentDay: state.eventNow?.dayOffset ?? 0, + playback: state.timer.playback, +})); + /* ======================= View specific subscriptions ======================= */ export const useTimerSocket = createSelector((state: RuntimeStore) => ({ diff --git a/apps/client/src/features/overview/composite/TimeElements.module.scss b/apps/client/src/features/overview/composite/TimeElements.module.scss index b51d8b709..f939e18e4 100644 --- a/apps/client/src/features/overview/composite/TimeElements.module.scss +++ b/apps/client/src/features/overview/composite/TimeElements.module.scss @@ -47,9 +47,10 @@ .daySpan { &::after { - content: '*'; + content: "+"attr(data-day-offset); vertical-align: super; - font-size: 0.75em; + font-size: 0.6em; + letter-spacing: 0; color: $info-blue; } } @@ -67,3 +68,10 @@ font-size: calc(1rem - 2px); text-align: right; } + +.dueTime { + text-transform: capitalize; + font-size: 1rem; + letter-spacing: 0; + color: $playback-over; +} diff --git a/apps/client/src/features/overview/composite/TimeElements.tsx b/apps/client/src/features/overview/composite/TimeElements.tsx index 49fe412cd..25c1e61a0 100644 --- a/apps/client/src/features/overview/composite/TimeElements.tsx +++ b/apps/client/src/features/overview/composite/TimeElements.tsx @@ -8,23 +8,26 @@ import { TbFolderPin, TbFolderStar, } from 'react-icons/tb'; -import { OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types'; -import { isPlaybackActive, millisToString } from 'ontime-utils'; +import { OffsetMode, OntimeEvent, OntimeGroup, TimerPhase, TimerType } from 'ontime-types'; +import { dayInMs, isPlaybackActive, millisToString } from 'ontime-utils'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useClock, useCurrentGroupId, + useFlagTimerOverView, + useGroupTimerOverView, useNextFlag, + useOffsetOverview, + useProgressOverview, useRundownOverview, - useRuntimePlaybackOverview, useTimer, } from '../../../common/hooks/useSocket'; import { useEntry } from '../../../common/hooks-query/useRundown'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { formatTime } from '../../../common/utils/time'; -import { calculateEndAndDaySpan, formattedTime } from '../overview.utils'; +import { calculateEndAndDaySpan, formatDueTime, formattedTime } from '../overview.utils'; import { OverUnder, TimeColumn } from './TimeLayout'; @@ -58,8 +61,8 @@ export function StartTimes() { } /> {maybePlannedDaySpan > 0 ? ( } + text={`Rundown spans over ${maybePlannedDaySpan + 1} days`} + render={} > {plannedEndText} @@ -71,8 +74,8 @@ export function StartTimes() { } /> {maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? ( } + text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`} + render={} > {formattedTime(maybeExpectedEnd)} @@ -96,61 +99,111 @@ export function MetadataTimes() { ); } -//TODO: there a some things here we still need to think about, mainly what to do whit the planed group duration in relation to the events function GroupTimes() { - const { clock, groupExpectedEnd } = useRuntimePlaybackOverview(); + const { clock, groupExpectedEnd, actualGroupStart, mode, playback, currentDay } = useGroupTimerOverView(); const { currentGroupId } = useCurrentGroupId(); const group = useEntry(currentGroupId) as OntimeGroup | null; - // the group end time dose not encode any day offsets - const plannedGroupEnd = group && group.timeStart !== null ? group.timeStart + group.duration - clock : null; - const plannedTimeUntilGroupEnd = formattedTime(plannedGroupEnd, 3, TimerType.CountDown); + const active = isPlaybackActive(playback); + + // the group end time dose not encode any day offsets so it is calculated with group start time and duration + const plannedGroupEnd = (() => { + if (!active) return null; + if (!group || group.timeStart === null) return null; + const normalizedClock = clock + currentDay * dayInMs; + return mode === OffsetMode.Absolute + ? group.timeStart + group.duration - normalizedClock + : actualGroupStart + group.duration - normalizedClock; + })(); + + const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown); const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null; - const expectedTimeUntilGroupEnd = formattedTime(expectedGroupEnd, 3, TimerType.CountDown); + const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown); const groupTitle = group?.title ?? null; return (
- {`${groupTitle ? groupTitle : 'Group'} `} + {`${groupTitle || 'Group'} `}
} /> - {plannedTimeUntilGroupEnd} + + {plannedTimeUntilGroupEnd} +
} /> - {expectedTimeUntilGroupEnd} + + {expectedTimeUntilGroupEnd} +
); } function FlagTimes() { - const { clock } = useClock(); + const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView(); const { id, expectedStart } = useNextFlag(); const entry = useEntry(id) as OntimeEvent | null; - const plannedFlagStart = entry ? entry.timeStart - clock : null; - const plannedTimeUntilDisplay = formattedTime(plannedFlagStart, 3, TimerType.CountDown); + const active = isPlaybackActive(playback); + + const plannedFlagStart = (() => { + if (!active) return null; + if (!entry) return null; + const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs; + const normalizedClock = clock + currentDay * dayInMs; + return mode === OffsetMode.Absolute + ? normalizedTimeStart - normalizedClock + : normalizedTimeStart + actualStart - plannedStart - normalizedClock; + })(); + + const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown); const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null; - const expectedTimeUntilDisplay = formattedTime(expectedTimeUntil, 3, TimerType.CountDown); + const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown); const title = entry?.title ?? null; return (
- {`${title ? title : 'Flag'} `} + {`${title || 'Flag'} `}
} /> - + {plannedTimeUntilDisplay}
} /> - + {expectedTimeUntilDisplay}
@@ -159,7 +212,7 @@ function FlagTimes() { } export function ProgressOverview() { - const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview(); + const { numEvents, selectedEventIndex } = useProgressOverview(); const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash; const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash; @@ -168,7 +221,7 @@ export function ProgressOverview() { } export function OffsetOverview() { - const { offset, playback } = useRuntimePlaybackOverview(); + const { offset, playback } = useOffsetOverview(); const isPlaying = isPlaybackActive(playback); const offsetState = getOffsetState(isPlaying ? offset : null); diff --git a/apps/client/src/features/overview/overview.utils.ts b/apps/client/src/features/overview/overview.utils.ts index 61eeac978..9bfe8fa2c 100644 --- a/apps/client/src/features/overview/overview.utils.ts +++ b/apps/client/src/features/overview/overview.utils.ts @@ -3,6 +3,23 @@ import { dayInMs, millisToString } from 'ontime-utils'; import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils'; +/** + * Composition to stop negative timers from being formatted + * They should show a due string instead + * + * This is used for cases when a negative timer is unwanted + * eg: count down to a milestone + */ +export function formatDueTime( + time: MaybeNumber, + segments: number = 3, + direction?: TimerType.CountDown | TimerType.CountUp, + dueString = 'due', +): string { + if (time !== null && time <= 0) return dueString; + return formattedTime(time, segments, direction); +} + /** * Encapsulates the logic for formatting time in overview */ diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx index a6d02b7e2..9a99799bb 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -73,9 +73,7 @@ interface EventUntilProps { isLinkedToLoaded: boolean; } -function EventUntil(props: EventUntilProps) { - const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props; - +function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: EventUntilProps) { const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded }); const isDue = timeUntil < MILLIS_PER_SECOND; diff --git a/apps/client/src/theme/_viewerDefs.scss b/apps/client/src/theme/_viewerDefs.scss index fe90467dc..03df731b5 100644 --- a/apps/client/src/theme/_viewerDefs.scss +++ b/apps/client/src/theme/_viewerDefs.scss @@ -17,7 +17,6 @@ $header-font-size: clamp(24px, 2.5vw, 48px); // General styling $accent-color: $red-500; // --accent-color-override -$delay-color: $ontime-delay-text; $viewer-label-color: rgba(white, 25%); // Main Properties of a viewer diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss index 0808440b8..6c32000d9 100644 --- a/apps/client/src/views/countdown/Countdown.scss +++ b/apps/client/src/views/countdown/Countdown.scss @@ -141,7 +141,7 @@ $item-height: 3.5rem; } .sub__schedule--delayed { - color: $delay-color; + color: $ontime-delay-text; } .sub__schedule--strike { diff --git a/apps/client/src/views/timeline/Timeline.module.scss b/apps/client/src/views/timeline/Timeline.module.scss index f4b30269a..423836180 100644 --- a/apps/client/src/views/timeline/Timeline.module.scss +++ b/apps/client/src/views/timeline/Timeline.module.scss @@ -105,7 +105,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over } .delay { - color: $delay-color; + color: $ontime-delay-text; } .timeOverview { @@ -117,7 +117,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over .cross { text-decoration: line-through; text-decoration-thickness: 2px; - text-decoration-color: $delay-color; + text-decoration-color: $ontime-delay-text; } .separeLeft { diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index abdc63734..1e6baf6b2 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,7 +1,8 @@ -import { dayInMs, millisToString } from 'ontime-utils'; +import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils'; import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { + findDayOffset, getCurrent, getExpectedFinish, getRuntimeOffset, @@ -726,6 +727,7 @@ describe('getRuntimeOffset()', () => { eventNow: { id: '1', timeStart: 100, + dayOffset: 0, }, timer: { startedAt: 150, @@ -738,7 +740,10 @@ describe('getRuntimeOffset()', () => { rundown: { actualStart: 150, plannedStart: 100, + currentDay: 0, }, + clock: 150, + _startDayOffset: 0, } as RuntimeState; const { absolute } = getRuntimeOffset(state); @@ -750,6 +755,7 @@ describe('getRuntimeOffset()', () => { eventNow: { id: '1', timeStart: 100, + dayOffset: 0, }, timer: { startedAt: 150, // we started 50ms delayed @@ -762,7 +768,9 @@ describe('getRuntimeOffset()', () => { rundown: { actualStart: 150, plannedStart: 100, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute } = getRuntimeOffset(state); @@ -775,6 +783,7 @@ describe('getRuntimeOffset()', () => { id: '1', timeStart: 100, timeEnd: 140, + dayOffset: 0, }, timer: { startedAt: 100, // we started ontime @@ -787,7 +796,9 @@ describe('getRuntimeOffset()', () => { rundown: { actualStart: 100, plannedStart: 100, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute } = getRuntimeOffset(state); @@ -800,6 +811,7 @@ describe('getRuntimeOffset()', () => { id: '1', timeStart: 100, timeEnd: 150, + dayOffset: 0, }, clock: 150, timer: { @@ -813,7 +825,9 @@ describe('getRuntimeOffset()', () => { rundown: { actualStart: 100, plannedStart: 100, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute } = getRuntimeOffset(state); @@ -830,6 +844,7 @@ describe('getRuntimeOffset()', () => { duration: 3600000, timeStrategy: 'lock-duration', linkStart: false, + dayOffset: 0, }, rundown: { selectedEventIndex: 0, @@ -837,6 +852,7 @@ describe('getRuntimeOffset()', () => { plannedStart: 77400000, plannedEnd: 84600000, actualStart: null, + currentDay: 0, }, offset: { absolute: -77400000, @@ -852,6 +868,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: null, }, + _startDayOffset: 0, _timer: { pausedAt: null }, } as RuntimeState; @@ -874,6 +891,7 @@ describe('getRuntimeOffset()', () => { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: true, + dayOffset: 0, skip: false, note: '', colour: '', @@ -890,6 +908,7 @@ describe('getRuntimeOffset()', () => { plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 78000000, // 21:40:00 + currentDay: 0, }, offset: { absolute: 0, @@ -905,6 +924,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 78000000, }, + _startDayOffset: 0, _timer: { pausedAt: null }, } as RuntimeState; @@ -922,6 +942,7 @@ describe('getRuntimeOffset()', () => { timeStart: 77400000, // 21:30:00 timeEnd: 81000000, // 22:30:00 duration: 3600000, // 01:00:00 + dayOffset: 0, timeStrategy: TimeStrategy.LockEnd, linkStart: false, endAction: EndAction.None, @@ -943,6 +964,7 @@ describe('getRuntimeOffset()', () => { plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 78000000, // 21:40:00 + currentDay: 0, }, offset: { absolute: 0, @@ -958,6 +980,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 78000000, }, + _startDayOffset: 0, _timer: { pausedAt: null }, } as RuntimeState; @@ -978,6 +1001,7 @@ describe('getRuntimeOffset()', () => { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: true, + dayOffset: 0, }, rundown: { selectedEventIndex: 0, @@ -985,6 +1009,7 @@ describe('getRuntimeOffset()', () => { plannedStart: 77400000, // 21:30:00 plannedEnd: 81000000, // 22:30:00 actualStart: 82000000, // 22:46:40 <--- started now + currentDay: 0, }, offset: { absolute: 0, @@ -1000,6 +1025,7 @@ describe('getRuntimeOffset()', () => { secondaryTimer: null, startedAt: 82000000, // <--- started now }, + _startDayOffset: 0, _timer: { pausedAt: null }, } as RuntimeState; @@ -1017,6 +1043,7 @@ describe('getRuntimeOffset() relative', () => { eventNow: { id: '1', timeStart: 150, + dayOffset: 0, }, timer: { startedAt: 150, @@ -1029,7 +1056,9 @@ describe('getRuntimeOffset() relative', () => { rundown: { actualStart: 150, plannedStart: 150, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute, relative } = getRuntimeOffset(state); @@ -1041,6 +1070,7 @@ describe('getRuntimeOffset() relative', () => { eventNow: { id: '1', timeStart: 100, + dayOffset: 0, }, timer: { startedAt: 150, @@ -1053,7 +1083,9 @@ describe('getRuntimeOffset() relative', () => { rundown: { actualStart: 150, plannedStart: 100, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute, relative } = getRuntimeOffset(state); @@ -1065,6 +1097,7 @@ describe('getRuntimeOffset() relative', () => { eventNow: { id: '1', timeStart: 150, + dayOffset: 0, }, timer: { startedAt: 100, @@ -1077,7 +1110,9 @@ describe('getRuntimeOffset() relative', () => { rundown: { actualStart: 100, plannedStart: 150, + currentDay: 0, }, + _startDayOffset: 0, } as RuntimeState; const { absolute, relative } = getRuntimeOffset(state); @@ -1258,3 +1293,21 @@ describe('getTimerPhase()', () => { expect(phase).toBe(TimerPhase.Pending); }); }); + +describe('findDay()', () => { + test('finds dayOffset', () => { + //both have 1 hour offset but the clock are on different days + expect(findDayOffset(0, 23 * MILLIS_PER_HOUR)).toBe(-1); // -> 23 + expect(findDayOffset(0, 13 * MILLIS_PER_HOUR)).toBe(-1); // -> 13 + expect(findDayOffset(0, 12 * MILLIS_PER_HOUR)).toBe(-1); // -> 12 + expect(findDayOffset(0, 11 * MILLIS_PER_HOUR)).toBe(0); // -> 11 + expect(findDayOffset(1 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -1 + + //both have 1 hour offset but the clock are on different days + expect(findDayOffset(23 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -23 + expect(findDayOffset(13 * MILLIS_PER_HOUR, 0)).toBe(1); // -> -13 + expect(findDayOffset(12 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -12 + expect(findDayOffset(11 * MILLIS_PER_HOUR, 0)).toBe(0); // -> -11 + expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1 + }); +}); diff --git a/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts b/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts index f10f890de..1963d335e 100644 --- a/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts +++ b/apps/server/src/services/restore-service/__tests__/restore.parser.test.ts @@ -12,6 +12,7 @@ describe('isRestorePoint()', () => { addedTime: 2, pausedAt: 3, firstStart: 1, + startEpoch: 1, }; expect(isRestorePoint(restorePoint)).toBe(true); @@ -22,6 +23,7 @@ describe('isRestorePoint()', () => { addedTime: 0, pausedAt: null, firstStart: 1, + startEpoch: 1, }; expect(isRestorePoint(restorePoint)).toBe(true); }); @@ -35,6 +37,7 @@ describe('isRestorePoint()', () => { addedTime: 0, pausedAt: null, groupStartAt: 10, + startEpoch: 1, }; expect(isRestorePoint(restorePoint)).toBe(false); }); @@ -56,6 +59,7 @@ describe('isRestorePoint()', () => { addedTime: 0, pausedAt: null, groupStartAt: 10, + startEpoch: 1, }; expect(isRestorePoint(restorePoint)).toBe(false); }); diff --git a/apps/server/src/services/restore-service/__tests__/restore.service.test.ts b/apps/server/src/services/restore-service/__tests__/restore.service.test.ts index 8316e1d47..81afc392c 100644 --- a/apps/server/src/services/restore-service/__tests__/restore.service.test.ts +++ b/apps/server/src/services/restore-service/__tests__/restore.service.test.ts @@ -16,6 +16,7 @@ describe('restoreService', () => { addedTime: 5678, pausedAt: 9087, firstStart: 1234, + startEpoch: 1234, }; const mockRead = vi.fn().mockResolvedValue(expected); @@ -33,6 +34,7 @@ describe('restoreService', () => { addedTime: 0, pausedAt: null, firstStart: 1234, + startEpoch: 1234, }; const mockRead = vi.fn().mockResolvedValue(expected); @@ -79,6 +81,7 @@ describe('restoreService', () => { addedTime: 1234, pausedAt: 1234, firstStart: 1234, + startEpoch: 1234, }; const mockWrite = vi.fn().mockResolvedValue(undefined); @@ -95,6 +98,7 @@ describe('restoreService', () => { addedTime: 5678, pausedAt: 5678, firstStart: 5678, + startEpoch: 5678, }; const mockWrite = vi.fn().mockRejectedValue(new Error('Write failed')); diff --git a/apps/server/src/services/restore-service/restore.parser.ts b/apps/server/src/services/restore-service/restore.parser.ts index e1f16bb55..a4c027c44 100644 --- a/apps/server/src/services/restore-service/restore.parser.ts +++ b/apps/server/src/services/restore-service/restore.parser.ts @@ -20,6 +20,7 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo 'addedTime', 'pausedAt', 'firstStart', + 'startEpoch', ]) ) { return false; @@ -49,5 +50,9 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo return false; } + if (!is.number(restorePoint.startEpoch) && restorePoint.startEpoch !== null) { + return false; + } + return true; } diff --git a/apps/server/src/services/restore-service/restore.type.ts b/apps/server/src/services/restore-service/restore.type.ts index 018612f46..d01979ce6 100644 --- a/apps/server/src/services/restore-service/restore.type.ts +++ b/apps/server/src/services/restore-service/restore.type.ts @@ -7,4 +7,5 @@ export type RestorePoint = { addedTime: number; pausedAt: MaybeNumber; firstStart: MaybeNumber; + startEpoch: MaybeNumber; }; diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index 10dca10b9..1611921f7 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -743,6 +743,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert addedTime: state.timer.addedTime, pausedAt: state._timer.pausedAt, firstStart: state.rundown.actualStart, + startEpoch: state._startEpoch, }) .catch((_e) => { //we don't do anything with the error here diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 45d33768e..9251fbc23 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,5 +1,5 @@ import { MaybeNumber, TimerPhase } from 'ontime-types'; -import { dayInMs, isPlaybackActive } from 'ontime-utils'; +import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils'; import type { RuntimeState } from '../stores/runtimeState.js'; /** @@ -113,14 +113,14 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski * Negative offset is under time / ahead of schedule */ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } { - const { eventNow, clock } = state; + const { eventNow, clock, _startDayOffset } = state; const { addedTime, current, startedAt } = state.timer; // nothing to calculate if there are no loaded events or if we havent started - if (eventNow === null || startedAt === null) { + if (eventNow === null || startedAt === null || _startDayOffset === null) { return { absolute: 0, relative: 0 }; } - const { countToEnd, timeStart } = eventNow; + const { countToEnd, timeStart, dayOffset } = eventNow; const { plannedStart, actualStart } = state.rundown; // eslint-disable-next-line no-unused-labels -- dev code path @@ -131,8 +131,8 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat if (actualStart === null) throw new Error('timerUtils.getRuntimeOffset: state.rundown.plannedStart must be set'); } - // difference between planned event start and actual event start (will be positive if we stared behind ) - const eventStartOffset = startedAt - timeStart; + // difference between planned event start and actual event start (will be positive if we started behind ) + const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs); // how long has the event been running over (is a negative number when in over timer so inverted before adding to offset) const overtime = Math.abs(Math.min(current, 0)); @@ -142,8 +142,8 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat const absolute = eventStartOffset + overtime + pausedTime + addedTime; - // the relative offset i the same as the absolute offset but adjusted relative to the actual start time - const relative = absolute + plannedStart - actualStart; + // the relative offset is the same as the absolute offset but adjusted relative to the actual start time + const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs; // in case of count to end, the absolute offset is just the overtime return countToEnd ? { absolute: overtime, relative } : { absolute, relative }; @@ -180,3 +180,14 @@ export function getTimerPhase(state: RuntimeState): TimerPhase { return TimerPhase.Default; } + +/** + * 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 { + const distance = clock - plannedStart; + if (distance >= 12 * MILLIS_PER_HOUR) return -1; + if (distance < -12 * MILLIS_PER_HOUR) return 1; + return 0; +} diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index a562dc92f..be182ae2e 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -14,6 +14,8 @@ const baseState: RuntimeState = { plannedStart: 0, plannedEnd: 0, actualStart: null, + actualGroupStart: null, + currentDay: 0, }, offset: { absolute: 0, @@ -46,6 +48,8 @@ const baseState: RuntimeState = { _group: null, _end: null, _flag: null, + _startDayOffset: null, + _startEpoch: null, }; export function makeRuntimeStateData(patch?: Partial): RuntimeState { diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 01dd67b59..4c5529c96 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -393,6 +393,7 @@ describe('loadGroupFlagAndEnd()', () => { const state = { groupNow: null, eventNow: rundown.entries[11], + rundown: { actualGroupStart: null }, } as RuntimeState; const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; @@ -420,6 +421,7 @@ describe('loadGroupFlagAndEnd()', () => { const state = { groupNow: rundown.entries[1], eventNow: rundown.entries[22], + rundown: { actualGroupStart: null }, } as RuntimeState; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; @@ -447,6 +449,7 @@ describe('loadGroupFlagAndEnd()', () => { const state = { groupNow: rundown.entries[1], eventNow: rundown.entries[0], + rundown: { actualGroupStart: null }, } as RuntimeState; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; @@ -471,6 +474,7 @@ describe('loadGroupFlagAndEnd()', () => { const state = { groupNow: null, eventNow: rundown.entries[0], + rundown: { actualGroupStart: null }, } as RuntimeState; const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata; diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 27e6e88a5..34662fadc 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -23,9 +23,15 @@ import { isPlaybackActive, } from 'ontime-utils'; -import { timeNow } from '../utils/time.js'; +import { getTimeObject, timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/restore-service/restore.type.js'; -import { getCurrent, getExpectedFinish, getRuntimeOffset, getTimerPhase } from '../services/timerUtils.js'; +import { + findDayOffset, + getCurrent, + getExpectedFinish, + getRuntimeOffset, + getTimerPhase, +} from '../services/timerUtils.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { timerConfig } from '../setup/config.js'; import { RundownMetadata } from '../api-data/rundown/rundown.types.js'; @@ -55,6 +61,8 @@ export type RuntimeState = { _group: ExpectedMetadata; _flag: ExpectedMetadata; _end: ExpectedMetadata; + _startEpoch: MaybeNumber; + _startDayOffset: MaybeNumber; }; const runtimeState: RuntimeState = { @@ -78,6 +86,8 @@ const runtimeState: RuntimeState = { _group: null, _flag: null, _end: null, + _startEpoch: null, + _startDayOffset: null, }; export function getState(): Readonly { @@ -152,6 +162,10 @@ export function clearState() { runtimeState._timer.pausedAt = null; runtimeState._timer.secondaryTarget = null; runtimeState._timer.hasFinished = false; + + runtimeState._startEpoch = null; + runtimeState._startDayOffset = null; + runtimeState.rundown.currentDay = null; } /** @@ -190,7 +204,8 @@ export function updateRundownData(rundownData: { runtimeState.rundown.plannedStart = rundownData.firstStart; runtimeState.rundown.plannedEnd = rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration; - getExpectedTimes(); + + if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes(); } /** @@ -229,9 +244,14 @@ export function load( // patch with potential provided data if (initialData) { patchTimer(initialData); + const startEpoch = initialData?.startEpoch; const firstStart = initialData?.firstStart; - if (firstStart === null || typeof firstStart === 'number') { + if ( + (firstStart === null || typeof firstStart === 'number') && + (startEpoch === null || typeof startEpoch === 'number') + ) { runtimeState.rundown.actualStart = firstStart; + runtimeState._startEpoch = startEpoch; const { absolute, relative } = getRuntimeOffset(runtimeState); runtimeState.offset.absolute = absolute; runtimeState.offset.relative = relative; @@ -367,7 +387,8 @@ export function start(state: RuntimeState = runtimeState): boolean { return false; } - state.clock = timeNow(); + const [epoch, now] = getTimeObject(); + state.clock = now; state.timer.secondaryTimer = null; // add paused time if it exists @@ -386,9 +407,16 @@ 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.rundown.currentDay = state._startDayOffset; + state._startEpoch = epoch; state.rundown.actualStart = state.clock; } + if (state.groupNow !== null && state.rundown.actualGroupStart === null) { + state.rundown.actualGroupStart = state.clock; + } + // update timer phase runtimeState.timer.phase = getTimerPhase(runtimeState); @@ -480,13 +508,20 @@ export type UpdateResult = { export function update(): UpdateResult { // 0. there are some things we always do const previousClock = runtimeState.clock; - runtimeState.clock = timeNow(); // we update the clock on every update call + const [epoch, fromMidnight] = getTimeObject(); + runtimeState.clock = fromMidnight; // we update the clock on every update call // 1. is playback idle? if (!isPlaybackActive(runtimeState.timer.playback)) { return updateIfIdle(); } + // if we are playing and playback changes. we tick the current runtime day + if (runtimeState._startDayOffset !== null && runtimeState._startEpoch) { + runtimeState.rundown.currentDay = + runtimeState._startDayOffset + Math.floor((epoch - runtimeState._startEpoch) / dayInMs); + } + // 2. are we waiting to roll? if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { const hasCrossedMidnight = previousClock > runtimeState.clock; @@ -704,7 +739,7 @@ function getExpectedTimes(state = runtimeState) { if (_group !== null) { const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group; const lastEventExpectedStart = getExpectedStart(lastEvent, { - currentDay: eventNow.dayOffset, + currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, mode: offset.mode, @@ -721,7 +756,7 @@ function getExpectedTimes(state = runtimeState) { if (_flag) { const { event, accumulatedGap, isLinkedToLoaded } = _flag; const expectedStart = getExpectedStart(event, { - currentDay: eventNow.dayOffset, + currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, mode: offset.mode, @@ -736,7 +771,7 @@ function getExpectedTimes(state = runtimeState) { if (state._end) { const { event, accumulatedGap, isLinkedToLoaded } = state._end; const expectedStart = getExpectedStart(event, { - currentDay: eventNow.dayOffset, + currentDay: state.rundown.currentDay!, totalGap: accumulatedGap, isLinkedToLoaded, mode: offset.mode, @@ -752,16 +787,19 @@ export function loadGroupFlagAndEnd( rundown: Rundown, metadata: RundownMetadata, currentIndex: MaybeNumber, - state = runtimeState, + state = runtimeState, // used for testing ) { + const previousGroup = state.groupNow?.id; state.groupNow = null; state._group = null; state.eventFlag = null; state._flag = null; state._end = null; - if (currentIndex == null) return; - if (state.eventNow === null) return; + if (currentIndex === null || state.eventNow === null) { + state.rundown.actualGroupStart = null; + return; + } const currentGroupId = state.eventNow.parent; const flagsPresent = metadata.flags.length !== 0; @@ -773,6 +811,10 @@ export function loadGroupFlagAndEnd( state.groupNow = currentGroupId ? (entries[currentGroupId] as OntimeGroup) : null; const lastEventInGroup = orderInGroup ? getLastEventNormal(rundown.entries, orderInGroup).lastEvent : null; + if (previousGroup !== currentGroupId) { + state.rundown.actualGroupStart = null; + } + // if we don't have a any flags in the rundown then no need to look for it let foundFlag = !flagsPresent; // if we don't have a last event for the group there is no need to find its end time @@ -814,4 +856,5 @@ export function loadGroupFlagAndEnd( export function setOffsetMode(mode: OffsetMode) { runtimeState.offset.mode = mode; + if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes(); } diff --git a/apps/server/src/utils/time.ts b/apps/server/src/utils/time.ts index 5b1ffdbfd..b1605a975 100644 --- a/apps/server/src/utils/time.ts +++ b/apps/server/src/utils/time.ts @@ -74,3 +74,18 @@ export function timeNow() { elapsed += now.getMilliseconds(); return elapsed; } + +/** + * Get current time from system + * @returns [number, number] - [epoch time, milliseconds since midnight] + */ +export function getTimeObject(): [number, number] { + const now = new Date(); + + // extract milliseconds since midnight + let elapsed = now.getHours() * 3600000; + elapsed += now.getMinutes() * 60000; + elapsed += now.getSeconds() * 1000; + elapsed += now.getMilliseconds(); + return [now.getTime(), elapsed]; +} diff --git a/e2e/tests/features/212-time-until.spec.ts b/e2e/tests/features/212-time-until.spec.ts index daf296408..50d6802db 100644 --- a/e2e/tests/features/212-time-until.spec.ts +++ b/e2e/tests/features/212-time-until.spec.ts @@ -72,4 +72,6 @@ test('time until relative', async ({ page }) => { await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('30s'); await expect(page.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('10m'); await expect(page.getByTestId('entry-4').getByTestId('rundown-event')).toContainText('20m'); + + await page.getByRole('button', { name: 'Absolute' }).click(); }); diff --git a/packages/types/src/definitions/runtime/RundownState.type.ts b/packages/types/src/definitions/runtime/RundownState.type.ts index e10e50b9a..ab29991c0 100644 --- a/packages/types/src/definitions/runtime/RundownState.type.ts +++ b/packages/types/src/definitions/runtime/RundownState.type.ts @@ -4,6 +4,8 @@ export type RundownState = { selectedEventIndex: MaybeNumber; numEvents: number; plannedStart: MaybeNumber; - actualStart: MaybeNumber; plannedEnd: MaybeNumber; + actualStart: MaybeNumber; + currentDay: MaybeNumber; + actualGroupStart: MaybeNumber; }; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index 685561f75..00ebaaec8 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -33,6 +33,8 @@ export const runtimeStorePlaceholder: Readonly = { plannedStart: 0, // only changes if event changes plannedEnd: 0, // only changes if event changes, overflows over dayInMs actualStart: null, // set once we start the timer + actualGroupStart: null, // maybe set once we start the timer + currentDay: null, }, offset: { absolute: 0, // changes at runtime diff --git a/packages/utils/src/date-utils/getExpectedStart.test.ts b/packages/utils/src/date-utils/getExpectedStart.test.ts index a4ed5d4a6..4878672df 100644 --- a/packages/utils/src/date-utils/getExpectedStart.test.ts +++ b/packages/utils/src/date-utils/getExpectedStart.test.ts @@ -1,6 +1,6 @@ import { OffsetMode } from 'ontime-types'; -import { dayInMs } from './conversionUtils'; +import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils'; import { getExpectedStart } from './getExpectedStart'; describe('getExpectedStart()', () => { @@ -277,4 +277,41 @@ describe('getExpectedStart()', () => { // the overlap will be pushed out to the expected available time expect(getExpectedStart(testEvent, { ...testState, totalGap: -5 })).toBe(110); }); + + test('we started on the day before', () => { + const testEvent = { + timeStart: 5, + dayOffset: 0, + delay: 0, + }; + const testState = { + currentDay: -1, + actualStart: 23 * MILLIS_PER_HOUR, + plannedStart: 0, + offset: -1 * MILLIS_PER_HOUR, + mode: OffsetMode.Absolute, + isLinkedToLoaded: true, + totalGap: 0, + }; + expect(getExpectedStart(testEvent, { ...testState })).toBe(23 * MILLIS_PER_HOUR + 5); + }); + + test('next day in multi-day rundown', () => { + const testEvent = { + timeStart: 5, + dayOffset: 1, + delay: 0, + }; + const testState = { + currentDay: -1, + actualStart: 23 * MILLIS_PER_HOUR, + plannedStart: 0, + offset: -1 * MILLIS_PER_HOUR, + mode: OffsetMode.Absolute, + isLinkedToLoaded: true, + totalGap: 0, + }; + expect(getExpectedStart(testEvent, { ...testState })).toBe(23 * MILLIS_PER_HOUR + 5 + dayInMs); + expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5); + }); }); diff --git a/packages/utils/src/date-utils/getExpectedStart.ts b/packages/utils/src/date-utils/getExpectedStart.ts index c51bb44ca..716ccae0d 100644 --- a/packages/utils/src/date-utils/getExpectedStart.ts +++ b/packages/utils/src/date-utils/getExpectedStart.ts @@ -15,7 +15,7 @@ import { dayInMs } from './conversionUtils.js'; export function getExpectedStart( event: Pick, state: { - currentDay: number; + currentDay: number; // the current day from the rundown totalGap: number; isLinkedToLoaded: boolean; offset: number; @@ -38,7 +38,7 @@ export function getExpectedStart( let relativeStartOffset = 0; if (mode === OffsetMode.Relative) { - relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0); + relativeStartOffset = (actualStart ?? 0) + currentDay * dayInMs - (plannedStart ?? 0); } const scheduledStartTime = normalisedTimeStart + relativeStartOffset;