From 20ee391525c9e0dcfc0d1f36de20ad96f028b101 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Wed, 23 Jul 2025 21:39:20 +0200 Subject: [PATCH] refactor: extract timer data to hook --- apps/client/src/AppRouter.tsx | 2 + apps/client/src/common/hooks/useSocket.ts | 19 +++- .../src/common/models/TimeManager.type.ts | 19 ---- .../src/features/viewers/ViewWrapper.tsx | 103 ------------------ .../src/features/viewers/common/viewUtils.ts | 20 ++-- apps/client/src/views/timer/Timer.tsx | 62 +++++------ apps/client/src/views/timer/useTimerData.ts | 38 +++++++ .../src/views/utils/viewLoader.utils.ts | 19 ++++ 8 files changed, 117 insertions(+), 165 deletions(-) delete mode 100644 apps/client/src/common/models/TimeManager.type.ts delete mode 100644 apps/client/src/features/viewers/ViewWrapper.tsx create mode 100644 apps/client/src/views/timer/useTimerData.ts diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 7dcb736db..7d4eeab62 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -2,6 +2,7 @@ import { ComponentType, lazy, Suspense, useMemo } from 'react'; import { Navigate, Route, useLocation } from 'react-router'; import { OntimeView, OntimeViewPresettable } from 'ontime-types'; +import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; import { useClientPath } from './common/hooks/useClientPath'; import useUrlPresets from './common/hooks-query/useUrlPresets'; import Log from './features/log/Log'; @@ -41,6 +42,7 @@ export default function AppRouter() { path='timer' element={ + } diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index e6eb4c2f1..8a23ee623 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,4 +1,4 @@ -import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; +import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types'; import { useRuntimeStore } from '../stores/runtime'; import { sendSocket } from '../utils/socket'; @@ -225,3 +225,20 @@ export const usePlayback = () => { return useRuntimeStore(featureSelector); }; + +/* ======================= View specific subscriptions ======================= */ + +export const useTimerSocket = createSelector((state: RuntimeStore) => ({ + eventNext: state.eventNext, + eventNow: state.eventNow, + message: state.message, + time: state.timer, + clock: state.clock, + timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown, + countToEndNow: state.eventNow?.countToEnd ?? false, + auxTimer: { + aux1: state.auxtimer1.current, + aux2: state.auxtimer2.current, + aux3: state.auxtimer3.current, + }, +})); diff --git a/apps/client/src/common/models/TimeManager.type.ts b/apps/client/src/common/models/TimeManager.type.ts deleted file mode 100644 index 3a2a23fb5..000000000 --- a/apps/client/src/common/models/TimeManager.type.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types'; - -// first set extends TimerState -export type ViewExtendedTimer = { - addedTime: number; - current: MaybeNumber; - duration: MaybeNumber; - elapsed: MaybeNumber; - expectedFinish: MaybeNumber; - finishedAt: MaybeNumber; - phase: TimerPhase; - playback: Playback; - secondaryTimer: MaybeNumber; - startedAt: MaybeNumber; - - clock: number; - timerType: TimerType; - countToEnd: boolean; -}; diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx deleted file mode 100644 index 8a9893b50..000000000 --- a/apps/client/src/features/viewers/ViewWrapper.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { ComponentType } from 'react'; -import { ViewExtendedTimer } from 'common/models/TimeManager.type'; -import { - CustomFields, - MessageState, - OntimeEvent, - ProjectData, - Runtime, - Settings, - SimpleTimerState, - TimerType, - ViewSettings, -} from 'ontime-types'; -import { useStore } from 'zustand'; - -import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; -import useCustomFields from '../../common/hooks-query/useCustomFields'; -import useProjectData from '../../common/hooks-query/useProjectData'; -import { useFlatRundown } from '../../common/hooks-query/useRundown'; -import useSettings from '../../common/hooks-query/useSettings'; -import useViewSettings from '../../common/hooks-query/useViewSettings'; -import { runtimeStore } from '../../common/stores/runtime'; -import { useViewOptionsStore } from '../../common/stores/viewOptions'; - -type WithDataProps = { - auxTimer: SimpleTimerState; - events: OntimeEvent[]; - customFields: CustomFields; - eventNext: OntimeEvent | null; - eventNow: OntimeEvent | null; - general: ProjectData; - isMirrored: boolean; - message: MessageState; - nextId: string | null; - onAir: boolean; - runtime: Runtime; - selectedId: string | null; - settings: Settings | undefined; // TODO: what is the case for this being undefined? - time: ViewExtendedTimer; - viewSettings: ViewSettings; -}; - -function getDisplayName(Component: React.ComponentType): string { - return Component.displayName || Component.name || 'Component'; -} - -const withData =

(Component: ComponentType

) => { - const WithDataComponent = (props: P) => { - // persisted app state - const isMirrored = useViewOptionsStore((state) => state.mirror); - - // HTTP API data - const { data: rundownData } = useFlatRundown(); - const { data: project } = useProjectData(); - const { data: viewSettings } = useViewSettings(); - const { data: settings } = useSettings(); - const { data: customFields } = useCustomFields(); - - // websocket data - const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } = useStore(runtimeStore); - const selectedId = eventNow?.id ?? null; - const nextId = eventNext?.id ?? null; - - /** - * Contains an extended timer object with properties from the current event - */ - const timeManagerType: ViewExtendedTimer = { - ...timer, - clock, - timerType: eventNow?.timerType ?? TimerType.CountDown, - countToEnd: eventNow?.countToEnd ?? false, - }; - - return ( - <> - - - - ); - }; - - WithDataComponent.displayName = `WithData(${getDisplayName(Component)})`; - return WithDataComponent; -}; - -export default withData; diff --git a/apps/client/src/features/viewers/common/viewUtils.ts b/apps/client/src/features/viewers/common/viewUtils.ts index 7ce663658..29a9e00f2 100644 --- a/apps/client/src/features/viewers/common/viewUtils.ts +++ b/apps/client/src/features/viewers/common/viewUtils.ts @@ -1,24 +1,28 @@ -import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types'; +import { MaybeNumber, MaybeString, OntimeEvent, TimerState, TimerType } from 'ontime-types'; import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; -import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { formatTime } from '../../../common/utils/time'; -type TimerTypeParams = Pick; - +/** + * Gathers all options that affect which timer is displayed and selects the correct data source to display + * it also handles edge cases such as freezing on end + */ export function getTimerByType( freezeEnd: boolean, - timerObject?: TimerTypeParams, + timerTypeNow: TimerType, + countToEndNow: boolean, + clock: number, + timerObject: Pick, timerTypeOverride?: TimerType, ): number | null { if (!timerObject) { return null; } - const viewTimerType = timerTypeOverride ?? timerObject.timerType; + const viewTimerType = timerTypeOverride ?? timerTypeNow; - if (timerObject.countToEnd) { + if (countToEndNow) { if (timerObject.current === null) { return null; } @@ -34,7 +38,7 @@ export function getTimerByType( case TimerType.CountUp: return Math.abs(timerObject.elapsed ?? 0); case TimerType.Clock: - return timerObject.clock; + return clock; case TimerType.None: return null; default: { diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index e3141744c..d9a9e659e 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -1,19 +1,20 @@ import { useMemo } from 'react'; -import { CustomFields, MessageState, OntimeEvent, OntimeView, ProjectData, Settings, ViewSettings } from 'ontime-types'; +import { OntimeView } from 'ontime-types'; import { FitText } from '../../common/components/fit-text/FitText'; import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar'; +import EmptyPage from '../../common/components/state/EmptyPage'; import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; -import { useAuxTimersTime } from '../../common/hooks/useSocket'; +import { useTimerSocket } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; -import { ViewExtendedTimer } from '../../common/models/TimeManager.type'; import { cx } from '../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils'; import { useTranslation } from '../../translation/TranslationProvider'; +import Loader from '../common/loader/Loader'; import { getTimerColour } from '../utils/presentation.utils'; import { getTimerOptions, useTimerOptions } from './timer.options'; @@ -28,33 +29,28 @@ import { getShowProgressBar, getTotalTime, } from './timer.utils'; +import { TimerData, useTimerData } from './useTimerData'; import './Timer.scss'; -interface TimerProps { - customFields: CustomFields; - eventNext: OntimeEvent | null; - eventNow: OntimeEvent | null; - general: ProjectData; - isMirrored: boolean; - message: MessageState; - settings: Settings | undefined; - time: ViewExtendedTimer; - viewSettings: ViewSettings; +export default function TimerLoader() { + const { data, status } = useTimerData(); + + useWindowTitle('Timer'); + + if (status === 'pending') { + return ; + } + + if (status === 'error') { + return ; + } + + return ; } -export default function Timer({ - customFields, - eventNow, - eventNext, - general, - isMirrored, - message, - settings, - time, - viewSettings, -}: TimerProps) { - const auxTimer = useAuxTimersTime(); +function Timer({ customFields, projectData, isMirrored, settings, viewSettings }: TimerData) { + const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); const { hideClock, hideCards, @@ -78,14 +74,12 @@ export default function Timer({ const { getLocalizedString } = useTranslation(); const localisedMinutes = getLocalizedString('common.minutes'); - useWindowTitle('Timer'); - // gather modifiers - const viewTimerType = timerType ?? time.timerType; + const viewTimerType = timerType ?? timerTypeNow; const showOverlay = getShowMessage(message.timer); const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers( - time.timerType, - time.countToEnd, + timerTypeNow, + countToEndNow, time.phase, freezeOvertime, freezeMessage, @@ -107,8 +101,8 @@ export default function Timer({ // gather timer data const totalTime = getTotalTime(time.duration, time.addedTime); - const clock = formatTime(time.clock); - const stageTimer = getTimerByType(freezeOvertime, time, timerType); + const formattedClock = formatTime(clock); + const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, countToEndNow, clock, time, timerType); const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, { removeSeconds: hideTimerSeconds, removeLeadingZero: removeLeadingZeros, @@ -155,7 +149,7 @@ export default function Timer({ className={cx(['stage-timer', isMirrored && 'mirror', showFinished && 'stage-timer--finished'])} style={userStyles} > - {!hideLogo && general?.logo && } + {!hideLogo && projectData?.logo && } @@ -172,7 +166,7 @@ export default function Timer({ {showClock && (

{getLocalizedString('common.time_now')}
- +
)} diff --git a/apps/client/src/views/timer/useTimerData.ts b/apps/client/src/views/timer/useTimerData.ts new file mode 100644 index 000000000..2f7425236 --- /dev/null +++ b/apps/client/src/views/timer/useTimerData.ts @@ -0,0 +1,38 @@ +import { CustomFields, ProjectData, Settings, ViewSettings } from 'ontime-types'; + +import useCustomFields from '../../common/hooks-query/useCustomFields'; +import useProjectData from '../../common/hooks-query/useProjectData'; +import useSettings from '../../common/hooks-query/useSettings'; +import useViewSettings from '../../common/hooks-query/useViewSettings'; +import { useViewOptionsStore } from '../../common/stores/viewOptions'; +import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils'; + +export interface TimerData { + customFields: CustomFields; + projectData: ProjectData; + isMirrored: boolean; + settings: Settings; + viewSettings: ViewSettings; +} + +export function useTimerData(): ViewData { + // persisted app state + const isMirrored = useViewOptionsStore((state) => state.mirror); + + // HTTP API data + const { data: projectData, status: projectDataStatus } = useProjectData(); + const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); + const { data: settings, status: settingsStatus } = useSettings(); + const { data: customFields, status: customFieldsStatus } = useCustomFields(); + + return { + data: { + customFields, + projectData, + isMirrored, + settings, + viewSettings, + }, + status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]), + }; +} diff --git a/apps/client/src/views/utils/viewLoader.utils.ts b/apps/client/src/views/utils/viewLoader.utils.ts index 410f04b0f..4aae1d1ce 100644 --- a/apps/client/src/views/utils/viewLoader.utils.ts +++ b/apps/client/src/views/utils/viewLoader.utils.ts @@ -4,3 +4,22 @@ export type ViewData = { data: T; status: QueryStatus; }; + +/** + * Aggregates a loading status from multiple query statuses. + * If all statuses are 'pending', returns 'pending'. + * If all statuses are 'success', returns 'success'. + * If any status is 'error', returns 'error'. + */ +export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus { + if (statuses.every((status) => status === 'pending')) { + return 'pending'; + } + if (statuses.every((status) => status === 'success')) { + return 'success'; + } + if (statuses.some((status) => status === 'error')) { + return 'error'; + } + return 'pending'; +}