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