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 (
-
{`${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;