Compare commits

..

6 Commits

Author SHA1 Message Date
Claude f2455fec17 refactor(views): keep query status truthful, push render logic to the view boundary
Reworks the previous fix, which had base hooks (useRundown, useSettings,
useCustomFields, useProjectData, useViewSettings) rewrite their own
status/isError to hide background-refetch failures. That was misleading:
those hooks are consumed well beyond the 9 view loaders, and other callers
may legitimately want to know a fetch genuinely failed.

Instead the base hooks now return react-query's status/isError untouched,
plus the isLoadingError flag react-query already computes (true only when
a query has never received data). aggregateQueryStatus (viewLoader.utils.ts)
takes {status, isLoadingError} pairs and only reports 'error' once every
query has settled and at least one never got data; a background/refetch
error with data present now aggregates to 'success'. The two single-query
consumers outside that aggregator (RundownList/CuesheetTable via
useScopedRundown, and ProjectInfo) branch on isLoadingError directly instead
of status === 'error'.

Removes the standalone deriveQueryStatus helper introduced in the prior
iteration, since the logic now lives in the one place that already existed
for this purpose (aggregateQueryStatus) rather than a new abstraction.
2026-08-02 05:28:49 +00:00
Claude f7b58f5ebd refactor(views): use react-query's isLoadingError instead of reimplementing it
deriveQueryStatus previously inferred "never received data" by checking
data !== undefined itself. TanStack Query already computes and exposes
this exact distinction as isLoadingError (isError && no data) vs
isRefetchError (isError && data present) on every useQuery result, so
consume that directly instead of duplicating the logic.
2026-08-01 18:59:39 +00:00
Claude 9e2dfaab16 fix(views): move stale-data-on-error fix to the shared query hooks
The previous Operator-only fix (checking rundown.revision) papered over a
bug shared by every view loader: react-query sets status to 'error' on any
failed fetch, including a background refetch, even when a prior successful
fetch's data is still cached. All view loaders treated that as "no data"
and blanked the whole view.

Add deriveQueryStatus(), a small helper that only reports 'error' when a
query has never received data, and apply it in the five base data hooks
(useRundown, useRundownById, useCustomFields, useSettings, useProjectData,
useViewSettings). This fixes the class of bug for every consumer (Operator,
Timer, Backstage, Studio, Countdown, TimelinePage, ProjectInfo, RundownList,
CuesheetTable) at the source, so the Operator-specific revision check can
be reverted back to the plain status check.
2026-08-01 18:53:44 +00:00
Claude 65e1d3cfe9 fix(operator): only show error empty state when no rundown data is available
The operator view previously replaced its content with the full-page
error state on any query error, even when a previously fetched rundown
was still cached. Now it falls back to rendering the cached data and
only shows the error placeholder if a rundown has never been received.
2026-08-01 18:47:01 +00:00
Carlos Valente 94d54529ee refactor(views): consistent loading and empty states 2026-08-01 16:25:14 +02:00
Carlos Valente f7535651f6 refactor(info): improve UI consistency and polish 2026-08-01 14:41:39 +02:00
39 changed files with 533 additions and 436 deletions
@@ -1,20 +1,43 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: $white-10; color: var(--secondary-color-override, $viewer-secondary-color);
.empty { .empty {
display: block; display: block;
width: min(100%, 24rem); width: min(100%, 14rem);
margin-inline: auto; margin: 0 auto -1.5rem;
opacity: 0.8; opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 400;
font-size: 2em; font-size: clamp(1rem, 1.55vw, 1.5rem);
max-width: min(100%, 600px); 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;
} }
} }
@@ -1,4 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,12 +10,18 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles }: EmptyProps) { export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
return ( return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}> <div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -0,0 +1,11 @@
.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;
}
@@ -0,0 +1,19 @@
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 */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100vh; height: 100dvh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,5 +16,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 5rem; justify-content: center;
padding-block: 5rem;
} }
@@ -7,12 +7,13 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} /> <Empty text={text} injectedStyles={injectedStyles} variant={variant} />
</div> </div>
); );
} }
@@ -15,9 +15,4 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} /> <Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -8,12 +8,12 @@ import { getCustomFields } from '../api/customFields';
const placeholder: CustomFields = {}; const placeholder: CustomFields = {};
export default function useCustomFields() { export default function useCustomFields() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: CUSTOM_FIELDS, queryKey: CUSTOM_FIELDS,
queryFn: ({ signal }) => getCustomFields({ signal }), queryFn: ({ signal }) => getCustomFields({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? placeholder, status, isFetching, isError, refetch }; return { data: data ?? placeholder, status, isFetching, isError, isLoadingError, refetch };
} }
@@ -6,14 +6,14 @@ import { getProjectData, postProjectData } from '../api/project';
import { projectDataPlaceholder } from '../models/ProjectData'; import { projectDataPlaceholder } from '../models/ProjectData';
export default function useProjectData() { export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: PROJECT_DATA, queryKey: PROJECT_DATA,
queryFn: ({ signal }) => getProjectData({ signal }), queryFn: ({ signal }) => getProjectData({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch }; return { data: data ?? projectDataPlaceholder, status, isFetching, isError, isLoadingError, refetch };
} }
export function useUpdateProjectData() { export function useUpdateProjectData() {
@@ -32,7 +32,7 @@ export default function useRundown() {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
@@ -50,14 +50,14 @@ export default function useRundown() {
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]); }, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata }; return { data, status, isLoadingError, rundownMetadata };
} }
/** /**
@@ -65,7 +65,7 @@ export function useRundownWithMetadata() {
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown() { export function useFlatRundown() {
const { data, status } = useRundown(); const { data, status, isLoadingError } = useRundown();
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { 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); return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]); }, [data]);
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status, isLoadingError };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status, isLoadingError } = useRundown();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status, isLoadingError };
} }
/** /**
@@ -127,7 +127,7 @@ export function useRundownAuxData() {
export function useRundownById(rundownId: string | null | undefined) { export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId); const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, isLoadingError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''), queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }), queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled, enabled,
@@ -135,5 +135,5 @@ export function useRundownById(rundownId: string | null | undefined) {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; return { data: data ?? cachedRundownPlaceholder, status, isError, isLoadingError, refetch, isFetching };
} }
@@ -11,6 +11,7 @@ export type RundownSource = {
rundown: Rundown; rundown: Rundown;
flatRundown: ExtendedEntry[]; flatRundown: ExtendedEntry[];
status: string; status: string;
isLoadingError: boolean;
selectedEventId: EntryId | null; selectedEventId: EntryId | null;
}; };
@@ -35,7 +36,7 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId; const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId(); const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null; const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId); const { data: rundown, status, isLoadingError } = useRundownById(rundownId);
const flatRundown = useMemo( const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId), () => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown], [effectiveSelectedEventId, rundown],
@@ -47,8 +48,9 @@ function useRundownSource(rundownId: string | null, loadedRundownId: string | nu
rundown, rundown,
flatRundown, flatRundown,
status, status,
isLoadingError,
selectedEventId: effectiveSelectedEventId, selectedEventId: effectiveSelectedEventId,
}), }),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status], [effectiveSelectedEventId, flatRundown, isLoadingError, rundown, rundownId, status],
); );
} }
@@ -6,7 +6,7 @@ import { getSettings } from '../api/settings';
import { ontimePlaceholderSettings } from '../models/OntimeSettings'; import { ontimePlaceholderSettings } from '../models/OntimeSettings';
export default function useSettings() { export default function useSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, isLoadingError, refetch } = useQuery({
queryKey: APP_SETTINGS, queryKey: APP_SETTINGS,
queryFn: ({ signal }) => getSettings({ signal }), queryFn: ({ signal }) => getSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -22,5 +22,5 @@ export default function useSettings() {
}, },
}); });
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, isLoadingError, refetch };
} }
@@ -7,7 +7,7 @@ import { VIEW_SETTINGS } from '../api/constants';
import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
export default function useViewSettings() { export default function useViewSettings() {
const { data, status } = useQuery({ const { data, status, isLoadingError } = useQuery({
queryKey: VIEW_SETTINGS, queryKey: VIEW_SETTINGS,
queryFn: ({ signal }) => getViewSettings({ signal }), queryFn: ({ signal }) => getViewSettings({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
@@ -24,5 +24,5 @@ export default function useViewSettings() {
}, },
}); });
return { data: data ?? viewsSettingsPlaceholder, status, mutateAsync }; return { data: data ?? viewsSettingsPlaceholder, status, isLoadingError, mutateAsync };
} }
+110 -102
View File
@@ -1,6 +1,7 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -10,6 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -35,7 +37,7 @@ export default function OperatorLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Operator {...data} />; return <Operator {...data} />;
@@ -43,6 +45,7 @@ export default function OperatorLoader() {
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { getLocalizedString } = useTranslation();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -113,6 +116,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
const hasEvents = rundown.order.length > 0;
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
@@ -127,117 +131,121 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
</div> </div>
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> {!hasEvents ? (
{rundown.order.map((entryId) => { <EmptyFill text={getLocalizedString('common.no_data')} />
const entry = rundown.entries[entryId]; ) : (
if (isOntimeEvent(entry)) { <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; {rundown.order.map((entryId) => {
// hide past events (if setting) and skipped events const entry = rundown.entries[entryId];
if ((hidePast && isPast) || entry.skip) { if (isOntimeEvent(entry)) {
return null; 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( const { mainField, secondaryField, subscribedData } = getEventData(
entry, entry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
customFields, customFields,
); );
return ( return (
<OperatorEvent <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} key={entry.id}
title={entry.title} id={entry.id}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} cue={entry.cue}
main={mainField}
secondary={secondaryField}
timeStart={entry.timeStart}
duration={entry.duration} 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;
}
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId]; if (isOntimeGroup(entry)) {
const { isPast } = rundownMetadata[entry.id];
// hide past events (if setting) and skipped events const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
if ((hidePast && isPast) || nestedEntry.skip) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData( if (hidePast && isPast && !isCurrentParent) {
nestedEntry, return null;
mainSource, }
secondarySource,
subscribe,
customFields,
);
return ( return (
<OperatorEvent <Fragment key={entry.id}>
key={nestedEntry.id} <OperatorGroup
id={nestedEntry.id} key={entry.id}
colour={nestedEntry.colour} title={entry.title}
cue={nestedEntry.cue} colour={entry.colour}
main={mainField} count={entry.entries.length}
secondary={secondaryField} duration={entry.duration}
timeStart={nestedEntry.timeStart} />
duration={nestedEntry.duration} {entry.entries.map((nestedEntryId) => {
delay={nestedEntry.delay} const nestedEntry = rundown.entries[nestedEntryId];
dayOffset={nestedEntry.dayOffset} if (!isOntimeEvent(nestedEntry)) {
isLinkedToLoaded={isLinkedToLoaded} return null;
isSelected={isLoaded} }
isPast={isPast}
groupColour={entry.colour} const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} // hide past events (if setting) and skipped events
subscribed={subscribedData} if ((hidePast && isPast) || nestedEntry.skip) {
totalGap={totalGap} return null;
onLongPress={canEdit ? handleEdit : () => undefined} }
/>
); const { mainField, secondaryField, subscribedData } = getEventData(
})} nestedEntry,
</Fragment> mainSource,
); secondarySource,
} subscribe,
return null; customFields,
})} );
</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} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
</div> </div>
); );
@@ -14,9 +14,18 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); const {
const { data: customFields, status: customFieldStatus } = useCustomFields(); data: rundown,
const { data: settings, status: settingsStatus } = useSettings(); rundownMetadata,
status: rundownStatus,
isLoadingError: rundownIsLoadingError,
} = useRundownWithMetadata();
const {
data: customFields,
status: customFieldStatus,
isLoadingError: customFieldIsLoadingError,
} = useCustomFields();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
return { return {
data: { data: {
@@ -25,6 +34,10 @@ export function useOperatorData(): ViewData<OperatorData> {
customFields, customFields,
settings, settings,
}, },
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]), status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: customFieldStatus, isLoadingError: customFieldIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
]),
}; };
} }
@@ -1,19 +1,24 @@
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown'; import Rundown from './Rundown';
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { data, status, isLoadingError, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
const isLoading = status !== 'success' || !data || !rundownMetadata; // avoid showing the editable empty state before we know whether the rundown is actually empty
if (status === 'pending') {
return <EmptyFill text='Loading…' />;
}
if (isLoading) { if (isLoadingError) {
return <Empty text='Connecting to server' />; return <EmptyFill text={getLocalizedString('common.no_data')} />;
} }
return ( return (
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -13,7 +12,7 @@ import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable); export default memo(RundownTable);
function RundownTable() { function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource(); const source = useLoadedRundownSource();
@@ -32,16 +31,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'> <CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? ( <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd> </CuesheetDnd>
</EntryActionsProvider> </EntryActionsProvider>
); );
@@ -35,7 +35,7 @@ export default function BackstageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Backstage {...data} />; return <Backstage {...data} />;
@@ -20,10 +20,14 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundown(); const { data: rundownData, status: rundownStatus, isLoadingError: rundownIsLoadingError } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -33,6 +37,11 @@ export function useBackstageData(): ViewData<BackstageData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]), status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -7,7 +7,7 @@ $dot-spacing: 1.5rem;
display: grid; display: grid;
place-items: center; place-items: center;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
height: 100vh; height: 100dvh;
} }
.ellipsis { .ellipsis {
@@ -21,26 +21,26 @@ $dot-spacing: 1.5rem;
height: $dot-size; height: $dot-size;
border-radius: 50%; border-radius: 50%;
background-color: var(--accent-color-override, $ontime-color); background-color: var(--accent-color-override, $ontime-color);
animation-timing-function: cubic-bezier(0, 1, 1, 0); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:nth-child(1) { &:nth-child(1) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis1 0.6s infinite; animation: lds-ellipsis1 1s infinite;
} }
&:nth-child(2) { &:nth-child(2) {
left: $dot-size; left: $dot-size;
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(3) { &:nth-child(3) {
left: calc($dot-size + $dot-spacing); left: calc($dot-size + $dot-spacing);
animation: lds-ellipsis2 0.6s infinite; animation: lds-ellipsis2 1s infinite;
} }
&:nth-child(4) { &:nth-child(4) {
left: calc($dot-size + 2 * $dot-spacing); left: calc($dot-size + 2 * $dot-spacing);
animation: lds-ellipsis3 0.6s infinite; animation: lds-ellipsis3 1s infinite;
} }
} }
} }
+1 -15
View File
@@ -80,21 +80,7 @@ $item-height: 3.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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 { .list-container {
@@ -43,7 +43,7 @@ export default function CountdownLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Countdown {...data} />; return <Countdown {...data} />;
@@ -87,7 +87,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && ( {!hasEvents && (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' /> <Empty text={getLocalizedString('common.no_data')} />
</div> </div>
)} )}
@@ -121,7 +121,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -137,7 +137,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -154,7 +154,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-state'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' /> <Empty text={getLocalizedString('countdown.all_have_finished')} />
</div> </div>
); );
} }
@@ -21,10 +21,18 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); const {
const { data: projectData, status: projectDataStatus } = useProjectData(); data: rundownData,
const { data: settings, status: settingsStatus } = useSettings(); status: rundownStatus,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); 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();
return { return {
data: { data: {
@@ -34,6 +42,11 @@ export function useCountdownData(): ViewData<CountdownData> {
isMirrored, isMirrored,
settings, settings,
}, },
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]), status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -2,7 +2,6 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select'; import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
@@ -34,40 +33,34 @@ function CuesheetTableWrapper({
const preset = use(PresetContext); const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
[customFields, cuesheetMode, preset], [customFields, cuesheetMode, preset],
); );
const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns}> <CuesheetDnd columns={columns}>
{isLoading ? ( <CuesheetTable
<EmptyPage text='Loading...' /> columns={columns}
) : ( source={source}
<CuesheetTable cuesheetMode={cuesheetMode}
columns={columns} tableRoot='cuesheet'
source={source} setCuesheetMode={setCuesheetMode}
cuesheetMode={cuesheetMode} isCurrentRundown={isCurrentRundown}
tableRoot='cuesheet' insertElement={
setCuesheetMode={setCuesheetMode} <>
isCurrentRundown={isCurrentRundown} <RundownSelect
insertElement={ cuesheetMode={cuesheetMode}
<> selectedRundownId={selectedRundownId}
<RundownSelect loadedRundownId={loadedRundownId}
cuesheetMode={cuesheetMode} setSelectedRundownId={setSelectedRundownId}
selectedRundownId={selectedRundownId} projectRundowns={projectRundowns}
loadedRundownId={loadedRundownId} />
setSelectedRundownId={setSelectedRundownId} </>
projectRundowns={projectRundowns} }
/> />
</>
}
/>
)}
</CuesheetDnd> </CuesheetDnd>
); );
} }
@@ -5,6 +5,10 @@ $table-header-font-size: calc(1rem - 2px);
@include rows.cuesheet-row-columns($table-header-font-size); @include rows.cuesheet-row-columns($table-header-font-size);
.tableLoading {
grid-area: table;
}
.cuesheet { .cuesheet {
font-size: $table-font-size; font-size: $table-font-size;
font-weight: 400; font-weight: 400;
@@ -11,7 +11,7 @@ import {
TableVirtuosoHandle, TableVirtuosoHandle,
} from 'react-virtuoso'; } from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +19,7 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { useTranslation } from '../../../translation/TranslationProvider';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions'; import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -62,8 +63,9 @@ export default function CuesheetTable({
isCurrentRundown, isCurrentRundown,
insertElement, insertElement,
}: CuesheetTableProps) { }: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source; const { flatRundown, status, isLoadingError, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
@@ -228,10 +230,13 @@ export default function CuesheetTable({
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !flatRundown || status === 'pending'; // 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} />;
}
if (isLoading) { if (isLoadingError) {
return <EmptyPage text='Loading...' />; return <EmptyFill text={getLocalizedString('common.no_data')} className={style.tableLoading} />;
} }
return ( return (
@@ -1,5 +1,7 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project { .project {
margin: 0; margin: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
@@ -16,56 +18,104 @@
display: flex; display: flex;
flex-direction: column; 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 { .logo {
max-width: min(200px, 30vw); 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 { .info {
flex: 1; flex: 1;
max-height: 100%; width: $content-width;
margin-inline: auto; margin-inline: auto;
overflow-y: auto; overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
gap: $view-element-gap; gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh; 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 { .info__label {
font-size: $timer-label-size;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase; text-transform: uppercase;
} }
.info__value { .info__value {
white-space: break-spaces; white-space: break-spaces;
} line-height: 1.35;
overflow-wrap: anywhere;
.info__custom {
display: flex;
gap: 1rem;
} }
.info__image-container { .info__image-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 192px; flex: 0 0 min(192px, 25%);
height: 192px;
} }
.info__image { .info__image {
display: block;
max-width: 100%; max-width: 100%;
max-height: 100%; height: auto;
object-fit: contain; object-fit: contain;
} }
.link.info__value { .link.info__value {
display: flex; display: inline-flex;
gap: $view-element-gap; gap: 0.35em;
align-items: center; align-items: center;
color: $action-text-color; color: $action-text-color;
@@ -78,12 +128,13 @@
/* =================== MOBILE ===================*/ /* =================== MOBILE ===================*/
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.project { .project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img { .logo img {
height: min(50px, 10vh); height: min(50px, 10vh);
} }
.info__image-container {
width: 96px;
height: 96px;
}
} }
} }
@@ -1,4 +1,5 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5'; import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -21,7 +22,7 @@ export default function ProjectInfoLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <ProjectInfo {...data} />; return <ProjectInfo {...data} />;
@@ -41,56 +42,49 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return ( return (
<> <>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <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 ( return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'> <div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} /> <ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />} {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>
)}
<div className='info'> <div className='info'>
{projectData.title && ( {projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
<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 && ( {projectData.url && (
<div> <div className='info__card'>
<div className='info__label'>{getLocalizedString('project.url')}</div> <div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'> <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> </a>
</div> </div>
)} )}
{projectData.custom.map((info, idx) => { {projectData.custom.map((info, idx) => {
const hasUrl = Boolean(info.url);
return ( return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here // 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__custom'> <div key={`${info.title}-${idx}`} className='info__card'>
{hasUrl && ( {info.title && <div className='info__label'>{info.title}</div>}
<div className='info__image-container'> {info.url ? (
<img className='info__image' src={info.url} loading='lazy' /> <div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
</div> </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> </div>
); );
})} })}
@@ -98,3 +92,31 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div> </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 useProjectData from '../../common/hooks-query/useProjectData';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData } from '../utils/viewLoader.utils'; import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface ProjectInfoData { export interface ProjectInfoData {
projectData: ProjectData; projectData: ProjectData;
@@ -14,13 +14,13 @@ export function useProjectInfoData(): ViewData<ProjectInfoData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
return { return {
data: { data: {
projectData, projectData,
isMirrored, isMirrored,
}, },
status: projectDataStatus, status: aggregateQueryStatus([{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError }]),
}; };
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export default function StudioLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Studio {...data} />; return <Studio {...data} />;
+18 -5
View File
@@ -20,10 +20,18 @@ export function useStudioData(): ViewData<StudioData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const {
const { data: settings, status: settingsStatus } = useSettings(); data: viewSettings,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); status: viewSettingsStatus,
isLoadingError: viewSettingsIsLoadingError,
} = useViewSettings();
const { data: settings, status: settingsStatus, isLoadingError: settingsIsLoadingError } = useSettings();
const {
data: customFields,
status: customFieldsStatus,
isLoadingError: customFieldsIsLoadingError,
} = useCustomFields();
return { return {
data: { data: {
@@ -33,6 +41,11 @@ export function useStudioData(): ViewData<StudioData> {
settings, settings,
viewSettings, viewSettings,
}, },
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]), status: aggregateQueryStatus([
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
@@ -1,6 +1,7 @@
import { OntimeView } from 'ontime-types'; import { OntimeView } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -29,7 +30,7 @@ export default function TimelinePageLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <TimelinePage {...data} />; return <TimelinePage {...data} />;
@@ -73,7 +74,7 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
totalDuration={totalDuration} totalDuration={totalDuration}
/> />
) : ( ) : (
<EmptyPage text={getLocalizedString('common.no_data')} /> <EmptyFill text={getLocalizedString('common.no_data')} />
)} )}
</div> </div>
); );
@@ -16,10 +16,18 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> { export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data // HTTP API data
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(); const {
const { data: projectData, status: projectDataStatus } = useProjectData(); data: rundownData,
const { data: settings, status: settingsStatus } = useSettings(); status: rundownStatus,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); 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();
return { return {
data: { data: {
@@ -28,6 +36,11 @@ export function useTimelineData(): ViewData<TimelineData> {
projectData, projectData,
settings, settings,
}, },
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]), status: aggregateQueryStatus([
{ status: rundownStatus, isLoadingError: rundownIsLoadingError },
{ status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
{ status: settingsStatus, isLoadingError: settingsIsLoadingError },
{ status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
]),
}; };
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export default function TimerLoader() {
} }
if (status === 'error') { if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />; return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
} }
return <Timer {...data} />; return <Timer {...data} />;
+18 -10
View File
@@ -22,11 +22,19 @@ export function useTimerData(): ViewData<TimerData> {
const isMirrored = useViewOptionsStore((state) => state.mirror); const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data // HTTP API data
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus, isLoadingError: projectDataIsLoadingError } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const {
const { data: settings, status: settingsStatus } = useSettings(); data: viewSettings,
const { data: customFields, status: customFieldsStatus } = useCustomFields(); status: viewSettingsStatus,
const { data: rundown, status: rundownStatus } = useRundown(); 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 { entries } = rundown; const { entries } = rundown;
return { return {
@@ -39,11 +47,11 @@ export function useTimerData(): ViewData<TimerData> {
entries, entries,
}, },
status: aggregateQueryStatus([ status: aggregateQueryStatus([
projectDataStatus, { status: projectDataStatus, isLoadingError: projectDataIsLoadingError },
viewSettingsStatus, { status: viewSettingsStatus, isLoadingError: viewSettingsIsLoadingError },
settingsStatus, { status: settingsStatus, isLoadingError: settingsIsLoadingError },
customFieldsStatus, { status: customFieldsStatus, isLoadingError: customFieldsIsLoadingError },
rundownStatus, { status: rundownStatus, isLoadingError: rundownIsLoadingError },
]), ]),
}; };
} }
+17 -11
View File
@@ -5,21 +5,27 @@ export type ViewData<T> = {
status: QueryStatus; 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 query statuses. * Aggregates a loading status from multiple queries, for the purpose of deciding
* If all statuses are 'pending', returns 'pending'. * whether a view can render.
* If all statuses are 'success', returns 'success'. * - 'pending' while any query hasn't settled yet (no result, first fetch in flight)
* If any status is 'error', returns 'error'. * - '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
*/ */
export function aggregateQueryStatus(statuses: QueryStatus[]): QueryStatus { export function aggregateQueryStatus(queries: AggregatableQuery[]): QueryStatus {
if (statuses.every((status) => status === 'pending')) { const allSettled = queries.every((query) => query.status !== 'pending');
if (!allSettled) {
return 'pending'; return 'pending';
} }
if (statuses.every((status) => status === 'success')) { if (queries.some((query) => query.isLoadingError)) {
return 'success';
}
if (statuses.some((status) => status === 'error')) {
return 'error'; return 'error';
} }
return 'pending'; return 'success';
} }
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10); expect(calculatedFinish).toBe(10);
}); });
describe('on timers of type time-to-end', () => { describe('on timers of type time-to-end', () => {
it('finish time is the fixed end, ignoring added time', () => { it('finish time is as schedule + added time', () => {
const state = { const state = {
eventNow: { eventNow: {
timeEnd: 30, timeEnd: 30,
@@ -245,29 +245,6 @@ describe('getExpectedFinish()', () => {
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40); expect(calculatedFinish).toBe(40);
}); });
it('finish time is the fixed end, including paused time', () => {
const state = {
eventNow: {
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(35);
});
it('handles events that finish the day after', () => { it('handles events that finish the day after', () => {
const state = { const state = {
eventNow: { eventNow: {
@@ -427,9 +404,6 @@ describe('getCurrent()', () => {
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => { it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
@@ -455,9 +429,6 @@ describe('getCurrent()', () => {
it('current time is the time to end', () => { it('current time is the time to end', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
@@ -480,13 +451,10 @@ describe('getCurrent()', () => {
expect(current).toBe(70); expect(current).toBe(70);
}); });
it('current time is the time to end, ignoring added time', () => { it('current time is the time to end + added time', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 0,
duration: 100,
timeEnd: 100, timeEnd: 100,
dayOffset: 0,
countToEnd: true, countToEnd: true,
}, },
clock: 30, clock: 30,
@@ -512,10 +480,8 @@ describe('getCurrent()', () => {
const state = { const state = {
eventNow: { eventNow: {
timeStart: 79200000, // 22:00:00 timeStart: 79200000, // 22:00:00
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
countToEnd: true, countToEnd: true,
dayOffset: 0,
}, },
clock: 79500000, // 22:05:00 clock: 79500000, // 22:05:00
timer: { timer: {
@@ -526,7 +492,6 @@ describe('getCurrent()', () => {
rundown: { rundown: {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
currentDay: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
@@ -538,36 +503,6 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000); expect(current).toBe(dayInMs - 79500000 + 600000);
}); });
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
duration: 2 * MILLIS_PER_HOUR, // 2h
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => { it('handles events that were started late', () => {
const state = { const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -576,7 +511,6 @@ describe('getCurrent()', () => {
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00 duration: 3600000, // 01:00:00
countToEnd: true, countToEnd: true,
dayOffset: 0,
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
@@ -1139,7 +1073,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0); expect(absolute).toBe(0);
}); });
it('with time-to-end, offset combines overtime and added time', () => { it('with time-to-end, offset is the overtime', () => {
const state = { const state = {
clock: 82000000, // 22:46:40 clock: 82000000, // 22:46:40
eventNow: { eventNow: {
@@ -1192,45 +1126,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
// overtime (400000) plus the operator's added time (-200000) expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
expect(absolute).toBe(200000);
});
it('with time-to-end', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
}); });
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
+12 -12
View File
@@ -62,34 +62,34 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/ */
export function getCurrent(state: RuntimeState): number { export function getCurrent(state: RuntimeState): number {
const { timer, eventNow } = state;
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
if (eventNow === null || timer.duration === null) { if (state.eventNow === null || state.timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received'); throw new Error('timerUtils.getCurrent: invalid state received');
} }
} }
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { clock } = state; const { clock } = state;
if (eventNow.countToEnd) { if (countToEnd) {
const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs; const isEventOverMidnight = timeStart > timeEnd;
const eventDayOffset = eventNow.dayOffset * dayInMs; const correctDay = isEventOverMidnight ? dayInMs : 0;
return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected; return correctDay - clock + timeEnd + addedTime;
} }
if (timer.startedAt === null) { if (startedAt === null) {
return timer.duration; return duration;
} }
if (pausedAt != null) { if (pausedAt != null) {
return timer.startedAt + timer.duration + timer.addedTime - pausedAt; return startedAt + duration + addedTime - pausedAt;
} }
const hasPassedMidnight = timer.startedAt > clock; const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0; const correctDay = hasPassedMidnight ? dayInMs : 0;
return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay; return startedAt + duration + addedTime - clock - correctDay;
} }
/** /**