Relative mode (#1764)

* feat: add group start time to state

* refactor: calculate expected times when offset mode changes

* feat: show relative data in ui

* fixup! feat: add group start time to state

* fixup! refactor: calculate expected times when offset mode changes

* fixup! feat: show relative data in ui

* try to handle multiday

* show planed/expected rundown end day offset values

* refactor: style tweaks to timers

* refactor: cleanup data use

* day offset

* update tests

* test getExpectedStart multiday

* write start epoch to restore file

* current day in relative mode

* remove todo

* move find day offset to utils

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2025-09-17 11:20:14 +02:00
parent 313590905f
commit 8d2db7ffcd
24 changed files with 364 additions and 79 deletions
+41 -19
View File
@@ -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) => ({
@@ -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;
}
@@ -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() {
<Tooltip text='Planned end time' render={<TbCalendarPin className={style.icon} />} />
{maybePlannedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />}
text={`Rundown spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />}
>
{plannedEndText}
</Tooltip>
@@ -71,8 +74,8 @@ export function StartTimes() {
<Tooltip text='Expected end time' render={<TbCalendarStar className={style.icon} />} />
{maybeExpectedEnd !== null && maybeExpectedDaySpan > 0 ? (
<Tooltip
text={`Event spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />}
text={`Rundown spans over ${maybeExpectedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} data-day-offset={maybeExpectedDaySpan} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
@@ -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 (
<div className={style.metadataRow}>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle ? groupTitle : 'Group'} `}</span>
<span className={groupTitle ? style.labelTitle : style.label}>{`${groupTitle || 'Group'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to planned group end' render={<TbFolderPin className={style.icon} />} />
<span className={cx([style.time, !group && style.muted])}>{plannedTimeUntilGroupEnd}</span>
<span
className={cx([
style.time,
(!group || !active) && style.muted,
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{plannedTimeUntilGroupEnd}
</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbFolderStar className={style.icon} />} />
<span className={cx([style.time, groupExpectedEnd === null && style.muted])}>{expectedTimeUntilGroupEnd}</span>
<span
className={cx([
style.time,
!groupExpectedEnd && style.muted,
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
{expectedTimeUntilGroupEnd}
</span>
</div>
</div>
);
}
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 (
<div className={style.metadataRow}>
<span className={title ? style.labelTitle : style.label}>{`${title ? title : 'Flag'} `}</span>
<span className={title ? style.labelTitle : style.label}>{`${title || 'Flag'} `}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag planned start' render={<TbFlagPin className={style.icon} />} />
<span data-testid='flag-plannedStart' className={cx([style.time, !entry && style.muted])}>
<span
data-testid='flag-plannedStart'
className={cx([
style.time,
(!entry || !active) && style.muted,
plannedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{plannedTimeUntilDisplay}
</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span data-testid='flag-expectedStart' className={cx([style.time, expectedTimeUntil === null && style.muted])}>
<span
data-testid='flag-expectedStart'
className={cx([
style.time,
expectedTimeUntil === null && style.muted,
expectedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
{expectedTimeUntilDisplay}
</span>
</div>
@@ -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);
@@ -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
*/
@@ -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;
-1
View File
@@ -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
@@ -141,7 +141,7 @@ $item-height: 3.5rem;
}
.sub__schedule--delayed {
color: $delay-color;
color: $ontime-delay-text;
}
.sub__schedule--strike {
@@ -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 {