Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 96c8228a19 refactor: use branded types in runtimestate 2026-07-29 23:15:08 +02:00
46 changed files with 705 additions and 820 deletions
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color);
color: $white-10;
.empty {
display: block;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
}
.text {
display: block;
margin-inline: auto;
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
font-weight: 600;
font-size: 2em;
max-width: min(100%, 600px);
}
}
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return (
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</div>
);
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100dvh;
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-block: 5rem;
padding-top: 5rem;
}
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
<Empty text={text} injectedStyles={injectedStyles} />
</div>
);
}
@@ -15,4 +15,9 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {};
export default function useCustomFields() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch };
return { data: data ?? placeholder, status, isFetching, isError, refetch };
}
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
}
export function useUpdateProjectData() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown();
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, isLoadingError, rundownMetadata };
return { data, status, rundownMetadata };
}
/**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status, isLoadingError } = useRundown();
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
@@ -74,15 +74,15 @@ export function useFlatRundown() {
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status, isLoadingError };
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status, isLoadingError } = useRundown();
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status, isLoadingError };
return { data: rundownWithMetadata, status };
}
/**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -11,7 +11,6 @@ export type RundownSource = {
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null;
};
@@ -36,7 +35,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status, isLoadingError } = useRundownById(rundownId);
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
@@ -48,9 +47,8 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown,
flatRundown,
status,
isLoadingError,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status],
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() {
const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
},
});
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch };
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
}
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() {
const { data, status, isLoadingError } = useQuery({
const { data, status } = useQuery({
queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
},
});
return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync };
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync };
}
+102 -110
View File
@@ -1,7 +1,6 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -11,7 +10,6 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
@@ -37,7 +35,7 @@ export default function OperatorLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
@@ -45,7 +43,6 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -116,7 +113,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return (
<div className={style.operatorContainer} data-testid='operator-view'>
@@ -131,121 +127,117 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div>
)}
{!hasEvents ? (
<EmptyFill text={getLocalizedString('common.no_data')} />
) : (
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{rundown.order.map((entryId) => {
const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
const { mainField, secondaryField, subscribedData } = getEventData(
entry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
return (
<OperatorEvent
key={entry.id}
id={entry.id}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if (hidePast && isPast && !isCurrentParent) {
return null;
}
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
id={entry.id}
title={entry.title}
colour={entry.colour}
cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
count={entry.entries.length}
duration={entry.duration}
delay={entry.delay}
dayOffset={entry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
if (hidePast && isPast && !isCurrentParent) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<Fragment key={entry.id}>
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEntry,
mainSource,
secondarySource,
subscribe,
customFields,
);
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
)}
return (
<OperatorEvent
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
totalGap={totalGap}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
})}
</Fragment>
);
}
return null;
})}
</div>
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div>
);
@@ -14,18 +14,9 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const {
data: rundown,
rundownMetadata,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
return {
data: {
@@ -34,10 +25,6 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields,
settings,
},
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
};
}
@@ -1,24 +1,19 @@
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
const isLoading = status !== 'success' || !data || !rundownMetadata;
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} />;
if (isLoading) {
return <Empty text='Connecting to server' />;
}
return (
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -12,7 +13,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields } = useCustomFields();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
@@ -31,10 +32,16 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
</EntryActionsProvider>
);
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
@@ -20,14 +20,10 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
@@ -37,11 +33,6 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
};
}
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid;
place-items: center;
background-color: var(--background-color-override, $viewer-background-color);
height: 100dvh;
height: 100vh;
}
.ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size;
border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
&:nth-child(1) {
left: $dot-size;
animation: lds-ellipsis1 1s infinite;
animation: lds-ellipsis1 0.6s infinite;
}
&:nth-child(2) {
left: $dot-size;
animation: lds-ellipsis2 1s infinite;
animation: lds-ellipsis2 0.6s infinite;
}
&:nth-child(3) {
left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 1s infinite;
animation: lds-ellipsis2 0.6s infinite;
}
&:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 1s infinite;
animation: lds-ellipsis3 0.6s infinite;
}
}
}
+15 -1
View File
@@ -80,7 +80,21 @@ $item-height: 3.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
button {
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
}
.list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} />
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div>
)}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} />
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} />
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} />
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div>
);
}
@@ -21,18 +21,10 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
@@ -42,11 +34,6 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored,
settings,
},
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
};
}
@@ -2,6 +2,7 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -33,34 +34,40 @@ function CuesheetTableWrapper({
const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset],
);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/>
)}
</CuesheetDnd>
);
}
@@ -5,10 +5,6 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,7 +19,6 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -63,9 +62,8 @@ export default function CuesheetTable({
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, isLoadingError, selectedEventId } = source;
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -230,13 +228,10 @@ export default function CuesheetTable({
});
}, [cuesheetMode, hideIndexColumn, table]);
// avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' className={style.tableLoading} />;
}
const isLoading = !flatRundown || status === 'pending';
if (isLoadingError) {
return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
if (isLoading) {
return <EmptyPage text='Loading...' />;
}
return (
@@ -1,7 +1,5 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -18,104 +16,56 @@ $content-width: min(100%, 1100px);
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
width: $content-width;
max-height: 100%;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__custom {
display: flex;
gap: 1rem;
}
.info__image-container {
display: flex;
justify-content: center;
align-items: center;
flex: 0 0 min(192px, 25%);
width: 192px;
height: 192px;
}
.info__image {
display: block;
max-width: 100%;
height: auto;
max-height: 100%;
object-fit: contain;
}
.link.info__value {
display: inline-flex;
gap: 0.35em;
display: flex;
gap: $view-element-gap;
align-items: center;
color: $action-text-color;
@@ -128,13 +78,12 @@ $content-width: min(100%, 1100px);
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,5 +1,4 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -22,7 +21,7 @@ export default function ProjectInfoLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
@@ -42,49 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='info'>
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.url && (
<div className='info__card'>
<div>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{info.url ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -92,31 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { ProjectData } from 'ontime-types';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
import { ViewData } from '../utils/viewLoader.utils';
export interface ProjectInfoData {
projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: projectData, status: projectDataStatus } = useProjectData();
return {
data: {
projectData,
isMirrored,
},
status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
status: projectDataStatus,
};
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
+5 -18
View File
@@ -20,18 +20,10 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
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: {
@@ -41,11 +33,6 @@ export function useStudioData(): ViewData<StudioData> {
settings,
viewSettings,
},
status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
};
}
@@ -1,7 +1,6 @@
import { OntimeView } from 'ontime-types';
import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -30,7 +29,7 @@ export default function TimelinePageLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <TimelinePage {...data} />;
@@ -74,7 +73,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration}
/>
) : (
<EmptyFill text={getLocalizedString('common.no_data')} />
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div>
);
@@ -16,18 +16,10 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const {
data: rundownData,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
@@ -36,11 +28,6 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData,
settings,
},
status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
+10 -18
View File
@@ -22,19 +22,11 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const {
data: viewSettings,
status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
const { data: rundown, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown;
return {
@@ -47,11 +39,11 @@ export function useTimerData(): ViewData<TimerData> {
entries,
},
status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
projectDataStatus,
viewSettingsStatus,
settingsStatus,
customFieldsStatus,
rundownStatus,
]),
};
}
+11 -17
View File
@@ -5,27 +5,21 @@ export type ViewData<T> = {
status: QueryStatus;
};
type AggregatableQuery = {
status: QueryStatus;
/** true only when the query has never received data, ie useQuery's isLoadingError */
isLoadingError: boolean;
};
/**
* Aggregates a loading status from multiple queries, for the purpose of deciding
* whether a view can render.
* - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
* - 'error' once all queries have settled, if any of them never received data
* - 'success' once all queries have settled and every one has data to show,
* even if a query's last (background) fetch failed
* 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(queries: AggregatableQuery[]): QueryStatus {
const allSettled = queries.every((query) => query.status !== 'pending');
if (!allSettled) {
export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus {
if (statuses.every((status) => status === 'pending')) {
return 'pending';
}
if (queries.some((query) => query.isLoadingError)) {
if (statuses.every((status) => status === 'success')) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
return 'error';
}
return 'success';
return 'pending';
}
+17 -1
View File
@@ -1,5 +1,5 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
@@ -59,6 +59,10 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant;
}
export function combineValues<T extends Duration | Day>(...duration: T[]): T {
return Array.isArray(duration) ? (duration.reduce<number>((total, current) => total + current, 0) as T) : duration;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
@@ -80,3 +84,15 @@ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day
return (currentDaySerial - startDaySerial) as Day;
}
export const ZERO_DURATION = 0 as Duration;
/**
* utility function to generate Instant times
*/
export function generateInstanceFromClock(hour: number, minutes: number, second: number, ms = 0) {
return toInstant(
(hour * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + second * MILLIS_PER_SECOND + ms) as TimeOfDay,
now(),
);
}
@@ -1,7 +1,8 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { Duration, EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import type { RuntimeState } from '../../stores/runtimeState.js';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getCurrent,
@@ -19,48 +20,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
clock: 5 * MILLIS_PER_MINUTE,
_now: timeCore.generateInstanceFromClock(0, 5, 0),
timer: {
addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE,
startedAt: 2 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
},
_timer: {
pausedAt: null,
pausedDuration: 0,
},
} as RuntimeState;
} as InternalRuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('subtracts accumulated pause time', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.generateInstanceFromClock(0, 10, 0),
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
},
_timer: {
pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
} as InternalRuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.generateInstanceFromClock(0, 10, 0),
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
} as InternalRuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
});
@@ -82,7 +83,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(null);
});
@@ -95,13 +96,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: true,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(20);
});
@@ -114,13 +115,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: 1,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(11);
});
@@ -133,13 +134,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 20,
duration: 10,
startedAt: 1,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(31);
@@ -153,13 +154,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -10,
duration: 10,
startedAt: 1,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -173,13 +174,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -100,
duration: 10,
startedAt: 1,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -193,13 +194,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 0,
startedAt: 1,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -213,13 +214,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(10);
@@ -234,13 +235,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 10,
duration: dayInMs,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
@@ -253,7 +254,7 @@ describe('getExpectedFinish()', () => {
},
timer: {
addedTime: 0,
startedAt: 79200000, // 22:00:00
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
},
_timer: {
pausedAt: null,
@@ -263,7 +264,7 @@ describe('getExpectedFinish()', () => {
actualStart: 79200000,
plannedEnd: 600000,
},
} as RuntimeState;
} as InternalRuntimeState;
const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time
@@ -279,7 +280,7 @@ describe('getCurrent()', () => {
timeEnd: 30,
timerType: TimerType.CountDown,
},
clock: 0,
_now: timeCore.generateInstanceFromClock(0, 0, 0),
timer: {
addedTime: 10,
duration: 111, // <-- we take the duration value
@@ -289,7 +290,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(111);
@@ -300,17 +301,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
clock: 1,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
timer: {
addedTime: 0,
duration: 10,
startedAt: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(9);
@@ -321,17 +322,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
clock: 1,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
timer: {
addedTime: 10,
duration: 10,
startedAt: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(19);
@@ -342,17 +343,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
clock: 10,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10),
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs + 10);
@@ -363,17 +364,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
clock: 5,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(15);
@@ -384,17 +385,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
clock: 5,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
timer: {
addedTime: 20,
duration: dayInMs + 10,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(35);
@@ -407,7 +408,7 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
clock: 30,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
timer: {
addedTime: 0,
duration: 100,
@@ -420,7 +421,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -432,11 +433,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
clock: 30,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
timer: {
addedTime: 0,
duration: 100,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
rundown: {
plannedEnd: 100,
@@ -445,7 +446,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -457,11 +458,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
clock: 30,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
timer: {
addedTime: 7,
duration: 100,
startedAt: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
},
rundown: {
plannedEnd: 100,
@@ -470,7 +471,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(77);
@@ -483,11 +484,11 @@ describe('getCurrent()', () => {
timeEnd: 600000, // 00:10:00
countToEnd: true,
},
clock: 79500000, // 22:05:00
_now: timeCore.generateInstanceFromClock(22, 5, 0),
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 79200000, // 22:00:00
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
},
rundown: {
actualStart: 79200000,
@@ -497,7 +498,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000);
@@ -505,7 +506,7 @@ describe('getCurrent()', () => {
it('handles events that were started late', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16 min after the scheduled end
eventNow: {
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
@@ -515,7 +516,7 @@ describe('getCurrent()', () => {
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 79200000, // 22:00:00
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
},
rundown: {
actualStart: 82000000, // 22:46:40 <--- started now
@@ -525,7 +526,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const current = getCurrent(state);
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
@@ -541,17 +542,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
clock: 0,
_now: timeCore.generateInstanceFromClock(0, 0, 0),
timer: {
addedTime: 0,
duration,
startedAt: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
@@ -569,25 +570,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
clock: 5,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
timer: {
addedTime: 3,
duration,
startedAt: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
const elapsed = duration - current;
expect(expectedFinish).toBe(13);
expect(elapsed).toBe(2);
expect(current).toBe(8);
expect(elapsed).toBe(2);
});
});
@@ -871,7 +872,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: 150,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
addedTime: 0,
current: 0,
},
@@ -883,9 +884,9 @@ describe('getRuntimeOffset()', () => {
plannedStart: 100,
currentDay: 0,
},
clock: 150,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -899,7 +900,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: 150, // we started 50ms delayed
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed
addedTime: 10, // we compensated with 10ms
current: 10, // we are 10ms into the timer
},
@@ -912,7 +913,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(60);
@@ -927,7 +928,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: 100, // we started ontime
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime
current: -10, // we are 10 seconds over
addedTime: 0, // we have not compensated with added time
},
@@ -940,7 +941,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(10);
@@ -954,9 +955,9 @@ describe('getRuntimeOffset()', () => {
timeEnd: 150,
dayOffset: 0,
},
clock: 150,
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
timer: {
startedAt: 100, // started on time
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time
current: 25, // are 25ms into it
addedTime: 0,
},
@@ -969,7 +970,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(25);
@@ -977,7 +978,7 @@ describe('getRuntimeOffset()', () => {
it('offset doesnt exist if we havent started', () => {
const state = {
clock: 78480789,
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789
eventNow: {
id: 'd6a2ce',
timeStart: 77400000,
@@ -1011,7 +1012,7 @@ describe('getRuntimeOffset()', () => {
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1019,7 +1020,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
const state = {
clock: 80000000, // 22:13:20
_now: timeCore.generateInstanceFromClock(22, 13, 20),
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1063,11 +1064,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 78000000,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1075,7 +1076,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offset is the overtime', () => {
const state = {
clock: 82000000, // 22:46:40
_now: timeCore.generateInstanceFromClock(22, 46, 40),
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1119,11 +1120,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 78000000,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
} as InternalRuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
@@ -1131,7 +1132,7 @@ describe('getRuntimeOffset()', () => {
it('handles time-to-end started after the end time', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16m 40s after the scheduled end
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
@@ -1164,14 +1165,14 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 82000000 + 3600000, // <--- now + duration
playback: Playback.Play,
secondaryTimer: null,
startedAt: 82000000, // <--- started now
startedAt: timeCore.generateInstanceFromClock(22, 46, 40),
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
} as InternalRuntimeState;
const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent;
state.timer.current = updateCurrent as Duration;
const { absolute } = getRuntimeOffset(state);
expect(millisToString(absolute)).toBe('00:16:40');
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
@@ -1187,7 +1188,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: 150,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
addedTime: 0,
current: 0,
},
@@ -1200,7 +1201,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1214,7 +1215,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: 150,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
addedTime: 0,
current: 0,
},
@@ -1227,7 +1228,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -1241,7 +1242,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100),
addedTime: 0,
current: 0,
},
@@ -1254,7 +1255,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
} as InternalRuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(-50);
@@ -1276,7 +1277,7 @@ describe('getTimerPhase()', () => {
secondaryTimer: null,
startedAt: null,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None);
@@ -1294,7 +1295,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime);
@@ -1312,7 +1313,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger);
@@ -1330,7 +1331,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning);
@@ -1348,7 +1349,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default);
@@ -1356,7 +1357,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
clock: 55691050,
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
eventNow: null,
eventNext: null,
rundown: {
@@ -1382,13 +1383,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: null,
forceFinish: false,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
@@ -1396,7 +1397,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
clock: 55691050,
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
eventNow: null,
eventNext: null,
rundown: {
@@ -1422,13 +1423,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: null,
forceFinish: false,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as RuntimeState;
} as InternalRuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
+2
View File
@@ -5,6 +5,8 @@ import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
import { normaliseEndTime } from './timerUtils.js';
// import * as timeCore from '../lib/time-core/timeCore.js';
/**
* Finds current event in a rolling rundown
*/
@@ -760,16 +760,17 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// save the restore state
if (hasImmediateChanges) {
const internalState = runtimeState.getInternalState();
restoreService
.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
pausedAt: internalState._timer.pausedAt,
pausedDuration: internalState._timer.pausedDuration,
firstStart: state.rundown.actualStart,
startEpoch: state._startEpoch,
startEpoch: internalState._startEpoch,
currentDay: state.rundown.currentDay,
})
.catch((_e) => {
+39 -36
View File
@@ -1,7 +1,9 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { Day, Duration, Maybe, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import * as timeCore from '../lib/time-core/timeCore.js';
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -20,48 +22,46 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
/**
* Calculates expected finish time of a running timer
* @param {RuntimeState} state runtime state
* @param {InternalRuntimeState} state runtime state
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
export function getExpectedFinish(state: InternalRuntimeState): Maybe<TimeOfDay> {
const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) {
return null;
}
const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
const { _now } = state;
if (startedAt === null) {
return null;
}
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0;
if (countToEnd) {
return timeEnd + addedTime + pausedTime;
return (timeEnd + addedTime + pausedTime) as TimeOfDay;
}
// handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return expectedFinish - dayInMs;
return (expectedFinish - dayInMs) as TimeOfDay;
}
// an event cannot finish before it started (user added too much negative time)
return Math.max(expectedFinish, startedAt);
return Math.max(expectedFinish, timeCore.toTimeOfDay(startedAt)) as TimeOfDay;
}
/**
* Calculates running countdown
* @param {RuntimeState} state runtime state
* @param {InternalRuntimeState} state runtime state
* @returns {number} current time for timer
*/
export function getCurrent(state: RuntimeState): number {
export function getCurrent(state: InternalRuntimeState): Duration {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
@@ -71,12 +71,12 @@ export function getCurrent(state: RuntimeState): number {
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
const { _now } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration;
}
if (startedAt === null) {
@@ -84,19 +84,19 @@ export function getCurrent(state: RuntimeState): number {
}
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - pausedAt) as Duration;
}
const hasPassedMidnight = startedAt > clock;
const hasPassedMidnight = timeCore.toTimeOfDay(startedAt) > timeCore.toTimeOfDay(_now);
const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - timeCore.toTimeOfDay(_now) - correctDay) as Duration;
}
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
const { _now } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
@@ -104,11 +104,11 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return null;
}
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now);
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt));
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
return Math.max(0, activeElapsed) as Duration;
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
@@ -127,7 +127,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we havent started
// we cant have skipped if we haven't started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false;
}
@@ -151,12 +151,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule
*/
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, clock, _startDayOffset } = state;
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } {
const { eventNow, _now, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: 0, relative: 0 };
return { absolute: ZERO_DURATION, relative: ZERO_DURATION };
}
const { countToEnd, timeStart, dayOffset } = eventNow;
@@ -171,20 +171,23 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
}
// difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
const eventStartOffset =
timeCore.toTimeOfDay(startedAt) + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
const overtime = Math.abs(Math.min(current, 0));
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
const pausedTime = state._timer.pausedAt === null ? 0 : timeCore.toTimeOfDay(_now) - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
// in case of count to end, the absolute offset is overtime and added time
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
const absolute = (
countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime
) as Duration;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
const relative = (absolute + plannedStart - actualStart - _startDayOffset * dayInMs) as Duration;
return { absolute, relative };
}
@@ -193,7 +196,7 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
* Checks running timer to see which phase it currently is in
* @param state
*/
export function getTimerPhase(state: RuntimeState): TimerPhase {
export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
if (!isPlaybackActive(state.timer.playback)) {
return TimerPhase.None;
}
@@ -223,7 +226,7 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
/**
* Finds the day offset relative to an event start
* used byt the runtimeState on first start to get correct offsets
* used by the RuntimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart;
@@ -1,33 +1,12 @@
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { Day, type Duration, type Instant, OffsetMode, Playback, TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
actualGroupStart: null,
currentDay: 0,
},
offset: {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedRundownEnd: null,
expectedGroupEnd: null,
expectedFlagStart: null,
},
export const runtimePlaceholder = {
timer: {
addedTime: 0,
addedTime: ZERO_DURATION,
current: null,
duration: null,
elapsed: null,
@@ -37,10 +16,43 @@ const baseState: RuntimeState = {
secondaryTimer: null,
startedAt: null,
},
offset: {
absolute: ZERO_DURATION,
relative: ZERO_DURATION,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
},
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0 as TimeOfDay,
plannedEnd: 0 as TimeOfDay,
actualStart: null,
actualGroupStart: null,
currentDay: 0 as Day,
},
} as Readonly<InternalRuntimeState>;
const baseState: InternalRuntimeState = {
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
...runtimePlaceholder.rundown,
},
offset: {
...runtimePlaceholder.offset,
},
timer: { ...runtimePlaceholder.timer },
_timer: {
forceFinish: null,
forceFinish: false,
pausedAt: null,
pausedDuration: 0,
pausedDuration: 0 as Duration,
secondaryTarget: null,
hasFinished: false,
},
@@ -6,9 +6,10 @@ import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import {
type RuntimeState,
type InternalRuntimeState,
addTime,
clearState,
getInternalState,
getState,
load,
loadGroupFlagAndEnd,
@@ -32,7 +33,7 @@ const mockEvent = {
} as PlayableEvent;
const mockState = {
clock: 666,
_now: 666,
eventNow: null,
eventNext: null,
rundown: {
@@ -53,7 +54,7 @@ const mockState = {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
} as InternalRuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
@@ -108,34 +109,34 @@ describe('mutation on runtimeState', () => {
const { metadata, rundown } = rundownCache.get();
load(mockEvent, rundown, metadata);
let newState = getState();
let newState = getInternalState();
expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.eventNext?.id).toBe('event2');
expect(newState.timer.playback).toBe(Playback.Armed);
expect(newState.clock).not.toBe(666);
expect(getState().clock).not.toBe(666);
expect(newState.groupNow).toBeNull();
// 2. Start event
vi.setSystemTime('jan 1 00:02');
let success = start();
newState = getState();
newState = getInternalState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
});
expect(newState.rundown.actualStart).toBe(newState.clock);
expect(newState.rundown.actualStart).toBe(getState().clock);
// 3. Pause event
vi.setSystemTime('jan 1 00:03');
success = pause();
newState = getState();
newState = getInternalState();
expect(success).toBe(true);
expect(newState.clock).not.toBe(666);
expect(getState().clock).not.toBe(666);
expect(newState.timer).toMatchObject({
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(newState.clock);
expect(newState._timer.pausedAt).toEqual(getState().clock);
success = pause();
expect(success).toBe(false);
@@ -143,7 +144,7 @@ describe('mutation on runtimeState', () => {
// 4. Restart event
vi.setSystemTime('jan 1 00:04');
success = start();
newState = getState();
newState = getInternalState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
@@ -163,7 +164,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event
vi.setSystemTime('jan 1 00:05');
success = stop();
newState = getState();
newState = getInternalState();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({
@@ -825,7 +826,7 @@ describe('roll mode', () => {
test('pending event', () => {
const { rundown, metadata } = rundownCache.get();
const { eventId, didStart } = roll(rundown, metadata);
const state = getState();
const state = getInternalState();
expect(eventId).toBe('1');
expect(didStart).toBe(false);
@@ -944,7 +945,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[11],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -972,7 +973,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[22],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1000,7 +1001,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1025,7 +1026,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
@@ -1087,7 +1088,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1169,7 +1170,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+154 -105
View File
@@ -17,7 +17,6 @@ import {
TimerPhase,
TimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
import {
calculateDuration,
@@ -44,14 +43,18 @@ import {
hasCrossedMidnight,
} from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js';
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
type ExpectedMetadata = {
event: OntimeEvent;
accumulatedGap: number;
accumulatedGap: Duration;
isLinkedToLoaded: boolean;
} | null;
export type RuntimeState = {
/**
* @deprecated we want to phase out the 24h clock
*/
clock: TimeOfDay;
groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null;
@@ -61,36 +64,72 @@ export type RuntimeState = {
timer: TimerState;
rundown: RundownState;
// private properties of the timer calculations
};
export type InternalRuntimeState = Omit<RuntimeState, 'clock' | 'timer' | 'offset' | 'rundown'> & {
timer: {
addedTime: Duration;
current: Maybe<Duration>;
duration: Maybe<Duration>;
elapsed: Maybe<Duration>;
expectedFinish: Maybe<TimeOfDay>;
phase: TimerPhase;
playback: Playback;
secondaryTimer: Maybe<Duration>;
startedAt: Maybe<Instant>;
};
offset: {
absolute: Duration;
relative: Duration;
mode: OffsetMode;
expectedGroupEnd: Maybe<TimeOfDay>;
expectedRundownEnd: Maybe<TimeOfDay>;
expectedFlagStart: Maybe<TimeOfDay>;
};
rundown: {
selectedEventIndex: Maybe<number>;
numEvents: number;
plannedStart: Maybe<TimeOfDay>;
plannedEnd: Maybe<TimeOfDay>;
actualStart: Maybe<TimeOfDay>;
currentDay: Maybe<Day>;
actualGroupStart: Maybe<TimeOfDay>;
};
_now: Instant;
_timer: {
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
forceFinish: boolean; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
pausedDuration: number;
pausedDuration: Duration;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
};
_rundown: {
totalDelay: number; // this value comes from rundown service
};
_startDayOffset: Maybe<Day>;
_startEpoch: Maybe<Instant>;
_group: ExpectedMetadata;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
};
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
const runtimeState: InternalRuntimeState = {
_now: timeCore.now(),
groupNow: null,
eventNow: null,
eventNext: null,
eventFlag: null,
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
rundown: { ...runtimeStorePlaceholder.rundown },
offset: { ...runtimePlaceholder.offset },
timer: {
...runtimePlaceholder.timer,
},
rundown: {
...runtimePlaceholder.rundown,
},
_timer: {
forceFinish: null,
forceFinish: false,
pausedAt: null,
pausedDuration: 0,
pausedDuration: 0 as Duration,
secondaryTarget: null,
hasFinished: false,
},
@@ -104,19 +143,32 @@ const runtimeState: RuntimeState = {
_startDayOffset: null,
};
export function getInternalState(): Readonly<InternalRuntimeState> {
return runtimeState;
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
...runtimeState,
clock: timeCore.toTimeOfDay(runtimeState._now),
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
timer: {
addedTime: runtimeState.timer.addedTime,
current: runtimeState.timer.current,
duration: runtimeState.timer.duration,
elapsed: runtimeState.timer.elapsed,
expectedFinish: runtimeState.timer.expectedFinish,
phase: runtimeState.timer.phase,
playback: runtimeState.timer.playback,
secondaryTimer: runtimeState.timer.secondaryTimer,
startedAt: runtimeState.timer.startedAt ? timeCore.toTimeOfDay(runtimeState.timer.startedAt) : null,
},
};
}
@@ -127,22 +179,18 @@ export function clearEventData() {
runtimeState.eventNow = null;
runtimeState.eventNext = null;
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
runtimeState._timer.forceFinish = false;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
}
@@ -160,22 +208,18 @@ export function clearState() {
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
runtimeState._timer.forceFinish = false;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
@@ -217,9 +261,9 @@ export function updateRundownData(rundownData: {
runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay;
runtimeState.rundown.plannedEnd =
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
rundownData.firstStart === null ? null : ((rundownData.firstStart + rundownData.totalDuration) as TimeOfDay);
if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
}
@@ -253,7 +297,7 @@ export function load(
// update state
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.duration = event.duration as Duration;
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
@@ -267,13 +311,13 @@ export function load(
(firstStart === null || typeof firstStart === 'number') &&
(startEpoch === null || typeof startEpoch === 'number')
) {
runtimeState.rundown.actualStart = firstStart;
runtimeState.rundown.actualStart = firstStart as TimeOfDay;
runtimeState._startEpoch = startEpoch;
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
}
if (currentDay !== undefined) {
runtimeState.rundown.currentDay = currentDay;
runtimeState.rundown.currentDay = currentDay as Day;
}
const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.offset.absolute = absolute;
@@ -365,16 +409,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
// if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
runtimeState.eventNow.timeStart as TimeOfDay,
offsetClock,
) as TimeOfDay;
}
@@ -391,7 +435,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.startedAt = null;
runtimeState._timer.hasFinished = false;
runtimeState.timer.addedTime = 0;
runtimeState.timer.addedTime = 0 as Duration;
runtimeState._timer.pausedAt = null;
// this could be looked after by the timer
@@ -414,7 +458,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
}
export function start(state: RuntimeState = runtimeState): boolean {
export function start(state: InternalRuntimeState = runtimeState): boolean {
if (state.eventNow === null) {
return false;
}
@@ -422,22 +466,19 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state._now = timeCore.now();
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration;
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd);
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd);
state._timer.pausedAt = null;
}
if (state.timer.startedAt === null) {
state.timer.startedAt = state.clock;
state.timer.startedAt = state._now;
}
state.timer.playback = Playback.Play;
@@ -445,14 +486,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) +
state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
state._startEpoch = state._now;
state.rundown.actualStart = timeCore.toTimeOfDay(state._now);
}
if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
state.rundown.actualGroupStart = state.clock;
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now);
}
// update timer phase
@@ -475,18 +517,18 @@ export function start(state: RuntimeState = runtimeState): boolean {
return true;
}
export function pause(state: RuntimeState = runtimeState): boolean {
export function pause(state: InternalRuntimeState = runtimeState): boolean {
if (state.timer.playback !== Playback.Play) {
return false;
}
state.timer.playback = Playback.Pause;
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
state._now = timeCore.now();
state._timer.pausedAt = timeCore.toTimeOfDay(state._now);
return true;
}
export function stop(state: RuntimeState = runtimeState): boolean {
export function stop(state: InternalRuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
@@ -516,7 +558,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
runtimeState._timer.forceFinish = true;
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
@@ -525,8 +567,8 @@ export function addTime(amount: number) {
}
// we can update the state after handling the side effects
runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount;
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration);
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration);
runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under
@@ -546,10 +588,8 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
const previousClock = timeCore.toTimeOfDay(runtimeState._now);
runtimeState._now = timeCore.now(); // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -558,13 +598,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart);
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, timeCore.toTimeOfDay(runtimeState._now));
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
@@ -618,22 +658,24 @@ export function update(): UpdateResult {
}
// account for offset
const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
const offsetClock = timeCore.toTimeOfDay(
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
);
runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
runtimeState.eventNow.timeStart as TimeOfDay,
offsetClock,
) as TimeOfDay;
}
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration;
return {
hasTimerFinished: false,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer! <= 0,
};
}
}
@@ -650,9 +692,7 @@ export function roll(
}
// we will need to do some calculations, update the time first
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
runtimeState._now = timeCore.now();
// 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
@@ -664,7 +704,9 @@ export function roll(
}
if (offset) {
runtimeState.offset = { ...offset };
runtimeState.offset = {
...offset,
} as InternalRuntimeState['offset'];
}
runtimeState.timer.playback = Playback.Roll;
@@ -673,15 +715,16 @@ export function roll(
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime;
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay;
// account for offset
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration);
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0;
runtimeState.timer.elapsed = 0 as Duration;
// check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
@@ -690,8 +733,8 @@ export function roll(
* If we are starting an event in roll mode
* we backtrace all the start times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart;
runtimeState.timer.startedAt = plannedStart;
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
// reset the secondary timer to cancel any countdowns
runtimeState.timer.secondaryTimer = null;
@@ -709,18 +752,20 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = timeCore.combineValues(
runtimeState._startDayOffset,
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
);
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
runtimeState.eventNow.timeStart as TimeOfDay,
offsetClock,
) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.phase = TimerPhase.Pending;
}
@@ -737,9 +782,9 @@ export function roll(
// account for offset but we only keep it if passed to us
if (offset) {
runtimeState.offset = { ...offset };
runtimeState.offset = { ...offset } as InternalRuntimeState['offset'];
}
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -766,7 +811,7 @@ export function roll(
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -789,18 +834,18 @@ export function roll(
* when we start in roll mode
* we need to backtrace all times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart;
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = plannedStart;
runtimeState.timer.expectedFinish = endTime;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
runtimeState.timer.expectedFinish = endTime as TimeOfDay;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0;
runtimeState.timer.elapsed = 0 as Duration;
// update runtime
runtimeState.rundown.actualStart = plannedStart;
@@ -813,11 +858,11 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
@@ -852,7 +897,11 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
state.offset.expectedGroupEnd = getExpectedEnd(
lastEvent,
lastEventExpectedStart,
state.rundown.currentDay!,
) as TimeOfDay;
}
}
@@ -869,7 +918,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedFlagStart = expectedStart;
state.offset.expectedFlagStart = expectedStart as TimeOfDay;
}
}
@@ -884,7 +933,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay;
}
}
@@ -957,13 +1006,13 @@ export function loadGroupFlagAndEnd(
if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true;
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
}
}
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
}
}
}
@@ -971,7 +1020,7 @@ export function loadGroupFlagAndEnd(
const lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) {
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
}
}
@@ -1,4 +1,4 @@
import type { OntimeEvent } from 'ontime-types';
import type { Duration, OntimeEvent } from 'ontime-types';
import { TimeStrategy } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js';
@@ -103,16 +103,17 @@ export function validateTimes(
/**
* @description calculates event duration considering midnight
* @deprecated - duration is the more useful value from the event data, prefer using that directly
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export function calculateDuration(timeStart: number, timeEnd: number): number {
export function calculateDuration(timeStart: number, timeEnd: number): Duration {
// Durations must be positive
if (timeEnd < timeStart) {
return timeEnd + dayInMs - timeStart;
return (timeEnd + dayInMs - timeStart) as Duration;
}
return timeEnd - timeStart;
return (timeEnd - timeStart) as Duration;
}
export function calculateEnd(timeStart: number, duration: number): number {