mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-07 23:39:09 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2455fec17 | |||
| f7b58f5ebd | |||
| 9e2dfaab16 | |||
| 65e1d3cfe9 | |||
| 94d54529ee | |||
| f7535651f6 | |||
| 5cf36f049a | |||
| c6248b0c72 | |||
| 2a890cf2b3 |
@@ -20,7 +20,7 @@
|
|||||||
.wide {
|
.wide {
|
||||||
top: 4vh;
|
top: 4vh;
|
||||||
min-width: min(1280px, 96vw);
|
min-width: min(1280px, 96vw);
|
||||||
max-width: min(1600px, 96vw);
|
max-width: min(1800px, 98vw);
|
||||||
height: 88vh;
|
height: 88vh;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
...state,
|
...state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd,
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -25,6 +25,13 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sourceGrid {
|
.sourceGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
+22
-8
@@ -8,6 +8,7 @@ import type {
|
|||||||
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
import { getErrorMessage, ImportMap } from 'ontime-utils';
|
||||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||||
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorksheetMetadata as getExcelWorksheetMetadata,
|
getWorksheetMetadata as getExcelWorksheetMetadata,
|
||||||
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||||
|
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
|
||||||
|
|
||||||
const { data: currentRundown } = useRundown();
|
const { data: currentRundown } = useRundown();
|
||||||
const { applyImport } = useSpreadsheetImport();
|
const { applyImport } = useSpreadsheetImport();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
|
|||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openGSheetFlow = () => {
|
const openGSheetFlow = () => {
|
||||||
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinished = () => {
|
const handleFinished = (rundownTitle: string) => {
|
||||||
setImportFlow('finished');
|
setImportFlow('finished');
|
||||||
setHasFile('none');
|
setHasFile('none');
|
||||||
setActiveSource(null);
|
setActiveSource(null);
|
||||||
setError('');
|
setError('');
|
||||||
|
setCompletedRundownTitle(rundownTitle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyImport = async (
|
const handleApplyImport = async (
|
||||||
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
|
|||||||
if (mode === 'new') {
|
if (mode === 'new') {
|
||||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||||
handleFinished();
|
handleFinished(title);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
|
|||||||
customFields: preview.customFields,
|
customFields: preview.customFields,
|
||||||
providedFields,
|
providedFields,
|
||||||
});
|
});
|
||||||
handleFinished();
|
handleFinished(currentRundown.title);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadWorksheetMetadata = useCallback(
|
const loadWorksheetMetadata = useCallback(
|
||||||
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
|
|||||||
{showCompleted && (
|
{showCompleted && (
|
||||||
<div className={style.finishSection}>
|
<div className={style.finishSection}>
|
||||||
<span className={style.finishBadge}>Import complete</span>
|
<span className={style.finishBadge}>Import complete</span>
|
||||||
<div className={style.finishTitle}>Spreadsheet data applied.</div>
|
<div className={style.finishTitle}>
|
||||||
<div className={style.finishDescription}>You can close this flow or start another import.</div>
|
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
|
||||||
<Button variant='subtle-white' onClick={resetFlow}>
|
</div>
|
||||||
Reset flow
|
<div className={style.finishDescription}>
|
||||||
</Button>
|
Review the imported rundown in the editor or start another import.
|
||||||
|
</div>
|
||||||
|
<div className={style.finishActions}>
|
||||||
|
<Button variant='primary' onClick={() => navigate('/editor')}>
|
||||||
|
Open editor
|
||||||
|
</Button>
|
||||||
|
<Button variant='subtle-white' onClick={resetFlow}>
|
||||||
|
Import another
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isGSheetFlow && (
|
{isGSheetFlow && (
|
||||||
|
|||||||
+4
@@ -92,6 +92,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarWarning {
|
||||||
|
color: $orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
.mappingPaneTitle {
|
.mappingPaneTitle {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
|
{toolbarStatus && (
|
||||||
|
<Panel.Description>
|
||||||
|
{toolbarStatus.entries === '–'
|
||||||
|
? 'No import preview yet'
|
||||||
|
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`}
|
||||||
|
{toolbarStatus.warnings > 0 && (
|
||||||
|
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
|
||||||
|
)}
|
||||||
|
</Panel.Description>
|
||||||
|
)}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
|
||||||
<div className={style.editorBody}>
|
<div className={style.editorBody}>
|
||||||
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
|
|||||||
|
|
||||||
<section className={style.previewPane}>
|
<section className={style.previewPane}>
|
||||||
<div className={style.previewPaneHeader}>
|
<div className={style.previewPaneHeader}>
|
||||||
<span className={style.previewPaneTitle}>Import preview</span>
|
<div className={style.previewPaneHeading}>
|
||||||
|
<span className={style.previewPaneTitle}>Import preview</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.tableShell}>
|
<div className={style.tableShell}>
|
||||||
<PreviewTable
|
<PreviewTable
|
||||||
preview={state.preview}
|
preview={state.preview}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
|
canRefresh={canPreview}
|
||||||
isLoadingMetadata={isLoadingMetadata}
|
isLoadingMetadata={isLoadingMetadata}
|
||||||
|
isRefreshing={state.loading === 'preview'}
|
||||||
|
needsPreviewRefresh={state.needsPreviewRefresh}
|
||||||
|
onRefresh={handlePreviewSubmit}
|
||||||
worksheetHeaders={worksheetHeaders}
|
worksheetHeaders={worksheetHeaders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+102
-24
@@ -1,27 +1,35 @@
|
|||||||
.emptyState {
|
.emptyState {
|
||||||
height: 100%;
|
padding: 3rem 1.5rem;
|
||||||
min-height: 16rem;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyTitle {
|
.emptyTitle {
|
||||||
color: $ui-white;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 1rem;
|
color: rgba($gray-200, 0.72);
|
||||||
font-weight: 600;
|
font-size: calc(1rem + 2px);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyBody {
|
.emptyBody {
|
||||||
color: $gray-400;
|
color: rgba($gray-200, 0.55);
|
||||||
font-size: 0.95rem;
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyAction {
|
||||||
|
margin: 1rem auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
color: $ui-white;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
@@ -34,31 +42,101 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
color: $gray-300;
|
||||||
color: $gray-400;
|
font-size: 0.8rem;
|
||||||
text-transform: capitalize;
|
font-weight: 600;
|
||||||
vertical-align: top;
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
vertical-align: bottom;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.5rem;
|
box-sizing: border-box;
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
vertical-align: top;
|
max-width: 20rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tbody tr {
|
||||||
background-color: $white-1;
|
--entry-colour: transparent;
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
|
||||||
|
box-shadow: inset 3px 0 var(--entry-colour);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td[data-empty='true'] {
|
||||||
|
color: $gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowNumber,
|
||||||
|
.rowType {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowNumber {
|
.rowNumber {
|
||||||
width: 4.5rem;
|
left: 0;
|
||||||
min-width: 4.5rem;
|
width: 3.25rem;
|
||||||
|
min-width: 3.25rem !important;
|
||||||
|
color: $gray-400;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowType {
|
.rowType {
|
||||||
width: 7rem;
|
left: 3.25rem;
|
||||||
min-width: 7rem;
|
width: 6.25rem;
|
||||||
|
min-width: 6.25rem !important;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
box-shadow: 1px 0 $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead .rowNumber,
|
||||||
|
thead .rowType {
|
||||||
|
z-index: 2;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numericCell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multilineCell {
|
||||||
|
max-width: 30rem !important;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventRow {
|
||||||
|
.rowNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupRow {
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
|
||||||
|
box-shadow: inset 4px 0 var(--entry-colour) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
td {
|
||||||
|
min-height: 3.25rem;
|
||||||
|
border-top: 0.75rem solid $gray-1350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestoneRow {
|
||||||
|
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
|
||||||
|
box-shadow: inset 3px 0 var(--entry-colour) !important;
|
||||||
|
color: $gray-300;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-14
@@ -1,7 +1,11 @@
|
|||||||
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../../../../common/components/buttons/Button';
|
||||||
|
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||||
|
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||||
import { getCellValue } from './previewTableUtils';
|
import { getCellValue } from './previewTableUtils';
|
||||||
|
|
||||||
import style from './PreviewTable.module.scss';
|
import style from './PreviewTable.module.scss';
|
||||||
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
|
|||||||
interface PreviewTableProps {
|
interface PreviewTableProps {
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
columnLabels: string[];
|
columnLabels: string[];
|
||||||
|
canRefresh: boolean;
|
||||||
isLoadingMetadata: boolean;
|
isLoadingMetadata: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
worksheetHeaders: string[];
|
worksheetHeaders: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
|
||||||
|
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
|
||||||
|
const transparentColour = 'transparent';
|
||||||
|
|
||||||
|
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
|
||||||
|
|
||||||
|
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
|
||||||
|
if (isOntimeGroup(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.groupRow,
|
||||||
|
entryColour: entry.colour,
|
||||||
|
entryType: 'Group',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryColour = groupColour ?? transparentColour;
|
||||||
|
|
||||||
|
if (isOntimeMilestone(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.milestoneRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Milestone',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOntimeDelay(entry)) {
|
||||||
|
return {
|
||||||
|
rowClassName: style.eventRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Delay',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowClassName: style.eventRow,
|
||||||
|
entryColour,
|
||||||
|
entryType: 'Event',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClassName(label: string, value: string) {
|
||||||
|
if (value.includes('\n')) {
|
||||||
|
return style.multilineCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numericColumns.has(label)) {
|
||||||
|
return style.numericCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayColumns(columnLabels: string[]) {
|
||||||
|
return [...columnLabels].sort((left, right) => {
|
||||||
|
const leftPriority = priorityColumns.indexOf(left);
|
||||||
|
const rightPriority = priorityColumns.indexOf(right);
|
||||||
|
return (
|
||||||
|
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
|
||||||
|
(rightPriority === -1 ? priorityColumns.length : rightPriority)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function PreviewTable({
|
export default function PreviewTable({
|
||||||
preview,
|
preview,
|
||||||
columnLabels,
|
columnLabels,
|
||||||
|
canRefresh,
|
||||||
isLoadingMetadata,
|
isLoadingMetadata,
|
||||||
|
isRefreshing,
|
||||||
|
needsPreviewRefresh,
|
||||||
|
onRefresh,
|
||||||
worksheetHeaders,
|
worksheetHeaders,
|
||||||
}: PreviewTableProps) {
|
}: PreviewTableProps) {
|
||||||
const customFieldKeyByLabel = useMemo(() => {
|
const customFieldKeyByLabel = useMemo(() => {
|
||||||
@@ -24,33 +99,57 @@ export default function PreviewTable({
|
|||||||
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
|
||||||
}, [preview]);
|
}, [preview]);
|
||||||
|
|
||||||
|
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
|
||||||
|
|
||||||
|
const previewMetadata = useMemo(() => {
|
||||||
|
if (!preview) return null;
|
||||||
|
return getRundownMetadata(preview.rundown, null);
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
|
let emptyTitle = 'Preview not generated';
|
||||||
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
let emptyContent = 'Select the fields you want to import, then click Preview import.';
|
||||||
|
|
||||||
if (isLoadingMetadata) {
|
if (isLoadingMetadata) {
|
||||||
|
emptyTitle = 'Loading worksheet';
|
||||||
emptyContent = 'Loading worksheet metadata...';
|
emptyContent = 'Loading worksheet metadata...';
|
||||||
} else if (worksheetHeaders.length === 0) {
|
} else if (worksheetHeaders.length === 0) {
|
||||||
|
emptyTitle = 'No headers found';
|
||||||
emptyContent =
|
emptyContent =
|
||||||
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
|
||||||
|
} else if (needsPreviewRefresh) {
|
||||||
|
emptyTitle = 'Preview needs updating';
|
||||||
|
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.emptyState}>
|
<div className={style.emptyState}>
|
||||||
<div className={style.emptyTitle}>Preview not generated</div>
|
<div className={style.emptyMessage}>
|
||||||
<div className={style.emptyBody}>{emptyContent}</div>
|
<div className={style.emptyTitle}>{emptyTitle}</div>
|
||||||
|
<div className={style.emptyBody}>{emptyContent}</div>
|
||||||
|
{needsPreviewRefresh && (
|
||||||
|
<Button
|
||||||
|
className={style.emptyAction}
|
||||||
|
variant='primary'
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={!canRefresh}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
Refresh preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let eventIndex = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={style.rowNumber}>#</th>
|
<th className={style.rowNumber}>#</th>
|
||||||
<th className={style.rowType}>Type</th>
|
<th className={style.rowType}>Type</th>
|
||||||
{columnLabels.map((label, index) => (
|
{displayColumns.map((label, index) => (
|
||||||
<th key={`${label}-${index}`}>{label}</th>
|
<th key={`${label}-${index}`}>{label}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,16 +158,29 @@ export default function PreviewTable({
|
|||||||
{preview.rundown.flatOrder.map((entryId) => {
|
{preview.rundown.flatOrder.map((entryId) => {
|
||||||
const entry = preview.rundown.entries[entryId];
|
const entry = preview.rundown.entries[entryId];
|
||||||
const isEvent = isOntimeEvent(entry);
|
const isEvent = isOntimeEvent(entry);
|
||||||
if (isEvent) eventIndex++;
|
const entryMetadata = previewMetadata?.[entryId];
|
||||||
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
|
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={entryId}>
|
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
|
||||||
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
|
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
|
||||||
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
|
<td className={style.rowType}>
|
||||||
{columnLabels.map((label, colIndex) => (
|
<Tag>{entryType}</Tag>
|
||||||
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
|
</td>
|
||||||
))}
|
{displayColumns.map((label, colIndex) => {
|
||||||
|
const value = getCellValue(label, entry, customFieldKeyByLabel);
|
||||||
|
const cellClassName = getCellClassName(label, value);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${entryId}-${colIndex}`}
|
||||||
|
className={cellClassName}
|
||||||
|
data-empty={value === ''}
|
||||||
|
title={value || undefined}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+36
-11
@@ -5,7 +5,8 @@ import type {
|
|||||||
SpreadsheetPreviewResponse,
|
SpreadsheetPreviewResponse,
|
||||||
SpreadsheetWorksheetMetadata,
|
SpreadsheetWorksheetMetadata,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ type ImportAction =
|
|||||||
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
|
||||||
| { type: 'applySuccess' }
|
| { type: 'applySuccess' }
|
||||||
| { type: 'exportSuccess' }
|
| { type: 'exportSuccess' }
|
||||||
| { type: 'clearPreview'; error?: string }
|
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
|
||||||
| { type: 'failure'; error: string }
|
| { type: 'failure'; error: string }
|
||||||
| { type: 'reset' };
|
| { type: 'reset' };
|
||||||
|
|
||||||
@@ -40,12 +41,14 @@ type ImportState = {
|
|||||||
loading: '' | 'preview' | 'apply' | 'export';
|
loading: '' | 'preview' | 'apply' | 'export';
|
||||||
error: string;
|
error: string;
|
||||||
preview: SpreadsheetPreviewResponse | null;
|
preview: SpreadsheetPreviewResponse | null;
|
||||||
|
needsPreviewRefresh: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialImportState: ImportState = {
|
const initialImportState: ImportState = {
|
||||||
loading: '',
|
loading: '',
|
||||||
error: '',
|
error: '',
|
||||||
preview: null,
|
preview: null,
|
||||||
|
needsPreviewRefresh: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
function importReducer(state: ImportState, action: ImportAction): ImportState {
|
||||||
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
|
|||||||
case 'startExport':
|
case 'startExport':
|
||||||
return { ...state, loading: 'export', error: '' };
|
return { ...state, loading: 'export', error: '' };
|
||||||
case 'previewSuccess':
|
case 'previewSuccess':
|
||||||
return { loading: '', error: '', preview: action.preview };
|
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
|
||||||
case 'applySuccess':
|
case 'applySuccess':
|
||||||
case 'exportSuccess':
|
case 'exportSuccess':
|
||||||
return { ...state, loading: '' };
|
return { ...state, loading: '' };
|
||||||
case 'clearPreview':
|
case 'clearPreview':
|
||||||
return { ...state, error: action.error ?? '', preview: null };
|
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
|
||||||
case 'failure': {
|
case 'failure': {
|
||||||
if (state.loading === 'preview') {
|
if (state.loading === 'preview') {
|
||||||
return { loading: '', error: action.error, preview: null };
|
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
|
||||||
}
|
}
|
||||||
return { ...state, loading: '', error: action.error };
|
return { ...state, loading: '', error: action.error };
|
||||||
}
|
}
|
||||||
@@ -221,7 +224,7 @@ export function useSheetImportForm({
|
|||||||
const sub = watch(() => {
|
const sub = watch(() => {
|
||||||
if (!previewRef.current) return;
|
if (!previewRef.current) return;
|
||||||
previewRef.current = null;
|
previewRef.current = null;
|
||||||
dispatch({ type: 'clearPreview' });
|
dispatch({ type: 'clearPreview', needsRefresh: true });
|
||||||
});
|
});
|
||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [watch]);
|
}, [watch]);
|
||||||
@@ -297,15 +300,37 @@ export function useSheetImportForm({
|
|||||||
}, [append]);
|
}, [append]);
|
||||||
|
|
||||||
const toolbarStatus = (() => {
|
const toolbarStatus = (() => {
|
||||||
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
|
|
||||||
|
|
||||||
if (!state.preview) {
|
if (!state.preview) {
|
||||||
return `entries: – | start: – | end: – | duration: –${warningText}`;
|
return {
|
||||||
|
entries: '–',
|
||||||
|
groups: '–',
|
||||||
|
milestones: '–',
|
||||||
|
start: '–',
|
||||||
|
end: '–',
|
||||||
|
duration: '–',
|
||||||
|
warnings: warningCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { flatOrder } = state.preview.rundown;
|
const { entries, flatOrder } = state.preview.rundown;
|
||||||
const { start, end, duration } = state.preview.summary;
|
const { start, end, duration } = state.preview.summary;
|
||||||
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
|
let groups = 0;
|
||||||
|
let milestones = 0;
|
||||||
|
for (const entryId of flatOrder) {
|
||||||
|
const entry = entries[entryId];
|
||||||
|
if (isOntimeGroup(entry)) groups++;
|
||||||
|
else if (isOntimeMilestone(entry)) milestones++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: flatOrder.length,
|
||||||
|
groups,
|
||||||
|
milestones,
|
||||||
|
start: removeTrailingZero(millisToString(start)),
|
||||||
|
end: removeTrailingZero(millisToString(end)),
|
||||||
|
duration: formatDuration(duration),
|
||||||
|
warnings: warningCount,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
|
|||||||
//TODO: consider relative mode
|
//TODO: consider relative mode
|
||||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||||
const { event, showExpected } = props;
|
const { event, showExpected } = props;
|
||||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
||||||
|
|
||||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||||
|
|
||||||
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
|||||||
|
|
||||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||||
const plannedEnd = plannedStart + duration + delay;
|
const plannedEnd = plannedStart + duration + delay;
|
||||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
|
||||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
||||||
import { getExpectedStart } from 'ontime-utils';
|
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import useReport from '../../common/hooks-query/useReport';
|
import useReport from '../../common/hooks-query/useReport';
|
||||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
import { useExpectedStartData } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||||
import { getPropertyValue } from '../common/viewUtils';
|
import { getPropertyValue } from '../common/viewUtils';
|
||||||
import { useCountdownOptions } from './countdown.options';
|
import { useCountdownOptions } from './countdown.options';
|
||||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||||
import { ScheduleTime } from './CountdownSubscriptions';
|
import { ScheduleTime } from './CountdownSubscriptions';
|
||||||
|
|
||||||
import './SingleEventCountdown.scss';
|
import './SingleEventCountdown.scss';
|
||||||
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
||||||
|
|
||||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
||||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionStatusProps {
|
interface SubscriptionStatusProps {
|
||||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
event: CountdownEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||||
|
|
||||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
|||||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||||
@@ -271,6 +271,7 @@ export function extendEventData(
|
|||||||
offset,
|
offset,
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
|
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
||||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||||
return { ...event, expectedStart, endedAt };
|
return { ...event, expectedStart, endedAt, expectedEnd };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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 },
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1126,7 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
|||||||
} as RuntimeState;
|
} as RuntimeState;
|
||||||
|
|
||||||
const { absolute } = getRuntimeOffset(state);
|
const { absolute } = getRuntimeOffset(state);
|
||||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles time-to-end started after the end time', () => {
|
it('handles time-to-end started after the end time', () => {
|
||||||
|
|||||||
@@ -180,13 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
|||||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||||
|
|
||||||
// absolute offset is difference between schedule and playback time
|
// absolute offset is difference between schedule and playback time
|
||||||
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
// in case of count to end, the absolute offset is overtime and added time
|
||||||
|
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||||
|
|
||||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
// 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;
|
||||||
|
|
||||||
// in case of count to end, the absolute offset is just the overtime
|
return { absolute, relative };
|
||||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
eventNow: rundown.entries[0],
|
eventNow: rundown.entries[0],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('countToEnd entries decouple the link chain for subsequent events', () => {
|
||||||
|
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||||
|
// Event 1: countToEnd=true → breaks the link chain
|
||||||
|
// Event 2: linkStart=true → unlinked (chain broken by event 1)
|
||||||
|
// Event 3: flag event → also unlinked
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||||
|
0: makeOntimeEvent({
|
||||||
|
id: '0',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 0,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: false,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
1: makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 3600000,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: true,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
2: makeOntimeEvent({
|
||||||
|
id: '2',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 7200000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
3: makeOntimeEvent({
|
||||||
|
id: '3',
|
||||||
|
parent: null,
|
||||||
|
timeStart: 10800000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
},
|
||||||
|
order: ['group', '0', '1', '2', '3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
groupNow: null,
|
||||||
|
eventNow: rundown.entries[0],
|
||||||
|
rundown: { actualGroupStart: null },
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||||
|
|
||||||
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||||
|
|
||||||
|
// _group is the last event in the group (event 2)
|
||||||
|
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
|
||||||
|
// accumulatedGap includes event 1's duration carried forward to event 2
|
||||||
|
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
|
||||||
|
expect(state._group).toMatchObject({
|
||||||
|
event: rundown.entries[2],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
|
||||||
|
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
|
||||||
|
// so previousWasCountToEnd is null and no further duration is carried forward
|
||||||
|
expect(state._flag).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _end (event 3): also unlinked because the chain was broken at event 1
|
||||||
|
expect(state._end).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
|
||||||
|
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||||
|
// Event 1: linkStart=true → linked to loaded
|
||||||
|
// Event 2: countToEnd=true → breaks the chain (also last in group)
|
||||||
|
// Event 3: linkStart=true → unlinked (chain broken by event 2)
|
||||||
|
const rundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||||
|
0: makeOntimeEvent({
|
||||||
|
id: '0',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 0,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: false,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
1: makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 3600000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
2: makeOntimeEvent({
|
||||||
|
id: '2',
|
||||||
|
parent: 'group',
|
||||||
|
timeStart: 7200000,
|
||||||
|
duration: 3600000,
|
||||||
|
countToEnd: true,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
3: makeOntimeEvent({
|
||||||
|
id: '3',
|
||||||
|
parent: null,
|
||||||
|
timeStart: 10800000,
|
||||||
|
duration: 3600000,
|
||||||
|
linkStart: true,
|
||||||
|
gap: 0,
|
||||||
|
} as any),
|
||||||
|
},
|
||||||
|
order: ['group', '0', '1', '2', '3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
groupNow: null,
|
||||||
|
eventNow: rundown.entries[0],
|
||||||
|
rundown: { actualGroupStart: null },
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||||
|
|
||||||
|
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||||
|
|
||||||
|
// _group is the last event in the group (event 2)
|
||||||
|
// isLinkedToLoaded is true because no preceding event had countToEnd
|
||||||
|
// accumulatedGap is 0 because no preceding event was countToEnd
|
||||||
|
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
|
||||||
|
expect(state._group).toMatchObject({
|
||||||
|
event: rundown.entries[2],
|
||||||
|
isLinkedToLoaded: true,
|
||||||
|
accumulatedGap: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
|
||||||
|
// accumulatedGap includes event 2's duration carried forward
|
||||||
|
expect(state._flag).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
accumulatedGap: 3600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// _end (event 3): unlinked because event 2 has countToEnd=true
|
||||||
|
expect(state._end).toMatchObject({
|
||||||
|
event: rundown.entries[3],
|
||||||
|
isLinkedToLoaded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
calculateDuration,
|
calculateDuration,
|
||||||
checkIsNow,
|
checkIsNow,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
getLastEventNormal,
|
getLastEventNormal,
|
||||||
isPlaybackActive,
|
isPlaybackActive,
|
||||||
@@ -831,7 +832,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
state.offset.expectedRundownEnd = null;
|
state.offset.expectedRundownEnd = null;
|
||||||
state.offset.expectedGroupEnd = null;
|
state.offset.expectedGroupEnd = null;
|
||||||
state.offset.expectedFlagStart = null;
|
state.offset.expectedFlagStart = null;
|
||||||
state.offset.expectedRundownEnd = null;
|
|
||||||
|
|
||||||
const { offset } = state;
|
const { offset } = state;
|
||||||
const { plannedStart, actualStart } = state.rundown;
|
const { plannedStart, actualStart } = state.rundown;
|
||||||
@@ -852,7 +852,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,6 +927,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
|
|
||||||
let accumulatedGap = 0;
|
let accumulatedGap = 0;
|
||||||
let isLinkedToLoaded = true;
|
let isLinkedToLoaded = true;
|
||||||
|
let previousWasCountToEnd: Maybe<number> = null;
|
||||||
|
|
||||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||||
const entry = entries[playableEventOrder[idx]];
|
const entry = entries[playableEventOrder[idx]];
|
||||||
@@ -934,8 +935,23 @@ export function loadGroupFlagAndEnd(
|
|||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
if (idx !== currentIndex) {
|
if (idx !== currentIndex) {
|
||||||
// we only accumulate data after the loaded event
|
// we only accumulate data after the loaded event
|
||||||
accumulatedGap += entry.gap;
|
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
if (previousWasCountToEnd !== null) {
|
||||||
|
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
|
||||||
|
* and break the link to the loaded event since countToEnd events reset the schedule
|
||||||
|
*/
|
||||||
|
accumulatedGap += entry.gap + previousWasCountToEnd;
|
||||||
|
isLinkedToLoaded = false;
|
||||||
|
} else {
|
||||||
|
accumulatedGap += entry.gap;
|
||||||
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.countToEnd) {
|
||||||
|
previousWasCountToEnd = entry.duration;
|
||||||
|
} else {
|
||||||
|
previousWasCountToEnd = null;
|
||||||
|
}
|
||||||
|
|
||||||
// and the loaded event is not allowed to be the next flag
|
// and the loaded event is not allowed to be the next flag
|
||||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
|
|||||||
await page.getByRole('button', { name: 'Preview import' }).click();
|
await page.getByRole('button', { name: 'Preview import' }).click();
|
||||||
await page.getByRole('button', { name: 'Apply import' }).click();
|
await page.getByRole('button', { name: 'Apply import' }).click();
|
||||||
await expect(page.getByText('Import complete')).toBeVisible();
|
await expect(page.getByText('Import complete')).toBeVisible();
|
||||||
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Reset flow' }).click();
|
await page.getByRole('button', { name: 'Import another' }).click();
|
||||||
|
|
||||||
// verify the data in the rundown
|
// verify the data in the rundown
|
||||||
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
|||||||
|
|
||||||
// feature business logic
|
// feature business logic
|
||||||
|
|
||||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||||
|
|
||||||
// feature business logic - rundown
|
// feature business logic - rundown
|
||||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||||
|
|||||||
+142
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Day, OffsetMode } from 'ontime-types';
|
import { Day, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||||
import { getExpectedStart } from './getExpectedStart';
|
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
||||||
|
|
||||||
describe('getExpectedStart()', () => {
|
describe('getExpectedStart()', () => {
|
||||||
describe('Absolute offset mode', () => {
|
describe('Absolute offset mode', () => {
|
||||||
@@ -315,3 +315,144 @@ describe('getExpectedStart()', () => {
|
|||||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getExpectedEnd()', () => {
|
||||||
|
const baseState = {
|
||||||
|
currentDay: 0,
|
||||||
|
totalGap: 0,
|
||||||
|
mode: OffsetMode.Absolute,
|
||||||
|
actualStart: null,
|
||||||
|
plannedStart: null,
|
||||||
|
isLinkedToLoaded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a regular event ends at its expected start plus duration', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// on schedule
|
||||||
|
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
|
||||||
|
|
||||||
|
// running 20 behind pushes the end out
|
||||||
|
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).toBe(170);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event pins to the planned end while in overtime', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an overnight countToEnd event returns a normalised end', () => {
|
||||||
|
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
|
||||||
|
const timeStart = 23 * MILLIS_PER_HOUR;
|
||||||
|
const duration = 2 * MILLIS_PER_HOUR;
|
||||||
|
const testEvent = {
|
||||||
|
timeStart,
|
||||||
|
duration,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(timeStart + duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 20,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
};
|
||||||
|
|
||||||
|
// events shift their schedule based on delay...
|
||||||
|
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
|
||||||
|
|
||||||
|
// ... but count to end events stay pinned to the scheduled end
|
||||||
|
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 60,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the delayed start (160) is past the fixed end (150), so the event can no longer
|
||||||
|
// finish on time and the end follows the compromised start
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 1 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
|
||||||
|
// not collapse to the day-shifted start
|
||||||
|
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
|
||||||
|
|
||||||
|
// when the running event is already on the same day, no extra day is added
|
||||||
|
const expectedStartSameDay = getExpectedStart(
|
||||||
|
{ ...testEvent, dayOffset: 0 as Day },
|
||||||
|
{ ...baseState, currentDay: 0, offset: 0 },
|
||||||
|
);
|
||||||
|
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeState = {
|
||||||
|
...baseState,
|
||||||
|
mode: OffsetMode.Relative,
|
||||||
|
actualStart: 30,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||||
|
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
|
||||||
|
180,
|
||||||
|
);
|
||||||
|
|
||||||
|
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||||
|
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
|
||||||
|
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-9
@@ -3,15 +3,6 @@ import { OffsetMode } from 'ontime-types';
|
|||||||
|
|
||||||
import { dayInMs } from './conversionUtils.js';
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* @param event the event that we are counting to
|
|
||||||
* @param currentDay the day offset of the currently running event
|
|
||||||
* @param totalGap accumulated gap from the current event
|
|
||||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
|
||||||
* @param clock
|
|
||||||
* @param offset
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function getExpectedStart(
|
export function getExpectedStart(
|
||||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||||
state: {
|
state: {
|
||||||
@@ -60,3 +51,25 @@ export function getExpectedStart(
|
|||||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||||
return offsetStartTimeBufferedByGaps;
|
return offsetStartTimeBufferedByGaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExpectedEnd(
|
||||||
|
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
|
||||||
|
expectedStart: number,
|
||||||
|
currentRuntimeDay: number,
|
||||||
|
): number {
|
||||||
|
/**
|
||||||
|
* Count to end events are a special case
|
||||||
|
* - the end time is always the wall clock
|
||||||
|
*/
|
||||||
|
if (event.countToEnd) {
|
||||||
|
// account for day offset
|
||||||
|
const relativeDayOffset = event.dayOffset - currentRuntimeDay;
|
||||||
|
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
|
||||||
|
|
||||||
|
// count to end should finish on the planned time or on start
|
||||||
|
return Math.max(expectedStart, plannedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for normal events, the expected end is when we would start + its duration
|
||||||
|
return expectedStart + event.duration;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user