refactor: extract timer data to hook

This commit is contained in:
Carlos Valente
2025-07-23 21:39:20 +02:00
committed by Carlos Valente
parent 78184b23b7
commit 20ee391525
8 changed files with 117 additions and 165 deletions
+2
View File
@@ -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={
<ViewLoader>
<ViewNavigationMenu isLockable />
<Timer />
</ViewLoader>
}
+18 -1
View File
@@ -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,
},
}));
@@ -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;
};
@@ -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<any>): string {
return Component.displayName || Component.name || 'Component';
}
const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
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 (
<>
<ViewNavigationMenu isLockable />
<Component
{...props}
auxTimer={auxtimer1}
events={rundownData}
customFields={customFields}
eventNext={eventNext}
eventNow={eventNow}
general={project}
isMirrored={isMirrored}
message={message}
nextId={nextId}
onAir={onAir}
runtime={runtime}
selectedId={selectedId}
settings={settings}
time={timeManagerType}
viewSettings={viewSettings}
/>
</>
);
};
WithDataComponent.displayName = `WithData(${getDisplayName(Component)})`;
return WithDataComponent;
};
export default withData;
@@ -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<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
/**
* 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<TimerState, 'current' | 'elapsed'>,
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: {
+28 -34
View File
@@ -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 <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
}
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 && <ViewLogo name={general.logo} className='logo' />}
{!hideLogo && projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
@@ -172,7 +166,7 @@ export default function Timer({
{showClock && (
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
<SuperscriptTime time={formattedClock} className='clock' />
</div>
)}
@@ -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<TimerData> {
// 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]),
};
}
@@ -4,3 +4,22 @@ export type ViewData<T> = {
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';
}