From 22559c59d4cbddd431416609c363c2c7560a5a70 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sun, 5 Oct 2025 14:46:42 +0200 Subject: [PATCH] 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 --- .../src/common/hooks-query/useRundown.ts | 6 +- apps/client/src/common/utils/time.ts | 32 +++++ .../client/src/features/operator/Operator.tsx | 20 ++- .../operator/__tests__/operator.utils.test.ts | 83 ------------- .../src/features/operator/operator.utils.ts | 107 +++++----------- .../src/views/common/schedule/Schedule.tsx | 16 +-- .../views/common/schedule/ScheduleContext.tsx | 7 +- .../views/common/schedule/ScheduleItem.tsx | 115 +++++++++++++----- .../views/common/schedule/schedule.utils.ts | 12 -- apps/client/src/views/timeline/Timeline.tsx | 8 +- .../src/views/timeline/TimelineEntry.tsx | 57 +++++++-- .../src/views/timeline/TimelineSections.tsx | 26 ++-- .../src/views/timeline/timeline.utils.ts | 30 +++-- .../src/views/timeline/useTimelineData.ts | 7 +- 14 files changed, 263 insertions(+), 263 deletions(-) delete mode 100644 apps/client/src/features/operator/__tests__/operator.utils.test.ts delete mode 100644 apps/client/src/views/common/schedule/schedule.utils.ts diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index aa32713e7..d6b953fe7 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -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) => boolean) { + const { data, status } = useFlatRundownWithMetadata(); const filteredData = useMemo(() => { return data.filter(cb); }, [data, cb]); diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 1be0e4118..dc45d7244 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -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, + 'timeStart' | 'dayOffset' | 'delay' | 'totalGap' | 'isLinkedToLoaded' | 'countToEnd' | 'duration' + > | null, + state: ReturnType, +) { + 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, + }; +} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index db766224d..469985682 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -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 (
@@ -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} diff --git a/apps/client/src/features/operator/__tests__/operator.utils.test.ts b/apps/client/src/features/operator/__tests__/operator.utils.test.ts deleted file mode 100644 index 3011ae877..000000000 --- a/apps/client/src/features/operator/__tests__/operator.utils.test.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/apps/client/src/features/operator/operator.utils.ts b/apps/client/src/features/operator/operator.utils.ts index fd73e3c56..05056b581 100644 --- a/apps/client/src/features/operator/operator.utils.ts +++ b/apps/client/src/features/operator/operator.utils.ts @@ -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 { - 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((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((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 }; +} diff --git a/apps/client/src/views/common/schedule/Schedule.tsx b/apps/client/src/views/common/schedule/Schedule.tsx index 90dc38b68..ae88df2ac 100644 --- a/apps/client/src/views/common/schedule/Schedule.tsx +++ b/apps/client/src/views/common/schedule/Schedule.tsx @@ -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 (
    {events.map((event) => { - const { timeStart, timeEnd, delay } = getScheduledTimes(event); - return ( ); })} diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx index ba699c6c3..39f34023c 100644 --- a/apps/client/src/views/common/schedule/ScheduleContext.tsx +++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx @@ -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[]; selectedEventId: string | null; numPages: number; visiblePage: number; @@ -21,7 +22,7 @@ interface ScheduleProviderProps { export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildren) => { const { cycleInterval, stopCycle, filter } = useScheduleOptions(); - const { data: events } = usePartialRundown((entry: OntimeEntry) => { + const { data: events } = usePartialRundown((entry: ExtendedEntry) => { 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 ( [], selectedEventId, numPages, visiblePage, diff --git a/apps/client/src/views/common/schedule/ScheduleItem.tsx b/apps/client/src/views/common/schedule/ScheduleItem.tsx index 5ce4357c9..fd69337ff 100644 --- a/apps/client/src/views/common/schedule/ScheduleItem.tsx +++ b/apps/client/src/views/common/schedule/ScheduleItem.tsx @@ -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, + | '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 ( ); } @@ -42,11 +67,11 @@ export default function ScheduleItem({ timeStart, timeEnd, title, colour, skip, return ( ); } @@ -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) { 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) { + const expectedStartData = useExpectedStartData(); + const { expectedStart, expectedEnd, plannedEnd } = getExpectedTimesFromExtendedEvent( + { + timeStart, + dayOffset, + delay, + totalGap, + isLinkedToLoaded, + countToEnd, + duration, + }, + expectedStartData, + ); + return (
  • - + → - +
    {title}
  • @@ -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 ; +function ExpectedTime({ expectedTime, plannedTime }: ExpectedTimeProps) { + const timeDisplay = formatTime(expectedTime); + const expectedState = getOffsetState(expectedTime - plannedTime); + return ; } diff --git a/apps/client/src/views/common/schedule/schedule.utils.ts b/apps/client/src/views/common/schedule/schedule.utils.ts deleted file mode 100644 index 1488612f2..000000000 --- a/apps/client/src/views/common/schedule/schedule.utils.ts +++ /dev/null @@ -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, - }; -} diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx index 962dcc437..dc15fbbee 100644 --- a/apps/client/src/views/timeline/Timeline.tsx +++ b/apps/client/src/views/timeline/Timeline.tsx @@ -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[]; 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 => 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} /> diff --git a/apps/client/src/views/timeline/TimelineEntry.tsx b/apps/client/src/views/timeline/TimelineEntry.tsx index 006b8bb22..3c12e3878 100644 --- a/apps/client/src/views/timeline/TimelineEntry.tsx +++ b/apps/client/src/views/timeline/TimelineEntry.tsx @@ -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; @@ -38,6 +41,9 @@ export function TimelineEntry({ left, status, start, + dayOffset, + totalGap, + isLinkedToLoaded, title, width, ref, @@ -73,11 +79,29 @@ export function TimelineEntry({
    {formattedStartTime}
    {hasDelay &&
    {formatTime(delayedStart, formatOptions)}
    } - {smallArea && } + {smallArea && ( + + )}
    {showTitle && ( <> - {!smallArea && } + {!smallArea && ( + + )}
    {title}
    )} @@ -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') { diff --git a/apps/client/src/views/timeline/TimelineSections.tsx b/apps/client/src/views/timeline/TimelineSections.tsx index 497c9541c..85aa5037c 100644 --- a/apps/client/src/views/timeline/TimelineSections.tsx +++ b/apps/client/src/views/timeline/TimelineSections.tsx @@ -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 | null; + next: ExtendedEntry | null; + followedBy: ExtendedEntry | 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); } } diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index 1f01d5df7..ccca7601f 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -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[]; firstStart: number; totalDuration: number; } -export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData { +export function useScopedRundown( + rundown: ExtendedEntry[], + 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[] = []; let selectedIndex = selectedEventId ? Infinity : -1; let firstStart = null; let totalDuration = 0; - let lastEntry: PlayableEvent | null = null; + let lastEntry: ExtendedEntry | 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 | null; + next: ExtendedEntry | null; + followedBy: ExtendedEntry | null; }; /** * Returns upcoming events from current: now, next and followedBy */ -export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents { +export function getUpcomingEvents(events: ExtendedEntry[], 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) : 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 | null) + : (getFirstEvent(events).firstEvent as ExtendedEntry | null); + const followedBy = next ? (getNextEvent(events, next.id)?.nextEvent as ExtendedEntry | null) : null; // Return the titles, handling nulls appropriately return { diff --git a/apps/client/src/views/timeline/useTimelineData.ts b/apps/client/src/views/timeline/useTimelineData.ts index 5b4de6b4a..1aa06422d 100644 --- a/apps/client/src/views/timeline/useTimelineData.ts +++ b/apps/client/src/views/timeline/useTimelineData.ts @@ -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[]; projectData: ProjectData; settings: Settings; } export function useTimelineData(): ViewData { // 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();