Expected time for views (#1798)

* refactor: use correct metadata in op view

* refactor: use correct metadata in backstage view

* remove uneeded test

* fix: correct offset colour

* refactor: use correct metadata in timline view

* refactor: schedule item dont pass the whole event

* chore: lint
This commit is contained in:
Alex Christoffer Rasmussen
2025-10-05 14:46:42 +02:00
parent 700948bfbb
commit 22559c59d4
14 changed files with 263 additions and 263 deletions
@@ -6,7 +6,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import useProjectData from './useProjectData';
@@ -85,8 +85,8 @@ export function useFlatRundownWithMetadata() {
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown();
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
+32
View File
@@ -12,6 +12,8 @@ import { APP_SETTINGS } from '../api/constants';
import { useExpectedStartData } from '../hooks/useSocket';
import { ontimeQueryClient } from '../queryClient';
import { ExtendedEntry } from './rundownMetadata';
/**
* Returns current time in milliseconds from midnight
* @returns {number}
@@ -154,3 +156,33 @@ export function useTimeUntilExpectedStart(
);
return expectedStart - clock;
}
export function getExpectedTimesFromExtendedEvent(
event: Pick<
ExtendedEntry<OntimeEvent>,
'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration'
> | null,
state: ReturnType<typeof useExpectedStartData>,
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
+9 -11
View File
@@ -8,7 +8,7 @@ import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
@@ -21,14 +21,14 @@ import OperatorGroup from './operator-group/OperatorGroup';
import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions, useOperatorOptions } from './operator.options';
import type { EditEvent } from './operator.types';
import { getEventData, makeOperatorMetadata } from './operator.utils';
import { getEventData } from './operator.utils';
import style from './Operator.module.scss';
const selectedOffset = 50;
export default function Operator() {
const { data, status } = useRundown();
const { data, rundownMetadata, status } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
@@ -113,7 +113,6 @@ export default function Operator() {
}
const canEdit = shouldEdit && subscribe.length;
const { process } = makeOperatorMetadata(selectedEventId);
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -130,8 +129,7 @@ export default function Operator() {
{data.order.map((entryId) => {
const entry = data.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(entry);
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
@@ -158,9 +156,9 @@ export default function Operator() {
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
@@ -179,7 +177,7 @@ export default function Operator() {
return null;
}
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
@@ -207,9 +205,9 @@ export default function Operator() {
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
@@ -1,83 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { makeOperatorMetadata } from '../operator.utils';
describe('makeOperatorMetadata()', () => {
it('should track past, selected states, gaps and linking', () => {
const event1 = { id: 'event1', gap: 5, linkStart: false } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: true } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
const { process } = makeOperatorMetadata('event2');
expect(process(event1)).toEqual({
isPast: true,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: false,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: true,
totalGap: 15,
isLinkedToLoaded: false,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: true,
});
});
it('should handle null selectedId', () => {
const event1 = { id: 'event1', gap: 5, linkStart: true } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: false } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true } as OntimeEvent;
const { process } = makeOperatorMetadata(null);
expect(process(event1)).toEqual({
isPast: false,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: true,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: false,
totalGap: 15,
isLinkedToLoaded: false,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: true,
});
});
it('should break linking chain on countToEnd events', () => {
const event1 = { id: 'event1', gap: 5, linkStart: true, countToEnd: false } as OntimeEvent;
const event2 = { id: 'event2', gap: 10, linkStart: true, countToEnd: true } as OntimeEvent;
const event3 = { id: 'event3', gap: 15, linkStart: true, countToEnd: false } as OntimeEvent;
const { process } = makeOperatorMetadata(null);
expect(process(event1)).toEqual({
isPast: false,
isSelected: false,
totalGap: 5,
isLinkedToLoaded: true,
});
expect(process(event2)).toEqual({
isPast: false,
isSelected: false,
totalGap: 15,
isLinkedToLoaded: true,
});
expect(process(event3)).toEqual({
isPast: false,
isSelected: false,
totalGap: 30,
isLinkedToLoaded: false,
});
});
});
@@ -1,74 +1,33 @@
import { CustomFields, EntryId, MaybeString, OntimeEvent } from 'ontime-types';
import { getPropertyValue } from '../viewers/common/viewUtils';
import type { Subscribed } from './operator.types';
type OperatorMetadata = {
isLinkedToLoaded: boolean;
isPast: boolean;
isSelected: boolean;
totalGap: number;
};
export function makeOperatorMetadata(selectedId: EntryId | null) {
const hasSelection = Boolean(selectedId);
let hasSeenSelected = false;
let totalGap = 0;
/** if the event can link all the way back to the currently playing event */
let isLinkedToLoaded = false;
let previousEvent: OntimeEvent | null = null;
function process(event: OntimeEvent): Readonly<OperatorMetadata> {
const isSelected = event.id === selectedId;
if (isSelected) {
hasSeenSelected = true;
}
// is past if we havent yet seen the selected event
const isPast = hasSelection && !hasSeenSelected;
totalGap += event.gap;
if (!isPast && !isSelected) {
/**
* isLinkToLoaded is a chain value that we maintain until we
* a) find an unlinked event
* b) find a countToEnd event
*/
isLinkedToLoaded = event.linkStart && !previousEvent?.countToEnd;
}
previousEvent = event;
return { isPast, isSelected, totalGap, isLinkedToLoaded };
}
return { process };
}
export function getEventData(
event: OntimeEvent,
main: MaybeString,
secondary: MaybeString,
subscriptions: string[],
customFields: CustomFields,
) {
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
const secondaryField = getPropertyValue(event, secondary) ?? '';
// remove subscriptions that are not in customFields
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
const field = customFields[id];
if (field) {
acc.push({
id,
label: field.label,
colour: field.colour,
value: event.custom[id],
});
}
return acc;
}, []);
return { mainField, secondaryField, subscribedData };
}
import { CustomFields, MaybeString, OntimeEvent } from 'ontime-types';
import { getPropertyValue } from '../viewers/common/viewUtils';
import type { Subscribed } from './operator.types';
export function getEventData(
event: OntimeEvent,
main: MaybeString,
secondary: MaybeString,
subscriptions: string[],
customFields: CustomFields,
) {
const mainField = main ? getPropertyValue(event, main) ?? '' : event.title;
const secondaryField = getPropertyValue(event, secondary) ?? '';
// remove subscriptions that are not in customFields
const sanitisedSubscriptions = subscriptions.filter((field) => Object.hasOwn(customFields, field));
const subscribedData = sanitisedSubscriptions.reduce<Subscribed>((acc, id) => {
const field = customFields[id];
if (field) {
acc.push({
id,
label: field.label,
colour: field.colour,
value: event.custom[id],
});
}
return acc;
}, []);
return { mainField, secondaryField, subscribedData };
}
@@ -1,6 +1,5 @@
import { cx } from '../../../common/utils/styleUtils';
import { getScheduledTimes } from './schedule.utils';
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
@@ -20,17 +19,20 @@ export default function Schedule({ className }: ScheduleProps) {
return (
<ul className={cx(['schedule', className])} ref={containerRef}>
{events.map((event) => {
const { timeStart, timeEnd, delay } = getScheduledTimes(event);
return (
<ScheduleItem
key={event.id}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
timeStart={event.timeStart}
dayOffset={event.dayOffset}
delay={event.delay}
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
countToEnd={event.countToEnd}
duration={event.duration}
colour={event.colour}
skip={event.skip}
delay={delay}
title={event.title}
timeEnd={event.timeEnd}
/>
);
})}
@@ -2,11 +2,12 @@ import { createContext, PropsWithChildren, RefObject, use, useEffect, useLayoutE
import { EntryId, isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
interface ScheduleContextState {
events: OntimeEvent[];
events: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
numPages: number;
visiblePage: number;
@@ -21,7 +22,7 @@ interface ScheduleProviderProps {
export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle, filter } = useScheduleOptions();
const { data: events } = usePartialRundown((entry: OntimeEntry) => {
const { data: events } = usePartialRundown((entry: ExtendedEntry<OntimeEntry>) => {
if (filter) {
// custom keys are prepended with custom-
const customKey = filter.startsWith('custom-') ? filter.slice('custom-'.length) : filter;
@@ -133,7 +134,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
return (
<ScheduleContext
value={{
events: viewEvents as OntimeEvent[],
events: viewEvents as ExtendedEntry<OntimeEvent>[],
selectedEventId,
numPages,
visiblePage,
@@ -1,7 +1,10 @@
import { useRuntimeOffset } from '../../../common/hooks/useSocket';
import { OntimeEvent } from 'ontime-types';
import { useExpectedStartData } from '../../../common/hooks/useSocket';
import { getOffsetState } from '../../../common/utils/offset';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { cx } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
import { formatTime, getExpectedTimesFromExtendedEvent } from '../../../common/utils/time';
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { useScheduleOptions } from './schedule.options';
@@ -13,27 +16,49 @@ const formatOptions = {
format24: 'HH:mm',
};
interface ScheduleItemProps {
timeStart: number;
timeEnd: number;
title: string;
colour?: string;
skip?: boolean;
delay: number;
}
type ScheduleItemProps = Pick<
ExtendedEntry<OntimeEvent>,
| 'timeStart'
| 'dayOffset'
| 'delay'
| 'totalGap'
| 'isLinkedToLoaded'
| 'countToEnd'
| 'duration'
| 'colour'
| 'skip'
| 'title'
| 'timeEnd'
>;
export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
export default function ScheduleItem({
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
colour,
duration,
skip,
title,
timeEnd,
}: ScheduleItemProps) {
const { showExpected } = useScheduleOptions();
if (showExpected) {
return (
<ExpectedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
dayOffset={dayOffset}
delay={delay}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
countToEnd={countToEnd}
duration={duration}
colour={colour}
skip={skip}
delay={delay}
title={title}
/>
);
}
@@ -42,11 +67,11 @@ export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip,
return (
<DelayedScheduleItem
timeStart={timeStart}
timeEnd={timeEnd}
title={title}
delay={delay}
colour={colour}
skip={skip}
delay={delay}
timeEnd={timeEnd}
title={title}
/>
);
}
@@ -66,7 +91,14 @@ export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip,
);
}
function DelayedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
function DelayedScheduleItem({
timeStart,
timeEnd,
title,
colour,
skip,
delay,
}: Pick<ScheduleItemProps, 'timeStart' | 'timeEnd' | 'title' | 'colour' | 'skip' | 'delay'>) {
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const delayedStart = formatTime(timeStart + delay, formatOptions);
@@ -92,14 +124,39 @@ function DelayedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }:
);
}
function ExpectedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }: ScheduleItemProps) {
function ExpectedScheduleItem({
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
colour,
duration,
skip,
title,
}: Omit<ScheduleItemProps, 'timeEnd'>) {
const expectedStartData = useExpectedStartData();
const { expectedStart, expectedEnd, plannedEnd } = getExpectedTimesFromExtendedEvent(
{
timeStart,
dayOffset,
delay,
totalGap,
isLinkedToLoaded,
countToEnd,
duration,
},
expectedStartData,
);
return (
<li className={cx(['entry', skip && 'entry--skip'])}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: colour }} />
<ExpectedTime time={timeStart} delay={delay} />
<ExpectedTime expectedTime={expectedStart} plannedTime={timeStart} />
<ExpectedTime time={timeEnd} delay={delay} />
<ExpectedTime expectedTime={expectedEnd} plannedTime={plannedEnd} />
</div>
<div className='entry-title'>{title}</div>
</li>
@@ -107,16 +164,12 @@ function ExpectedScheduleItem({ timeStart, timeEnd, title, colour, skip, delay }
}
interface ExpectedTimeProps {
time: number;
delay: number;
expectedTime: number;
plannedTime: number;
}
function ExpectedTime({ time, delay }: ExpectedTimeProps) {
const { offset } = useRuntimeOffset();
const expectedOffset = offset - delay;
const expectedTime = formatTime(time + offset, formatOptions);
const expectedState = getOffsetState(expectedOffset);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={expectedTime} />;
function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) {
const timeDisplay = formatTime(expectedTime);
const expectedState = getOffsetState(expectedTime - plannedTime);
return <SuperscriptTime className={`entry-times--${expectedState}`} time={timeDisplay} />;
}
@@ -1,12 +0,0 @@
import { OntimeEvent } from 'ontime-types';
/**
* Gather rules for how to present scheduled times
*/
export function getScheduledTimes(event: OntimeEvent) {
return {
timeStart: event.timeStart,
timeEnd: event.timeEnd,
delay: event.skip ? 0 : event.delay,
};
}
+6 -2
View File
@@ -4,6 +4,7 @@ import { isOntimeEvent, isPlayableEvent, OntimeEntry, PlayableEvent } from 'onti
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import useHorizontalFollowComponent from '../../common/hooks/useHorizontalFollowComponent';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
@@ -15,7 +16,7 @@ import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
rundown: OntimeEntry[];
rundown: ExtendedEntry<OntimeEntry>[];
selectedEventId: string | null;
totalDuration: number;
}
@@ -45,7 +46,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
const { positions, totalWidth } = useMemo(() => {
const playableEvents = rundown
.filter((event): event is PlayableEvent => isOntimeEvent(event) && isPlayableEvent(event))
.filter((event): event is ExtendedEntry<PlayableEvent> => isOntimeEvent(event) && isPlayableEvent(event))
.map((event) => ({
start: event.timeStart + (event.dayOffset ?? 0) * dayInMs + (event.delay ?? 0),
duration: event.duration,
@@ -96,6 +97,9 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
left={position.left}
status={statusMap[event.id]}
start={event.timeStart + (event.dayOffset ?? 0) * dayInMs}
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
dayOffset={event.dayOffset}
title={event.title}
width={position.width}
/>
@@ -1,12 +1,12 @@
import { RefObject } from 'react';
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../common/utils/time';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import { getStatusLabel, getTimeToStart } from './timeline.utils';
import { getStatusLabel } from './timeline.utils';
import style from './Timeline.module.scss';
@@ -20,6 +20,9 @@ interface TimelineEntryProps {
left: number;
status: ProgressStatus;
start: number;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
title: string;
width: number;
ref?: RefObject<HTMLDivElement | null>;
@@ -38,6 +41,9 @@ export function TimelineEntry({
left,
status,
start,
dayOffset,
totalGap,
isLinkedToLoaded,
title,
width,
ref,
@@ -73,11 +79,29 @@ export function TimelineEntry({
<div className={style.maybeInline}>
<div className={cx([hasDelay && style.cross])}>{formattedStartTime}</div>
{hasDelay && <div className={style.delay}>{formatTime(delayedStart, formatOptions)}</div>}
{smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
{smallArea && (
<TimelineEntryStatus
delay={delay}
start={start}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
status={status}
/>
)}
</div>
{showTitle && (
<>
{!smallArea && <TimelineEntryStatus delay={delay} start={start} status={status} />}
{!smallArea && (
<TimelineEntryStatus
delay={delay}
start={start}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
status={status}
/>
)}
<div>{title}</div>
</>
)}
@@ -92,16 +116,31 @@ export function TimelineEntry({
interface TimelineEntryStatusProps {
delay: number;
start: number;
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
status: ProgressStatus;
}
// extract component to isolate re-renders provoked by the clock changes
function TimelineEntryStatus({ delay, start, status }: TimelineEntryStatusProps) {
const { clock, offset } = useTimelineStatus();
function TimelineEntryStatus({
delay,
start,
dayOffset,
totalGap,
isLinkedToLoaded,
status,
}: TimelineEntryStatusProps) {
const state = useExpectedStartData();
const { getLocalizedString } = useTranslation();
// start times need to be normalised in a rundown that crosses midnight
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status);
const { timeToStart } = getExpectedTimesFromExtendedEvent(
{ timeStart: start, delay, dayOffset, totalGap, isLinkedToLoaded, countToEnd: false, duration: 0 },
state,
);
let statusText = getStatusLabel(timeToStart, status);
if (statusText === 'live') {
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
@@ -1,21 +1,21 @@
import { OntimeEvent } from 'ontime-types';
import { useTimelineSocket } from '../../common/hooks/useSocket';
import { formatDuration } from '../../common/utils/time';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import TimelineSection from './timeline-section/TimelineSection';
import { getTimeToStart } from './timeline.utils';
interface TimelineSectionsProps {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
}
export default function TimelineSections({ now, next, followedBy }: TimelineSectionsProps) {
const { getLocalizedString } = useTranslation();
const { clock, offset } = useTimelineSocket();
const state = useExpectedStartData();
// gather card data
const titleNow = now?.title ?? '-';
@@ -26,20 +26,20 @@ export default function TimelineSections({ now, next, followedBy }: TimelineSect
let followedByStatus: string | undefined;
if (next !== null) {
const timeToStart = getTimeToStart(clock, next.timeStart, next?.delay ?? 0, offset);
if (timeToStart < 0) {
const { timeToStart } = getExpectedTimesFromExtendedEvent(next, state);
if (timeToStart <= 0) {
nextStatus = dueText;
} else {
nextStatus = `T - ${formatDuration(timeToStart)}`;
nextStatus = formatDuration(timeToStart);
}
}
if (followedBy !== null) {
const timeToStart = getTimeToStart(clock, followedBy.timeStart, followedBy?.delay ?? 0, offset);
if (timeToStart < 0) {
const { timeToStart } = getExpectedTimesFromExtendedEvent(followedBy, state);
if (timeToStart <= 0) {
followedByStatus = dueText;
} else {
followedByStatus = `T - ${formatDuration(timeToStart)}`;
followedByStatus = formatDuration(timeToStart);
}
}
@@ -10,6 +10,7 @@ import {
MILLIS_PER_HOUR,
} from 'ontime-utils';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration } from '../../common/utils/time';
import { useTimelineOptions } from './timeline.options';
@@ -76,7 +77,7 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
return status;
}
if (timeToStart < 0) {
if (timeToStart <= 0) {
return 'pending';
}
@@ -84,12 +85,15 @@ export function getStatusLabel(timeToStart: number, status: ProgressStatus): str
}
interface ScopedRundownData {
scopedRundown: PlayableEvent[];
scopedRundown: ExtendedEntry<PlayableEvent>[];
firstStart: number;
totalDuration: number;
}
export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
export function useScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
): ScopedRundownData {
const { hidePast } = useTimelineOptions();
const data = useMemo(() => {
@@ -97,11 +101,11 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: PlayableEvent[] = [];
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
@@ -150,26 +154,28 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
}
type UpcomingEvents = {
now: OntimeEvent | null;
next: OntimeEvent | null;
followedBy: OntimeEvent | null;
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
};
/**
* Returns upcoming events from current: now, next and followedBy
*/
export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents {
export function getUpcomingEvents(events: ExtendedEntry<PlayableEvent>[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
let now = selectedId ? getEventWithId(events, selectedId) : null;
let now = selectedId ? (getEventWithId(events, selectedId) as ExtendedEntry<OntimeEvent>) : null;
if (!isOntimeEvent(now)) {
now = null;
}
const next = now ? getNextEvent(events, now.id)?.nextEvent : getFirstEvent(events).firstEvent;
const followedBy = next ? getNextEvent(events, next.id)?.nextEvent : null;
const next = now
? (getNextEvent(events, now.id)?.nextEvent as ExtendedEntry<OntimeEvent> | null)
: (getFirstEvent(events).firstEvent as ExtendedEntry<OntimeEvent> | null);
const followedBy = next ? (getNextEvent(events, next.id)?.nextEvent as ExtendedEntry<OntimeEvent> | null) : null;
// Return the titles, handling nulls appropriately
return {
@@ -1,19 +1,20 @@
import { OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface TimelineData {
events: OntimeEntry[];
events: ExtendedEntry<OntimeEntry>[];
projectData: ProjectData;
settings: Settings;
}
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();