feat: time to next flag

This commit is contained in:
Carlos Valente
2025-07-13 16:52:35 +02:00
parent 637454f73d
commit 445cc9e845
43 changed files with 550 additions and 310 deletions
@@ -1,19 +1,10 @@
import { memo, PropsWithChildren, useMemo } from 'react';
import { memo, PropsWithChildren } from 'react';
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
import { useRuntimeOverview } from '../../common/hooks/useSocket';
import {
ClockOverview,
CurrentBlockOverview,
OverviewWrapper,
RuntimeOverview,
TimerOverview,
} from './composite/OverviewWrapper';
import { TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime } from './overviewUtils';
import style from './Overview.module.scss';
import { ClockOverview, MetadataTimes, RuntimeOverview, StartTimes, TimerOverview } from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
export default memo(CuesheetOverview);
function CuesheetOverview({ children }: PropsWithChildren) {
@@ -33,49 +24,14 @@ function CuesheetMobile({ children }: PropsWithChildren) {
}
function CuesheetDesktop({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<TitleOverview />
<StartTimes />
<TimerOverview />
<RuntimeOverview />
<CurrentBlockOverview />
<MetadataTimes />
<ClockOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
@@ -1,67 +1,19 @@
import { memo, PropsWithChildren, useMemo } from 'react';
import { memo, PropsWithChildren } from 'react';
import { useRuntimeOverview } from '../../common/hooks/useSocket';
import {
ClockOverview,
CurrentBlockOverview,
OverviewWrapper,
ProgressOverview,
RuntimeOverview,
TitlesOverview,
} from './composite/OverviewWrapper';
import { TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime } from './overviewUtils';
import style from './Overview.module.scss';
import { ClockOverview, MetadataTimes, ProgressOverview, RuntimeOverview, StartTimes } from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
export default memo(EditorOverview);
function EditorOverview({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<TitlesOverview />
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<TitleOverview />
<StartTimes />
<ProgressOverview />
<RuntimeOverview />
<CurrentBlockOverview />
<MetadataTimes />
<ClockOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
@@ -2,6 +2,7 @@
grid-area: overview;
font-size: $inner-section-text-size;
display: flex;
overflow: hidden;
}
.isOffline {
@@ -23,36 +24,14 @@
.nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.info {
flex: 1;
padding-inline: 1rem;
padding-left: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 1.5rem;
@include ellipsis-overflow;
}
.description {
font-size: 1rem;
color: $label-gray;
@include ellipsis-overflow;
}
.offset {
color: $muted-gray;
}
.ahead {
color: $playback-ahead;
}
.behind {
color: $ontime-delay-text;
}
@@ -0,0 +1,23 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react';
import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils';
import style from './Overview.module.scss';
interface OverviewWrapperProps {
navElements: ReactNode;
}
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
</ErrorBoundary>
</div>
);
}
@@ -1,6 +1,6 @@
import { dayInMs } from 'ontime-utils';
import { calculateEndAndDaySpan } from '../overviewUtils';
import { calculateEndAndDaySpan } from '../overview.utils';
describe('calculateEndAndDaySpan', () => {
it('should return [null, 0] when end is null', () => {
@@ -1,146 +0,0 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react';
import { isOntimeBlock, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import {
useClock,
useCurrentBlockId,
useIsOnline,
useRuntimePlaybackOverview,
useTimer,
} from '../../../common/hooks/useSocket';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatedTime, getOffsetText } from '../overviewUtils';
import { TimeColumn, TimeRow } from './TimeLayout';
import style from '../Overview.module.scss';
interface OverviewWrapperProps {
navElements: ReactNode;
}
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
</ErrorBoundary>
</div>
);
}
export function TitlesOverview() {
const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return (
<div>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
export function CurrentBlockOverview() {
const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { currentBlockId } = useCurrentBlockId();
const entry = useEntry(currentBlockId);
const timeInBlock = formatedTime(blockStartedAt ? clock - blockStartedAt : null, 3, TimerType.CountUp);
const blockExpectedEndString = formatedTime(blockExpectedEnd, 3, TimerType.CountUp);
const remainingBlockDuration = (() => {
if (blockStartedAt === null || !entry) return timerPlaceholder;
if (!isOntimeBlock(entry)) return timerPlaceholder;
return formatedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown);
})();
const timeUntilBlockEnd = (() => {
if (blockExpectedEnd === null) return timerPlaceholder;
return formatedTime(blockExpectedEnd - clock, 3, TimerType.CountDown);
})() ;
return (
<>
<div>
<Tooltip text='How long the group has been active'>
<TimeRow
label='Elapsed in group'
value={timeInBlock}
className={style.clock}
muted={blockStartedAt === null}
/>
</Tooltip>
<Tooltip text='Remaining time until the planed group duration is up'>
<TimeRow
label='Remaining group duration'
value={remainingBlockDuration}
className={style.clock}
muted={blockStartedAt === null}
/>
</Tooltip>
</div>
<div>
<Tooltip text='Expected time until the group can end, if everything ends on time from now on'>
<TimeRow
label='Expected time until group end'
value={timeUntilBlockEnd}
className={style.end}
muted={blockStartedAt === null}
/>
</Tooltip>
<Tooltip text='Expected time the group will end, if everything ends on time from now on'>
<TimeRow
label='Expected group end'
value={blockExpectedEndString}
className={style.end}
muted={blockStartedAt === null}
/>
</Tooltip>
</div>
</>
);
}
export function TimerOverview() {
const { current } = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
}
export function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
return <TimeColumn label='Progress' value={progressText} />;
}
export function RuntimeOverview() {
const { offset, playback } = useRuntimePlaybackOverview();
const isPlaying = isPlaybackActive(playback);
const offsetText = getOffsetText(isPlaying ? offset : null);
const offsetClasses = cx([style.offset, isPlaying && (offset < 0 ? style.behind : style.ahead)]);
return <TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />;
}
export function ClockOverview() {
const { clock } = useClock();
return <TimeColumn label='Time now' value={formatedTime(clock)} />;
}
@@ -0,0 +1,78 @@
.column {
display: flex;
flex-direction: column;
align-items: end;
}
.row {
display: grid;
grid-template-columns: 3rem 10rem 8rem;
align-items: center;
gap: 0.5rem;
}
.metadataRow {
display: grid;
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
align-items: center;
gap: 0.5rem;
}
.labelledElement {
display: flex;
align-items: center;
gap: 0.25rem;
}
.icon {
font-size: 1rem;
color: $label-gray;
}
.label {
color: $label-gray;
font-size: calc(1rem - 2px);
text-align: right;
}
.time {
font-size: 1.25rem;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.daySpan {
&::after {
content: '*';
vertical-align: super;
font-size: 0.75em;
color: $info-blue;
}
}
.muted {
color: $muted-gray;
}
.offset {
color: $muted-gray;
}
.ahead {
color: $playback-ahead;
}
.behind {
color: $ontime-delay-text;
}
.labelTitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $active-indicator;
font-size: calc(1rem - 2px);
text-align: right;
}
@@ -0,0 +1,218 @@
import { useMemo } from 'react';
import { TbCalendar, TbCalendarClock, TbCalendarDown, TbCalendarStar, TbFlagDown, TbFlagStar } from 'react-icons/tb';
import { isOntimeBlock, OntimeBlock, OntimeEvent, TimerType } from 'ontime-types';
import { isPlaybackActive, millisToString } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import {
useClock,
useCurrentBlockId,
useNextFlag,
useRuntimeOverview,
useRuntimePlaybackOverview,
useTimer,
} from '../../../common/hooks/useSocket';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime, useTimeUntilStart } from '../../../common/utils/time';
import { calculateEndAndDaySpan, formattedTime, getOffsetText } from '../overview.utils';
import { TimeColumn } from './TimeLayout';
import style from './TimeElements.module.scss';
export function StartTimes() {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const muted = maybeExpectedEnd === null;
return (
<div className={style.column}>
<div className={style.row}>
<span className={style.label}>Start</span>
<div className={style.labelledElement}>
<Tooltip text='Planned start time' render={<TbCalendar className={style.icon} />} />
<span className={cx([style.time])}>{formatTime(plannedStart)}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Actual start time' render={<TbCalendarClock className={style.icon} />} />
<span className={cx([style.time, muted && style.muted])}>{formattedTime(actualStart)}</span>
</div>
</div>
<div className={style.row}>
<span className={style.label}>End</span>
<div className={style.labelledElement}>
<Tooltip text='Planned end time' render={<TbCalendar className={style.icon} />} />
{maybePlannedDaySpan >= 0 ? (
<Tooltip
text={`Event spans over ${maybePlannedDaySpan + 1} days`}
render={<span className={cx([style.time, style.daySpan])} />}
>
{formatTime(maybePlannedEnd)}
</Tooltip>
) : (
<span className={cx([style.time, muted && style.muted])}>{formatTime(maybePlannedEnd)}</span>
)}
</div>
<div className={style.labelledElement}>
<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])} />}
>
{formattedTime(maybeExpectedEnd)}
</Tooltip>
) : (
<span className={cx([style.time, muted && style.muted])}>{formattedTime(maybeExpectedEnd)}</span>
)}
</div>
</div>
</div>
);
}
export function MetadataTimes() {
return (
<div className={style.column}>
<GroupTimes />
<FlagTimes />
</div>
);
}
function GroupTimes() {
const { blockStartedAt, clock, blockExpectedEnd } = useRuntimePlaybackOverview();
const { currentBlockId } = useCurrentBlockId();
const entry = useEntry(currentBlockId);
if (!currentBlockId) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Group</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
const remainingBlockDuration = (() => {
if (blockStartedAt === null || !entry) return timerPlaceholder;
if (!isOntimeBlock(entry)) return timerPlaceholder;
return formattedTime(blockStartedAt + entry.duration - clock, 3, TimerType.CountDown);
})();
const timeUntilBlockEnd = (() => {
if (blockExpectedEnd === null) return timerPlaceholder;
return formattedTime(blockExpectedEnd - clock, 3, TimerType.CountDown);
})();
const groupTitle = (entry as OntimeBlock | null)?.title || 'Group';
return (
<div className={style.metadataRow}>
<span className={style.labelTitle}>{groupTitle}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to scheduled group end' render={<TbCalendarDown className={style.icon} />} />
<span className={cx([style.time, blockStartedAt === null && style.muted])}>{remainingBlockDuration}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to expected group end' render={<TbCalendarStar className={style.icon} />} />
<span className={cx([style.time, blockExpectedEnd === null && style.muted])}>{timeUntilBlockEnd}</span>
</div>
</div>
);
}
function FlagTimes() {
const { clock } = useClock();
const { nextFlag } = useNextFlag();
const entry = useEntry(nextFlag?.id ?? null);
// TODO(v4): can we make a good approximation of time until next flag?
const timeUntil = useTimeUntilStart({
timeStart: nextFlag?.start ?? 0,
delay: 0,
dayOffset: 0,
totalGap: 0,
isLinkedToLoaded: true,
});
if (!nextFlag) {
return (
<div className={style.metadataRow}>
<span className={style.label}>Flag</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, style.muted])}>{timerPlaceholder}</span>
</div>
</div>
);
}
const muted = nextFlag === null;
const flagTitle = (entry as OntimeEvent | null)?.title || 'Flag';
const timeToNextFlag = nextFlag.start - clock;
const display = millisToString(timeToNextFlag, { fallback: timerPlaceholder });
const timeUntilDisplay = millisToString(timeUntil, { fallback: timerPlaceholder });
return (
<div className={style.metadataRow}>
<span className={cx([style.labelTitle])}>{flagTitle}</span>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag scheduled start' render={<TbFlagDown className={style.icon} />} />
<span className={cx([style.time])}>{display}</span>
</div>
<div className={style.labelledElement}>
<Tooltip text='Time to next flag expected start' render={<TbFlagStar className={style.icon} />} />
<span className={cx([style.time, muted && style.muted])}>{timeUntilDisplay}</span>
</div>
</div>
);
}
export function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
return <TimeColumn label='Progress' value={progressText} />;
}
export function RuntimeOverview() {
const { offset, playback } = useRuntimePlaybackOverview();
const isPlaying = isPlaybackActive(playback);
const offsetText = getOffsetText(isPlaying ? offset : null);
const offsetClasses = cx([style.offset, isPlaying && (offset < 0 ? style.behind : style.ahead)]);
return <TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />;
}
export function ClockOverview() {
const { clock } = useClock();
return <TimeColumn label='Time now' value={formattedTime(clock)} />;
}
export function TimerOverview() {
const { current } = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
}
@@ -1,7 +1,6 @@
.label {
color: $label-gray;
font-size: calc(1rem - 2px);
width: 15em; // a number large enough to force right alignment
}
.clock {
@@ -9,6 +8,7 @@
font-size: 1.5rem;
letter-spacing: 0.5px;
min-width: 5em;
font-variant-numeric: tabular-nums;
&::after {
content: '\200b';
@@ -21,6 +21,7 @@
.label {
line-height: 0.9em;
width: 7em;
}
}
@@ -32,6 +33,7 @@
.label {
text-align: right;
width: 5em;
}
.clock {
@@ -1,3 +1,5 @@
import { PropsWithChildren } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils';
@@ -41,3 +43,22 @@ export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutP
</div>
);
}
export function TimeElementsRow({ label, value, daySpan, muted, className }: PropsWithChildren<TimeLayoutProps>) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
{daySpan ? (
<Tooltip
text={`Event spans over ${daySpan + 1} days`}
render={<span />}
className={cx([style.clock, style.daySpan, className])}
>
{value}
</Tooltip>
) : (
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
)}
</div>
);
}
@@ -0,0 +1,10 @@
.title {
font-size: 1.5rem;
@include ellipsis-overflow;
}
.description {
font-size: 1rem;
color: $label-gray;
@include ellipsis-overflow;
}
@@ -0,0 +1,18 @@
import useProjectData from '../../../common/hooks-query/useProjectData';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return (
<div>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
@@ -6,7 +6,7 @@ import { enDash, timerPlaceholder, timerPlaceholderMin } from '../../common/util
/**
* Encapsulates the logic for formatting time in overview
*/
export function formatedTime(
export function formattedTime(
time: MaybeNumber,
segments: number = 3,
direction?: TimerType.CountDown | TimerType.CountUp,