mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user