Compare commits

..

12 Commits

Author SHA1 Message Date
alex-arc 99a2f58502 fix isLoadedRundown the now rundown id is provided 2026-07-26 16:33:05 +02:00
alex-arc 535717fa90 the rundown title list should also folow the runown selection 2026-07-26 16:07:13 +02:00
alex-arc 477777552c fix rebase 2026-07-26 15:43:20 +02:00
alex-arc df2d083a16 chore: lint 2026-07-26 15:41:51 +02:00
alex-arc 079a2ea96d feat: select rundown in list view 2026-07-26 15:41:51 +02:00
alex-arc c50c84f50b refactor: use context rundown in finder 2026-07-26 15:41:51 +02:00
alex-arc 58dce0a18c refactor: react state for cuesheet v editor layout 2026-07-26 15:41:51 +02:00
alex-arc b2f9b812bd refactor: rundown id context 2026-07-26 15:41:51 +02:00
alex-arc 35d2474797 fixup! refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc b9f5a0a82b refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc 26a58a9241 fixup! refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
alex-arc 588b04c46b refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
54 changed files with 956 additions and 955 deletions
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react'; import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction'; import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>; type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null); const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren { export function EntryActionsProvider({ children }: PropsWithChildren) {
actions: EntryActionsContextValue; const { effectiveRundownId } = useRundownSelectionContext();
} const actions = useScopedEntryActions(effectiveRundownId);
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>; return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
} }
@@ -0,0 +1,120 @@
import { Maybe, ProjectRundown } from 'ontime-types';
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export type RundownScopeValue = {
loadedRundownId: string;
selectedRundownId: Maybe<string>;
isLoadedRundown: boolean;
effectiveRundownId: string;
selectRundownId: (val: Maybe<string>) => void;
rundowns: ProjectRundown[];
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
'use memo';
const { data } = useProjectRundowns();
const { loaded, rundowns } = data;
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
const selectRundownId = useCallback(
(rundownId: Maybe<string>) => {
startTransition(() => {
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
else setSelectedRundownId(null);
});
},
[rundowns, setSelectedRundownId],
);
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
const isLoadedRundown = effectiveRundownId === loaded;
useEffect(() => {
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
const value = useMemo(
(): RundownScopeValue => ({
loadedRundownId: loaded,
isLoadedRundown,
selectedRundownId,
effectiveRundownId,
selectRundownId,
rundowns,
}),
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownSelectionContext() {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
}
return context;
}
const rundownParam = 'rundownId';
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const selectedRundownId = searchParams.get(rundownParam);
const setSelectedRundownId = useCallback(
(id: Maybe<string>) => {
if (id === null) {
setSearchParams((searchParams) => {
searchParams.delete(rundownParam);
return searchParams;
});
} else {
setSearchParams((searchParams) => {
searchParams.set(rundownParam, id);
return searchParams;
});
}
},
[setSearchParams],
);
return [selectedRundownId, setSelectedRundownId];
}
/**
*
* mutates the provided `searchParams`
*/
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
if (id === null) {
searchParams.delete(rundownParam);
} else {
searchParams.set(rundownParam, id);
}
}
export function useDirectLinkToBackgroundEdit() {
const navigate = useNavigate();
const [search] = useSearchParams();
return useCallback(
async (rundownId: string) => {
setSelectRundownInParams(rundownId, search);
navigate({
pathname: '/cuesheet',
search: search.toString(),
});
},
[navigate, search],
);
}
@@ -0,0 +1,101 @@
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { useMemo } from 'react';
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useFlatRundown, useRundown } from './useRundown';
export function useContextRundownEditModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown } = useRundown(effectiveRundownId);
return { rundown };
}
export function useContextRundownCueRenumberModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data } = useRundown(effectiveRundownId);
const { flatOrder } = data;
return { flatOrder };
}
export function useContextRundownList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const rundownMetadata = useMemo(
() => getRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundown,
rundownMetadata,
status,
isLoadedRundown,
}),
[rundown, rundownMetadata, status, isLoadedRundown],
);
}
export function useContextRundownTitleList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(() => {
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
}, [effectiveSelectedEventId, rundown]);
return useMemo(
() => ({
flatRundown,
status,
isLoadedRundown,
}),
[status, isLoadedRundown, flatRundown],
);
}
export function useContextRundownTable() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
flatRundown,
status,
loadedEventId,
}),
[flatRundown, status, loadedEventId],
);
}
export function useContextRundownFinder() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
return useMemo(
() => ({
rundown,
rundownId: effectiveRundownId,
status,
}),
[rundown, status, effectiveRundownId],
);
}
@@ -1,24 +1,18 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db'; import { getProjects } from '../api/db';
const placeholderProjectList: ProjectFileListResponse = { export function useProjectList() {
files: [], const { data, status, refetch } = useSuspenseQuery({
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }), queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, staleTime: MILLIS_PER_HOUR,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data, status, refetch };
} }
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc'; export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types'; import { ProjectRundownsList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { import {
createRundown, createRundown,
@@ -11,24 +11,21 @@ import {
loadRundown, loadRundown,
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }), queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, staleTime: MILLIS_PER_HOUR,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; return { data, status, isError, refetch, isFetching };
} }
export function useMutateProjectRundowns() { export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({ const { mutateAsync: create } = useMutation({
mutationFn: createRundown, mutationFn: createRundown,
onMutate: () => { onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; import { fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,43 +20,36 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Normalised rundown data for the currently loaded rundown. * Provides access to a specific rundown by ID.
* * When rundownId is not provided the loaded rundown is provided
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export function useRundown(rundownId: Maybe<string>) {
const queryClient = useQueryClient(); 'use memo';
const { const {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
// Seed the id-keyed cache when fetching via the bootstrap alias return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata(rundownId: Maybe<string>) {
const { data, status } = useRundown(); 'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -64,8 +57,8 @@ export function useRundownWithMetadata() {
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown() { export function useFlatRundown(rundownId: Maybe<string>) {
const { data, status } = useRundown(); const { data, status } = useRundown(rundownId);
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { if (data.revision === -1) {
@@ -77,11 +70,13 @@ export function useFlatRundown() {
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
const { data, status } = useRundown(); 'use memo';
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
} }
@@ -92,8 +87,8 @@ export function useFlatRundownWithMetadata() {
* re-filtering on every render. * re-filtering on every render.
* *
*/ */
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundownWithMetadata(rundownId);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
}, [data, cb]); }, [data, cb]);
@@ -103,37 +98,20 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
/** /**
* Hook to get a specific entry by ID from the rundown * Hook to get a specific entry by ID from the rundown
* @deprecated
*/ */
export function useEntry(entryId: EntryId | null): OntimeEntry | null { export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown(rundownId);
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData() { export function useRundownAuxData(rundownId: Maybe<string>) {
const { data, status } = useRundown(); const { data, status } = useRundown(rundownId);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const { title, id } = data; const { title, id } = data;
return { title, id }; return { title, id };
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,54 +0,0 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -90,6 +90,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -107,6 +108,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based eventIndex: 1, // UI indexes are 1 based
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -124,6 +126,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: true,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -140,6 +143,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -156,6 +160,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -172,6 +177,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
@@ -188,6 +194,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
@@ -204,6 +211,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 7, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -256,6 +264,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -273,6 +282,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -290,6 +300,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -27,6 +27,7 @@ export type RundownMetadata = {
groupColour: string | undefined; groupColour: string | undefined;
groupEntries: number | undefined; groupEntries: number | undefined;
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -94,6 +95,7 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined, groupColour: undefined,
groupEntries: undefined, groupEntries: undefined,
isFirstAfterGroup: false, isFirstAfterGroup: false,
isParentToLoaded: false,
}; };
function process(entry: OntimeEntry): Readonly<RundownMetadata> { function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -117,6 +119,7 @@ function processEntry(
// initialise data to be overridden below // initialise data to be overridden below
processedData.isNextDay = false; processedData.isNextDay = false;
processedData.isLoaded = false; processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId processedData.thisId = entry.id; // we reassign thisId
@@ -132,6 +135,7 @@ function processEntry(
processedData.groupId = entry.id; processedData.groupId = entry.id;
processedData.groupColour = entry.colour; processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length; processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else { } else {
// for delays and groups, we insert the group metadata // for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+4 -8
View File
@@ -14,7 +14,6 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS, CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
@@ -25,6 +24,7 @@ import {
VIEW_SETTINGS, VIEW_SETTINGS,
getRundownQueryKey, getRundownQueryKey,
PROJECT_RUNDOWNS, PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants'; } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -216,6 +216,9 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns: case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS }); ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break; break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -238,7 +241,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) { if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns // we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return; return;
} }
@@ -252,12 +254,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
} }
ontimeQueryClient.invalidateQueries({ queryKey, exact: true }); ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
} }
export function sendSocket<T extends MessageTag | ApiActionTag>( export function sendSocket<T extends MessageTag | ApiActionTag>(
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
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 useRundown from '../../../../common/hooks-query/useRundown'; import { useRundown } from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: reportData } = useReport();
const { data } = useRundown(); const { data } = useRundown(null);
const clearReport = async () => await deleteAllReport(); const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => { const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { import {
IoAdd, IoAdd,
IoDocumentOutline, IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -28,6 +28,20 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown'; import { useRundown } from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown(null);
const { applyImport } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -11,11 +11,25 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified'; type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() { export default function ProjectList() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ProjectListSuspend />
</Suspense>
);
}
function ProjectListSuspend() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc'); const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch, status } = useOrderedProjectList(sortMode); const { data, refetch } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -38,14 +52,6 @@ export default function ProjectList() {
}); });
}; };
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length; const numProjects = data.reorderedProjectFiles.length;
return ( return (
@@ -14,7 +14,7 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
@@ -1,9 +1,10 @@
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode } from 'react'; import { PropsWithChildren, ReactNode, Suspense } from 'react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss'; import style from './Overview.module.scss';
@@ -15,18 +16,37 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<div className={cx([style.overview, !isOnline && style.isOffline])}> <Suspense fallback={<OverviewFallback navElements={navElements} />}>
<ErrorBoundary> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<div className={style.nav}>{navElements}</div> <ErrorBoundary>
<ScrollArea <div className={style.nav}>{navElements}</div>
className={style.infoScroll} <ScrollArea
contentClassName={style.info} className={style.infoScroll}
contentStyle={{ minWidth: '100%' }} contentClassName={style.info}
orientation='horizontal' contentStyle={{ minWidth: '100%' }}
> orientation='horizontal'
{children} >
</ScrollArea> {children}
</ErrorBoundary> </ScrollArea>
</ErrorBoundary>
</div>
</Suspense>
);
}
function OverviewFallback({ navElements }: OverviewWrapperProps) {
return (
<div className={style.overview}>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{/* TODO: this could be alined in a nicer way */}
<Loader />
</ScrollArea>
</div> </div>
); );
} }
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(null, currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null; const entry = useEntry(null, id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(); const { data: rundownData } = useRundownAuxData(null);
if (!projectData.title && !rundownData.title) { if (!projectData.title && !rundownData.title) {
return null; return null;
@@ -6,8 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown'; import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -39,28 +38,29 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<EntryActionsProvider actions={entryActions}> <RundownSelectionContextProvider>
<ProtectRoute permission='editor'> <EntryActionsProvider>
<div <ProtectRoute permission='editor'>
className={cx([style.rundownExport, style.extracted])} <div
data-target='small-device' className={cx([style.rundownExport, style.extracted])}
data-testid='panel-rundown' data-target='small-device'
> data-testid='panel-rundown'
<FinderPlacement /> >
<ViewNavigationMenu suppressSettings /> <FinderPlacement />
<div className={style.rundown}> <ViewNavigationMenu suppressSettings />
<ErrorBoundary> <div className={style.rundown}>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} /> <ErrorBoundary>
<RundownContextMenu /> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
</ErrorBoundary> <RundownContextMenu />
</ErrorBoundary>
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
</EntryActionsProvider> </RundownSelectionContextProvider>
); );
} }
@@ -70,30 +70,32 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<EntryActionsProvider actions={entryActions}> <RundownSelectionContextProvider>
<ProtectRoute permission='editor'> <EntryActionsProvider>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <ProtectRoute permission='editor'>
<FinderPlacement /> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <FinderPlacement />
<div className={style.rundown}> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<Editor.Panel className={style.list}> <div className={style.rundown}>
<ErrorBoundary> <Editor.Panel className={style.list}>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEntryEditor /> {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </Editor.Panel>
)} {!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
</EntryActionsProvider> </RundownSelectionContextProvider>
); );
} }
@@ -105,17 +107,16 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : ( ) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />} {viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,16 +1,23 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import Rundown from './Rundown'; import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata; const isLoading = status !== 'success' || !rundown || !rundownMetadata;
if (isLoading) { if (isLoading) {
return <Empty text='Connecting to server' />; return <Empty text='Connecting to server' />;
@@ -18,12 +25,12 @@ function RundownList() {
return ( return (
<Rundown <Rundown
order={data.order} order={rundown.order}
flatOrder={data.flatOrder} flatOrder={rundown.flatOrder}
entries={data.entries} entries={rundown.entries}
id={data.id} id={rundown.id}
rundownMetadata={rundownMetadata} rundownMetadata={rundownMetadata}
featureData={featureData} featureData={isLoadedRundown ? featureData : backgroundFeatureData}
/> />
); );
} }
@@ -0,0 +1,3 @@
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -0,0 +1,42 @@
import Select from '../../../common/components/select/Select';
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
import { AppMode } from '../../../ontimeConfig';
import styles from './RundownSelect.module.scss';
const FOLLOW = '___null___';
interface RundownSelectProps {
appMode: AppMode;
}
export function RundownSelect({ appMode }: RundownSelectProps) {
'use memo';
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
const options = rundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
// add a follow option
options.unshift({
value: FOLLOW,
label: 'Follow loaded',
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? FOLLOW}
options={options}
onValueChange={(value) => {
if (value === FOLLOW) selectRundownId(null);
else selectRundownId(value);
}}
disabled={appMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -1,7 +1,7 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown'; import { useRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter'; import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() { export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown(null);
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useRundown from '../../../common/hooks-query/useRundown'; import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
import { orderEntries } from '../rundown.utils'; import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -17,8 +17,7 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() { export default function RenumberCuesDialog() {
'use memo'; 'use memo';
const { data } = useRundown(); const { flatOrder } = useContextRundownCueRenumberModal();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore(); const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext(); const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -8,6 +8,7 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout'; import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options'; import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode'; import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -24,6 +25,7 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean; showRunEditToggle: boolean;
showOffsetToggle: boolean; showOffsetToggle: boolean;
showOverflowMenu: boolean; showOverflowMenu: boolean;
showRundownSelect: boolean;
} }
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = { export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -31,16 +33,19 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true, showRunEditToggle: true,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: false,
}, },
[EditorLayoutMode.PLANNING]: { [EditorLayoutMode.PLANNING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: false, showOffsetToggle: false,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: true,
}, },
[EditorLayoutMode.TRACKING]: { [EditorLayoutMode.TRACKING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: false, showOverflowMenu: false,
showRundownSelect: false,
}, },
} as const; } as const;
@@ -50,7 +55,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode(); const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode]; const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -122,6 +128,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup> </ToggleGroup>
)} )}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />} {showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root> </Toolbar.Root>
); );
@@ -1,10 +1,7 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
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 { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -16,8 +13,6 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -35,14 +30,12 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending'; const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetDnd columns={columns} tableRoot='editor'> {isLoading ? (
{isLoading ? ( <EmptyPage text='Loading...' />
<EmptyPage text='Loading...' /> ) : (
) : ( <CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' /> )}
)} </CuesheetDnd>
</CuesheetDnd>
</EntryActionsProvider>
); );
} }
@@ -20,7 +20,7 @@ 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 } = useFlatRundown(null);
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter], [filter],
); );
const { data: events } = usePartialRundown(filterCallback); const { data: events } = usePartialRundown(null, filterCallback);
const [firstIndex, setFirstIndex] = useState(-1); const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
@@ -21,7 +21,7 @@ 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 { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -14,7 +14,3 @@
'table'; 'table';
color: $ui-white; color: $ui-white;
} }
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
+23 -28
View File
@@ -4,51 +4,46 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown'; import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo'; 'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<EntryActionsProvider actions={actions}> <RundownSelectionContextProvider>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <EntryActionsProvider>
<EntryEditModal rundown={source.rundown} /> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<div className={styles.tableWrapper} data-testid='cuesheet'> <EntryEditModal />
<CuesheetOverview> <div className={styles.tableWrapper} data-testid='cuesheet'>
{!isLocked && ( <CuesheetOverview>
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}> {!isLocked && (
<IoApps /> <IconButton
</IconButton> aria-label='Toggle navigation'
)} variant='subtle-white'
</CuesheetOverview> size='xlarge'
<CuesheetProgress /> onClick={menuHandler.open}
<CuesheetTableWrapper >
source={source} <IoApps />
selectedRundownId={selectedRundownId} </IconButton>
loadedRundownId={loadedRundownId} )}
setSelectedRundownId={setSelectedRundownId} </CuesheetOverview>
projectRundowns={projectRundowns} <CuesheetProgress />
/> <CuesheetTableWrapper />
</div> </div>
</EntryActionsProvider> </EntryActionsProvider>
</RundownSelectionContextProvider>
); );
} }
@@ -1,39 +1,20 @@
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 EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { useRundownSelectionContext } from '../../common/context/RundownSelectionContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({ function CuesheetTableWrapper() {
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; const { isLoadedRundown } = useRundownSelectionContext();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoadedRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
@@ -50,66 +31,11 @@ function CuesheetTableWrapper({
) : ( ) : (
<CuesheetTable <CuesheetTable
columns={columns} columns={columns}
source={source}
cuesheetMode={cuesheetMode} cuesheetMode={cuesheetMode}
tableRoot='cuesheet' tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode} setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/> />
)} )}
</CuesheetDnd> </CuesheetDnd>
); );
} }
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -1,21 +0,0 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
});
it('keeps the stored selection when it still exists in the current project', () => {
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
});
});
@@ -1,16 +1,13 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import { useContextRundownEditModal } from '../../../common/hooks-query/useContextRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal({ rundown }: EntryEditModalProps) { function EntryEditModal() {
const { rundown } = useContextRundownEditModal();
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ContextProp, ContextProp,
ItemProps, ItemProps,
@@ -14,7 +14,7 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
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 { useContextRundownTable } from '../../../common/hooks-query/useContextRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; 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';
@@ -35,34 +35,22 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
columns, const { flatRundown, status, loadedEventId } = useContextRundownTable();
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -143,17 +131,17 @@ export default function CuesheetTable({
// in Run mode, follow the current event // in Run mode, follow the current event
useEffect(() => { useEffect(() => {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) { if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !loadedEventId) {
return; return;
} }
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId); const eventIndex = flatRundown.findIndex((event) => event.id === loadedEventId);
if (eventIndex === -1) { if (eventIndex === -1) {
return; return;
} }
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, flatRundown, selectedEventId]); }, [cuesheetMode, flatRundown, loadedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard) // Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => { useEffect(() => {
@@ -242,17 +230,9 @@ export default function CuesheetTable({
handleResetResizing={resetColumnResizing} handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
insertElement={insertElement} appMode={cuesheetMode}
modeControls={ tableRoot={tableRoot}
tableRoot === 'cuesheet' setCuesheetMode={setCuesheetMode}
? {
cuesheetMode,
setCuesheetMode,
isCurrentRundown,
}
: undefined
}
showShare={tableRoot === 'cuesheet'}
/> />
<TableVirtuoso <TableVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
@@ -10,7 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import { useRundownSelectionContext } from '../../../../common/context/RundownSelectionContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { RundownSelect } from '../../../../features/rundown/common/RundownSelect';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
@@ -30,21 +32,15 @@ type TableHeaderOptionValues = Pick<
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays' 'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>; >;
type TableModeControls = {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
insertElement?: ReactNode; setCuesheetMode?: (mode: AppMode) => void;
modeControls?: TableModeControls; appMode: AppMode;
showShare?: boolean; tableRoot: 'editor' | 'cuesheet';
} }
export default function CuesheetTableHeaderToolbar({ export default function CuesheetTableHeaderToolbar({
@@ -53,23 +49,23 @@ export default function CuesheetTableHeaderToolbar({
handleResetResizing, handleResetResizing,
handleResetReordering, handleResetReordering,
handleClearToggles, handleClearToggles,
insertElement, setCuesheetMode,
modeControls, tableRoot,
showShare = false, appMode,
}: CuesheetTableHeaderToolbarProps) { }: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode); const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode) && tableRoot === 'cuesheet';
const canShare = useCuesheetPermissions((state) => state.canShare); const canShare = useCuesheetPermissions((state) => state.canShare) && tableRoot === 'cuesheet';
const showRundownSelect = tableRoot === 'cuesheet';
const { isLoadedRundown } = useRundownSelectionContext();
const toggleCuesheetMode = (mode: AppMode[]) => { const toggleCuesheetMode = (mode: AppMode[]) => {
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue || !modeControls) return; if (!newValue || !setCuesheetMode) return;
modeControls.setCuesheetMode(newValue); setCuesheetMode(newValue);
}; };
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return ( return (
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}> <Toolbar.Root className={style.tableSettings} data-background-rundown={!isLoadedRundown}>
<ViewSettings optionsStore={optionsStore} /> <ViewSettings optionsStore={optionsStore} />
<ColumnSettings <ColumnSettings
columns={columns} columns={columns}
@@ -77,14 +73,14 @@ export default function CuesheetTableHeaderToolbar({
handleResetReordering={handleResetReordering} handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles} handleClearToggles={handleClearToggles}
/> />
{modeControls && canChangeMode && ( <div className={style.apart}>
<div className={style.apart}> {showRundownSelect && <RundownSelect appMode={appMode} />}
{insertElement} {canChangeMode && (
<ToggleGroup <ToggleGroup
value={[modeControls.cuesheetMode]} value={[appMode]}
onValueChange={toggleCuesheetMode} onValueChange={toggleCuesheetMode}
className={style.group} className={style.group}
disabled={!modeControls.isCurrentRundown} disabled={!isLoadedRundown}
> >
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run Run
@@ -93,10 +89,10 @@ export default function CuesheetTableHeaderToolbar({
Edit Edit
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> </ToggleGroup>
</div> )}
)} </div>
{showShare && canShare && ( {canShare && (
<> <>
<Editor.Separator orientation='vertical' /> <Editor.Separator orientation='vertical' />
<CuesheetShareModal /> <CuesheetShareModal />
@@ -1,65 +0,0 @@
import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
});
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
const { loaded: loadedRundownId } = projectRundowns;
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
return {
loadedRundownId,
selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
setStoredSelectedRundownId(rundownId);
});
},
};
}
export function useDirectLinkToBackgroundEdit() {
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
async (rundownId: string) => {
await navigate('/cuesheet');
startTransition(() => setStoredSelectedRundownId(rundownId));
},
[setStoredSelectedRundownId, navigate],
);
}
+43 -35
View File
@@ -1,5 +1,6 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList'; import TitleList from './title-list/TitleList';
@@ -14,44 +15,51 @@ const MessageControl = lazy(() => import('../../features/control/message/Message
export default function Editor() { export default function Editor() {
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
if (layoutMode === EditorLayoutMode.CONTROL) { switch (layoutMode) {
return ( case EditorLayoutMode.TRACKING: {
<div id='panels' className={styles.panelContainer}> return (
<div className={styles.left}> <div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<TimerControl /> <div className={styles.rundownLayout}>
<MessageControl /> <div className={styles.titlesPanel}>
</div> <RundownSelectionContextProvider>
<Rundown /> <TitleList mode={AppMode.Run} />
</div> </RundownSelectionContextProvider>
); </div>
} <div className={styles.rundownPanel}>
<Rundown />
if (layoutMode === EditorLayoutMode.TRACKING) { </div>
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div> </div>
<div className={styles.rundownPanel}> <TrackingPlaybackBar />
<Rundown /> </div>
);
}
case EditorLayoutMode.PLANNING: {
return (
<div id='panels' className={styles.panelContainer}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<RundownSelectionContextProvider>
<TitleList mode={AppMode.Edit} />
</RundownSelectionContextProvider>
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div> </div>
</div> </div>
<TrackingPlaybackBar /> );
</div> }
); case EditorLayoutMode.CONTROL:
} default: {
return (
return ( <div id='panels' className={styles.panelContainer}>
<div id='panels' className={styles.panelContainer}> <div className={styles.left}>
<div className={styles.rundownLayout}> <TimerControl />
<div className={styles.titlesPanel}> <MessageControl />
<TitleList mode={AppMode.Edit} /> </div>
</div>
<div className={styles.rundownPanel}>
<Rundown /> <Rundown />
</div> </div>
</div> );
</div> }
); }
} }
@@ -1,7 +1,7 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useContextRundownFinder } from '../../../common/hooks-query/useContextRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; const maxResults = 12;
@@ -38,7 +38,7 @@ type FilterableMilestone = {
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
export default function useFinder() { export default function useFinder() {
const { data, rundownId } = useFlatRundown(); const { rundown: data, rundownId } = useContextRundownFinder();
const [results, setResults] = useState<FilterableEntry[]>([]); const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null); const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef(''); const lastSearchString = useRef('');
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import ScrollArea from '../../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
import useRundown from '../../../common/hooks-query/useRundown'; import { useContextRundownList } from '../../../common/hooks-query/useContextRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -20,7 +20,7 @@ interface TitleListProps {
} }
export default function TitleList({ mode }: TitleListProps) { export default function TitleList({ mode }: TitleListProps) {
const { data: rundown } = useRundown(); const { rundown } = useContextRundownList();
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor); const cursor = useEventSelection((state) => state.cursor);
@@ -1,6 +1,9 @@
import { isValueOfEnum } from 'ontime-utils'; import { isValueOfEnum } from 'ontime-utils';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { setSelectRundownInParams } from '../../common/context/RundownSelectionContext';
const layoutParam = 'layout'; const layoutParam = 'layout';
export enum EditorLayoutMode { export enum EditorLayoutMode {
@@ -20,14 +23,29 @@ function getEditorLayout(value: string | null): EditorLayoutMode {
} }
export function useEditorLayout() { export function useEditorLayout() {
'use memo';
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const layoutMode = getEditorLayout(searchParams.get(layoutParam)); const layoutMode = getEditorLayout(searchParams.get(layoutParam));
const setLayoutMode = (mode: EditorLayoutMode) => { useEffect(() => {
const nextParams = new URLSearchParams(searchParams); setSearchParams((searchParams) => {
nextParams.set(layoutParam, mode); if (layoutMode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
setSearchParams(nextParams, { replace: true }); return searchParams;
}; });
}, [setSearchParams, layoutMode]);
const setLayoutMode = useCallback(
(mode: EditorLayoutMode) => {
setSearchParams((searchParams) => {
searchParams.set(layoutParam, mode);
// Only the Planning layout is allowed to look at something other than the current rundown
if (mode !== EditorLayoutMode.PLANNING) setSelectRundownInParams(null, searchParams);
return searchParams;
});
},
[setSearchParams],
);
return { layoutMode, setLayoutMode }; return { layoutMode, setLayoutMode };
} }
@@ -16,7 +16,7 @@ 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 { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata(null);
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
+2 -2
View File
@@ -2,7 +2,7 @@ import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } fro
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown'; import { useRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions'; import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings(); const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields(); const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown(); const { data: rundown, status: rundownStatus } = useRundown(null);
const { entries } = rundown; const { entries } = rundown;
return { return {
+1 -17
View File
@@ -1,5 +1,5 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types'; import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs } from 'ontime-utils'; import { MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
/** Returns the current instant */ /** Returns the current instant */
export function now(): Instant { export function now(): Instant {
@@ -59,10 +59,6 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant; return (instant + totalDuration) as Instant;
} }
export function combineValues<T extends Duration | Day>(...duration: T[]): T {
return Array.isArray(duration) ? (duration.reduce<number>((total, current) => total + current, 0) as T) : duration;
}
/** /**
* Calculates elapsed time on the clock from a starting time to the current time * Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight) * Handles overnight crossing (when current < start, assumes we've crossed midnight)
@@ -84,15 +80,3 @@ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day
return (currentDaySerial - startDaySerial) as Day; return (currentDaySerial - startDaySerial) as Day;
} }
export const ZERO_DURATION = 0 as Duration;
/**
* utility function to generate Instant times
*/
export function generateInstanceFromClock(hour: number, minutes: number, second: number, ms = 0) {
return toInstant(
(hour * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + second * MILLIS_PER_SECOND + ms) as TimeOfDay,
now(),
);
}
@@ -1,8 +1,7 @@
import { Duration, EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getCurrent, getCurrent,
@@ -20,48 +19,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
describe('getElapsed()', () => { describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => { it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 5, 0), clock: 5 * MILLIS_PER_MINUTE,
timer: { timer: {
addedTime: -10 * MILLIS_PER_MINUTE, addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE, current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE, duration: 20 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
pausedDuration: 0, pausedDuration: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
}); });
it('subtracts accumulated pause time', () => { it('subtracts accumulated pause time', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0), clock: 10 * MILLIS_PER_MINUTE,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE, pausedDuration: 5 * MILLIS_PER_MINUTE,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
}); });
it('uses the current pause start while paused', () => { it('uses the current pause start while paused', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0), clock: 10 * MILLIS_PER_MINUTE,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: 7 * MILLIS_PER_MINUTE, pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE, pausedDuration: 1 * MILLIS_PER_MINUTE,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
}); });
@@ -83,7 +82,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(null); expect(calculatedFinish).toBe(null);
}); });
@@ -96,13 +95,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: true, hasFinished: true,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(20); expect(calculatedFinish).toBe(20);
}); });
@@ -115,13 +114,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(11); expect(calculatedFinish).toBe(11);
}); });
@@ -134,13 +133,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 20, addedTime: 20,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(31); expect(calculatedFinish).toBe(31);
@@ -154,13 +153,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: -10, addedTime: -10,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -174,13 +173,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: -100, addedTime: -100,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -194,13 +193,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 0, duration: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -214,13 +213,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs, duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(10); expect(calculatedFinish).toBe(10);
@@ -235,13 +234,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: dayInMs, duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40); expect(calculatedFinish).toBe(40);
@@ -254,7 +253,7 @@ describe('getExpectedFinish()', () => {
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
@@ -264,7 +263,7 @@ describe('getExpectedFinish()', () => {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time // expected finish is not a duration but a point in time
@@ -280,7 +279,7 @@ describe('getCurrent()', () => {
timeEnd: 30, timeEnd: 30,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0), clock: 0,
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: 111, // <-- we take the duration value duration: 111, // <-- we take the duration value
@@ -290,7 +289,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(111); expect(current).toBe(111);
@@ -301,17 +300,17 @@ describe('getCurrent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1), clock: 1,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(9); expect(current).toBe(9);
@@ -322,17 +321,17 @@ describe('getCurrent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1), clock: 1,
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(19); expect(current).toBe(19);
@@ -343,17 +342,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10), clock: 10,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(dayInMs + 10); expect(current).toBe(dayInMs + 10);
@@ -364,17 +363,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(15); expect(current).toBe(15);
@@ -385,17 +384,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 20, addedTime: 20,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(35); expect(current).toBe(35);
@@ -408,7 +407,7 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 100, duration: 100,
@@ -421,7 +420,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(70); expect(current).toBe(70);
@@ -433,11 +432,11 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 100, duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
rundown: { rundown: {
plannedEnd: 100, plannedEnd: 100,
@@ -446,7 +445,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(70); expect(current).toBe(70);
@@ -458,11 +457,11 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 7, addedTime: 7,
duration: 100, duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
rundown: { rundown: {
plannedEnd: 100, plannedEnd: 100,
@@ -471,7 +470,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(77); expect(current).toBe(77);
@@ -484,11 +483,11 @@ describe('getCurrent()', () => {
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(22, 5, 0), clock: 79500000, // 22:05:00
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
rundown: { rundown: {
actualStart: 79200000, actualStart: 79200000,
@@ -498,7 +497,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000); expect(current).toBe(dayInMs - 79500000 + 600000);
@@ -506,7 +505,7 @@ describe('getCurrent()', () => {
it('handles events that were started late', () => { it('handles events that were started late', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16 min after the scheduled end clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
eventNow: { eventNow: {
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
@@ -516,7 +515,7 @@ describe('getCurrent()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
rundown: { rundown: {
actualStart: 82000000, // 22:46:40 <--- started now actualStart: 82000000, // 22:46:40 <--- started now
@@ -526,7 +525,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(81000000 - 82000000); // <-- planned end - now expect(current).toBe(81000000 - 82000000); // <-- planned end - now
@@ -542,17 +541,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0), clock: 0,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration, duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const expectedFinish = getExpectedFinish(state); const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state); const current = getCurrent(state);
@@ -570,25 +569,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 3, addedTime: 3,
duration, duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const expectedFinish = getExpectedFinish(state); const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state); const current = getCurrent(state);
const elapsed = duration - current; const elapsed = duration - current;
expect(expectedFinish).toBe(13); expect(expectedFinish).toBe(13);
expect(current).toBe(8);
expect(elapsed).toBe(2); expect(elapsed).toBe(2);
expect(current).toBe(8);
}); });
}); });
@@ -872,7 +871,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -884,9 +883,9 @@ describe('getRuntimeOffset()', () => {
plannedStart: 100, plannedStart: 100,
currentDay: 0, currentDay: 0,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150), clock: 150,
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(50); expect(absolute).toBe(50);
@@ -900,7 +899,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed startedAt: 150, // we started 50ms delayed
addedTime: 10, // we compensated with 10ms addedTime: 10, // we compensated with 10ms
current: 10, // we are 10ms into the timer current: 10, // we are 10ms into the timer
}, },
@@ -913,7 +912,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(60); expect(absolute).toBe(60);
@@ -928,7 +927,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime startedAt: 100, // we started ontime
current: -10, // we are 10 seconds over current: -10, // we are 10 seconds over
addedTime: 0, // we have not compensated with added time addedTime: 0, // we have not compensated with added time
}, },
@@ -941,7 +940,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(10); expect(absolute).toBe(10);
@@ -955,9 +954,9 @@ describe('getRuntimeOffset()', () => {
timeEnd: 150, timeEnd: 150,
dayOffset: 0, dayOffset: 0,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150), clock: 150,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time startedAt: 100, // started on time
current: 25, // are 25ms into it current: 25, // are 25ms into it
addedTime: 0, addedTime: 0,
}, },
@@ -970,7 +969,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(25); expect(absolute).toBe(25);
@@ -978,7 +977,7 @@ describe('getRuntimeOffset()', () => {
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789 clock: 78480789,
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
timeStart: 77400000, timeStart: 77400000,
@@ -1012,7 +1011,7 @@ describe('getRuntimeOffset()', () => {
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1020,7 +1019,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offsets dont exist if we are not in overtime', () => { it('with time-to-end, offsets dont exist if we are not in overtime', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 13, 20), clock: 80000000, // 22:13:20
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
type: 'event', type: 'event',
@@ -1064,11 +1063,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000, expectedFinish: 81600000,
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0), startedAt: 78000000,
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1076,7 +1075,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset is the overtime', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), clock: 82000000, // 22:46:40
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
type: 'event', type: 'event',
@@ -1120,11 +1119,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000, expectedFinish: 81600000,
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0), startedAt: 78000000,
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
@@ -1132,7 +1131,7 @@ describe('getRuntimeOffset()', () => {
it('handles time-to-end started after the end time', () => { it('handles time-to-end started after the end time', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16m 40s after the scheduled end clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
@@ -1165,14 +1164,14 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 82000000 + 3600000, // <--- now + duration expectedFinish: 82000000 + 3600000, // <--- now + duration
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(22, 46, 40), startedAt: 82000000, // <--- started now
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent as Duration; state.timer.current = updateCurrent;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(millisToString(absolute)).toBe('00:16:40'); expect(millisToString(absolute)).toBe('00:16:40');
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
@@ -1188,7 +1187,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1201,7 +1200,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1215,7 +1214,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1228,7 +1227,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(50); expect(absolute).toBe(50);
@@ -1242,7 +1241,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), startedAt: 100,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1255,7 +1254,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(-50); expect(absolute).toBe(-50);
@@ -1277,7 +1276,7 @@ describe('getTimerPhase()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: null, startedAt: null,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None); expect(phase).toBe(TimerPhase.None);
@@ -1295,7 +1294,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime); expect(phase).toBe(TimerPhase.Overtime);
@@ -1313,7 +1312,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger); expect(phase).toBe(TimerPhase.Danger);
@@ -1331,7 +1330,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning); expect(phase).toBe(TimerPhase.Warning);
@@ -1349,7 +1348,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default); expect(phase).toBe(TimerPhase.Default);
@@ -1357,7 +1356,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => { it('#1042 identifies waiting to roll', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050 clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -1383,13 +1382,13 @@ describe('getTimerPhase()', () => {
startedAt: null, startedAt: null,
}, },
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
}, },
_rundown: { _rundown: {
totalDelay: 0, totalDelay: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending); expect(phase).toBe(TimerPhase.Pending);
@@ -1397,7 +1396,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => { it('#1042 identifies waiting to roll', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050 clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -1423,13 +1422,13 @@ describe('getTimerPhase()', () => {
startedAt: null, startedAt: null,
}, },
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
}, },
_rundown: { _rundown: {
totalDelay: 0, totalDelay: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending); expect(phase).toBe(TimerPhase.Pending);
@@ -1,9 +1,10 @@
import { copyFile } from 'fs/promises'; import { copyFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types'; import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils'; import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -105,6 +106,9 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
currentProjectName: fileName, currentProjectName: fileName,
}; };
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return fileName; return fileName;
} }
@@ -263,6 +267,10 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename); const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate); await copyFile(projectFilePath, pathToDuplicate);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return; return;
} }
@@ -293,6 +301,10 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const newFileName = await loadProject(projectData.data, newFilename); const newFileName = await loadProject(projectData.data, newFilename);
return newFileName; return newFileName;
} }
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
return newFilename; return newFilename;
} }
@@ -332,6 +344,9 @@ export async function deleteProjectFile(filename: string) {
} }
await deleteFile(projectFilePath); await deleteFile(projectFilePath);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectFiles);
});
} }
/** /**
@@ -374,6 +389,7 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
} }
} }
const updatedData = await getDataProvider().getData(); const updatedData = getDataProvider().getData();
return updatedData; return updatedData;
} }
-2
View File
@@ -5,8 +5,6 @@ import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js'; import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
import { normaliseEndTime } from './timerUtils.js'; import { normaliseEndTime } from './timerUtils.js';
// import * as timeCore from '../lib/time-core/timeCore.js';
/** /**
* Finds current event in a rolling rundown * Finds current event in a rolling rundown
*/ */
@@ -760,17 +760,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// save the restore state // save the restore state
if (hasImmediateChanges) { if (hasImmediateChanges) {
const internalState = runtimeState.getInternalState();
restoreService restoreService
.save({ .save({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt, startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: internalState._timer.pausedAt, pausedAt: state._timer.pausedAt,
pausedDuration: internalState._timer.pausedDuration, pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart, firstStart: state.rundown.actualStart,
startEpoch: internalState._startEpoch, startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay, currentDay: state.rundown.currentDay,
}) })
.catch((_e) => { .catch((_e) => {
+36 -39
View File
@@ -1,9 +1,7 @@
import { Day, Duration, Maybe, TimeOfDay, TimerPhase } from 'ontime-types'; import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js'; import type { RuntimeState } from '../stores/runtimeState.js';
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
/** /**
* handle events that span over midnight * handle events that span over midnight
@@ -22,46 +20,48 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
/** /**
* Calculates expected finish time of a running timer * Calculates expected finish time of a running timer
* @param {InternalRuntimeState} state runtime state * @param {RuntimeState} state runtime state
* @returns {number | null} new current time or null if nothing is running * @returns {number | null} new current time or null if nothing is running
*/ */
export function getExpectedFinish(state: InternalRuntimeState): Maybe<TimeOfDay> { export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const { startedAt, duration, addedTime } = state.timer; const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) { if (state.eventNow === null) {
return null; return null;
} }
const { countToEnd, timeEnd } = state.eventNow; const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { _now } = state; const { clock } = state;
if (startedAt === null) { if (startedAt === null) {
return null; return null;
} }
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0; const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (countToEnd) { if (countToEnd) {
return (timeEnd + addedTime + pausedTime) as TimeOfDay; return timeEnd + addedTime + pausedTime;
} }
// handle events that finish the day after // handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime; const expectedFinish = startedAt + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) { if (expectedFinish > dayInMs) {
return (expectedFinish - dayInMs) as TimeOfDay; return expectedFinish - dayInMs;
} }
// an event cannot finish before it started (user added too much negative time) // an event cannot finish before it started (user added too much negative time)
return Math.max(expectedFinish, timeCore.toTimeOfDay(startedAt)) as TimeOfDay; return Math.max(expectedFinish, startedAt);
} }
/** /**
* Calculates running countdown * Calculates running countdown
* @param {InternalRuntimeState} state runtime state * @param {RuntimeState} state runtime state
* @returns {number} current time for timer * @returns {number} current time for timer
*/ */
export function getCurrent(state: InternalRuntimeState): Duration {
export function getCurrent(state: RuntimeState): number {
// eslint-disable-next-line no-unused-labels -- dev code path // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
if (state.eventNow === null || state.timer.duration === null) { if (state.eventNow === null || state.timer.duration === null) {
@@ -71,12 +71,12 @@ export function getCurrent(state: InternalRuntimeState): Duration {
const { startedAt, duration, addedTime } = state.timer; const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow; const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { _now } = state; const { clock } = state;
if (countToEnd) { if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd; const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0; const correctDay = isEventOverMidnight ? dayInMs : 0;
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration; return correctDay - clock + timeEnd + addedTime;
} }
if (startedAt === null) { if (startedAt === null) {
@@ -84,19 +84,19 @@ export function getCurrent(state: InternalRuntimeState): Duration {
} }
if (pausedAt != null) { if (pausedAt != null) {
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - pausedAt) as Duration; return startedAt + duration + addedTime - pausedAt;
} }
const hasPassedMidnight = timeCore.toTimeOfDay(startedAt) > timeCore.toTimeOfDay(_now); const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0; const correctDay = hasPassedMidnight ? dayInMs : 0;
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - timeCore.toTimeOfDay(_now) - correctDay) as Duration; return startedAt + duration + addedTime - clock - correctDay;
} }
/** /**
* Calculates active time elapsed since the timer started. * Calculates active time elapsed since the timer started.
*/ */
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> { export function getElapsed(state: RuntimeState): MaybeNumber {
const { _now } = state; const { clock } = state;
const { startedAt } = state.timer; const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer; const { pausedAt, pausedDuration } = state._timer;
@@ -104,11 +104,11 @@ export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
return null; return null;
} }
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now); const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt)); const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration; const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed) as Duration; return Math.max(0, activeElapsed);
} }
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
@@ -127,7 +127,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
* @returns {boolean} * @returns {boolean}
*/ */
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean { export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we haven't started // we cant have skipped if we havent started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) { if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false; return false;
} }
@@ -151,12 +151,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule * Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule * Negative offset is under time / ahead of schedule
*/ */
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } { export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, _now, _startDayOffset } = state; const { eventNow, clock, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started // nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null || _startDayOffset === null) { if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: ZERO_DURATION, relative: ZERO_DURATION }; return { absolute: 0, relative: 0 };
} }
const { countToEnd, timeStart, dayOffset } = eventNow; const { countToEnd, timeStart, dayOffset } = eventNow;
@@ -171,23 +171,20 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
} }
// difference between planned event start and actual event start (will be positive if we started behind) // difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset = const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
timeCore.toTimeOfDay(startedAt) + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset) // how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
const overtime = Math.abs(Math.min(current, 0)); const overtime = Math.abs(Math.min(current, 0));
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight // time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : timeCore.toTimeOfDay(_now) - 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
// in case of count to end, the absolute offset is overtime and added time // in case of count to end, the absolute offset is overtime and added time
const absolute = ( const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime
) as Duration;
// the relative offset is the same as the absolute but adjusted relative to the actual start time // the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = (absolute + plannedStart - actualStart - _startDayOffset * dayInMs) as Duration; const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
return { absolute, relative }; return { absolute, relative };
} }
@@ -196,7 +193,7 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
* Checks running timer to see which phase it currently is in * Checks running timer to see which phase it currently is in
* @param state * @param state
*/ */
export function getTimerPhase(state: InternalRuntimeState): TimerPhase { export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state.timer.playback)) { if (!isPlaybackActive(state.timer.playback)) {
return TimerPhase.None; return TimerPhase.None;
} }
@@ -226,7 +223,7 @@ export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
/** /**
* Finds the day offset relative to an event start * Finds the day offset relative to an event start
* used by the RuntimeState on first start to get correct offsets * used byt the runtimeState on first start to get correct offsets
*/ */
export function findDayOffset(plannedStart: number, clock: number): Day { export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart; const distance = clock - plannedStart;
@@ -1,12 +1,33 @@
import { Day, type Duration, type Instant, OffsetMode, Playback, TimeOfDay, TimerPhase } from 'ontime-types'; import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js'; import type { RuntimeState } from '../runtimeState.js';
import type { InternalRuntimeState, RuntimeState } from '../runtimeState.js';
export const runtimePlaceholder = { const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
actualGroupStart: null,
currentDay: 0,
},
offset: {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedRundownEnd: null,
expectedGroupEnd: null,
expectedFlagStart: null,
},
timer: { timer: {
addedTime: ZERO_DURATION, addedTime: 0,
current: null, current: null,
duration: null, duration: null,
elapsed: null, elapsed: null,
@@ -16,43 +37,10 @@ export const runtimePlaceholder = {
secondaryTimer: null, secondaryTimer: null,
startedAt: null, startedAt: null,
}, },
offset: {
absolute: ZERO_DURATION,
relative: ZERO_DURATION,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
},
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0 as TimeOfDay,
plannedEnd: 0 as TimeOfDay,
actualStart: null,
actualGroupStart: null,
currentDay: 0 as Day,
},
} as Readonly<InternalRuntimeState>;
const baseState: InternalRuntimeState = {
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
...runtimePlaceholder.rundown,
},
offset: {
...runtimePlaceholder.offset,
},
timer: { ...runtimePlaceholder.timer },
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0 as Duration, pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, hasFinished: false,
}, },
@@ -6,10 +6,9 @@ import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { import {
type InternalRuntimeState, type RuntimeState,
addTime, addTime,
clearState, clearState,
getInternalState,
getState, getState,
load, load,
loadGroupFlagAndEnd, loadGroupFlagAndEnd,
@@ -33,7 +32,7 @@ const mockEvent = {
} as PlayableEvent; } as PlayableEvent;
const mockState = { const mockState = {
_now: 666, clock: 666,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -54,7 +53,7 @@ const mockState = {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => { vi.mock('../../classes/data-provider/DataProvider.js', () => {
return { return {
@@ -109,34 +108,34 @@ describe('mutation on runtimeState', () => {
const { metadata, rundown } = rundownCache.get(); const { metadata, rundown } = rundownCache.get();
load(mockEvent, rundown, metadata); load(mockEvent, rundown, metadata);
let newState = getInternalState(); let newState = getState();
expect(newState.eventNow?.id).toBe(mockEvent.id); expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.eventNext?.id).toBe('event2'); expect(newState.eventNext?.id).toBe('event2');
expect(newState.timer.playback).toBe(Playback.Armed); expect(newState.timer.playback).toBe(Playback.Armed);
expect(getState().clock).not.toBe(666); expect(newState.clock).not.toBe(666);
expect(newState.groupNow).toBeNull(); expect(newState.groupNow).toBeNull();
// 2. Start event // 2. Start event
vi.setSystemTime('jan 1 00:02'); vi.setSystemTime('jan 1 00:02');
let success = start(); let success = start();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Play, playback: Playback.Play,
}); });
expect(newState.rundown.actualStart).toBe(getState().clock); expect(newState.rundown.actualStart).toBe(newState.clock);
// 3. Pause event // 3. Pause event
vi.setSystemTime('jan 1 00:03'); vi.setSystemTime('jan 1 00:03');
success = pause(); success = pause();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(getState().clock).not.toBe(666); expect(newState.clock).not.toBe(666);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Pause, playback: Playback.Pause,
addedTime: 0, addedTime: 0,
}); });
expect(newState._timer.pausedAt).toEqual(getState().clock); expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
@@ -144,7 +143,7 @@ describe('mutation on runtimeState', () => {
// 4. Restart event // 4. Restart event
vi.setSystemTime('jan 1 00:04'); vi.setSystemTime('jan 1 00:04');
success = start(); success = start();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Play, playback: Playback.Play,
@@ -164,7 +163,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event // 5. Stop event
vi.setSystemTime('jan 1 00:05'); vi.setSystemTime('jan 1 00:05');
success = stop(); success = stop();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.eventNow).toBe(null); expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
@@ -826,7 +825,7 @@ describe('roll mode', () => {
test('pending event', () => { test('pending event', () => {
const { rundown, metadata } = rundownCache.get(); const { rundown, metadata } = rundownCache.get();
const { eventId, didStart } = roll(rundown, metadata); const { eventId, didStart } = roll(rundown, metadata);
const state = getInternalState(); const state = getState();
expect(eventId).toBe('1'); expect(eventId).toBe('1');
expect(didStart).toBe(false); expect(didStart).toBe(false);
@@ -945,7 +944,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[11], eventNow: rundown.entries[11],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -973,7 +972,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1], groupNow: rundown.entries[1],
eventNow: rundown.entries[22], eventNow: rundown.entries[22],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1001,7 +1000,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1], groupNow: rundown.entries[1],
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1026,7 +1025,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
@@ -1088,7 +1087,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1170,7 +1169,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+105 -154
View File
@@ -17,6 +17,7 @@ import {
TimerPhase, TimerPhase,
TimerState, TimerState,
isOntimeEvent, isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types'; } from 'ontime-types';
import { import {
calculateDuration, calculateDuration,
@@ -43,18 +44,14 @@ import {
hasCrossedMidnight, hasCrossedMidnight,
} from '../services/timerUtils.js'; } from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js'; import { timerConfig } from '../setup/config.js';
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
type ExpectedMetadata = { type ExpectedMetadata = {
event: OntimeEvent; event: OntimeEvent;
accumulatedGap: Duration; accumulatedGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} | null; } | null;
export type RuntimeState = { export type RuntimeState = {
/**
* @deprecated we want to phase out the 24h clock
*/
clock: TimeOfDay; clock: TimeOfDay;
groupNow: OntimeGroup | null; groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null; eventNow: PlayableEvent | null;
@@ -64,72 +61,36 @@ export type RuntimeState = {
timer: TimerState; timer: TimerState;
rundown: RundownState; rundown: RundownState;
// private properties of the timer calculations // private properties of the timer calculations
};
export type InternalRuntimeState = Omit<RuntimeState, 'clock' | 'timer' | 'offset' | 'rundown'> & {
timer: {
addedTime: Duration;
current: Maybe<Duration>;
duration: Maybe<Duration>;
elapsed: Maybe<Duration>;
expectedFinish: Maybe<TimeOfDay>;
phase: TimerPhase;
playback: Playback;
secondaryTimer: Maybe<Duration>;
startedAt: Maybe<Instant>;
};
offset: {
absolute: Duration;
relative: Duration;
mode: OffsetMode;
expectedGroupEnd: Maybe<TimeOfDay>;
expectedRundownEnd: Maybe<TimeOfDay>;
expectedFlagStart: Maybe<TimeOfDay>;
};
rundown: {
selectedEventIndex: Maybe<number>;
numEvents: number;
plannedStart: Maybe<TimeOfDay>;
plannedEnd: Maybe<TimeOfDay>;
actualStart: Maybe<TimeOfDay>;
currentDay: Maybe<Day>;
actualGroupStart: Maybe<TimeOfDay>;
};
_now: Instant;
_timer: { _timer: {
forceFinish: boolean; // whether we should declare an event as finished, will contain the finish time forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>; pausedAt: Maybe<TimeOfDay>;
pausedDuration: Duration; pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
}; };
_rundown: { _rundown: {
totalDelay: number; // this value comes from rundown service totalDelay: number; // this value comes from rundown service
}; };
_startDayOffset: Maybe<Day>;
_startEpoch: Maybe<Instant>;
_group: ExpectedMetadata; _group: ExpectedMetadata;
_flag: ExpectedMetadata; _flag: ExpectedMetadata;
_end: ExpectedMetadata; _end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
}; };
const runtimeState: InternalRuntimeState = { const runtimeState: RuntimeState = {
_now: timeCore.now(), clock: timeCore.timeOfDayNow(),
groupNow: null, groupNow: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null, eventFlag: null,
offset: { ...runtimePlaceholder.offset }, offset: { ...runtimeStorePlaceholder.offset },
timer: { timer: { ...runtimeStorePlaceholder.timer },
...runtimePlaceholder.timer, rundown: { ...runtimeStorePlaceholder.rundown },
},
rundown: {
...runtimePlaceholder.rundown,
},
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0 as Duration, pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, hasFinished: false,
}, },
@@ -143,32 +104,19 @@ const runtimeState: InternalRuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
export function getInternalState(): Readonly<InternalRuntimeState> {
return runtimeState;
}
export function getState(): Readonly<RuntimeState> { export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
...runtimeState, ...runtimeState,
clock: timeCore.toTimeOfDay(runtimeState._now),
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null, eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null, eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null, eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null, groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset }, offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown }, rundown: { ...runtimeState.rundown },
timer: { timer: { ...runtimeState.timer },
addedTime: runtimeState.timer.addedTime, _timer: { ...runtimeState._timer },
current: runtimeState.timer.current, _rundown: { ...runtimeState._rundown },
duration: runtimeState.timer.duration,
elapsed: runtimeState.timer.elapsed,
expectedFinish: runtimeState.timer.expectedFinish,
phase: runtimeState.timer.phase,
playback: runtimeState.timer.playback,
secondaryTimer: runtimeState.timer.secondaryTimer,
startedAt: runtimeState.timer.startedAt ? timeCore.toTimeOfDay(runtimeState.timer.startedAt) : null,
},
}; };
} }
@@ -179,18 +127,22 @@ export function clearEventData() {
runtimeState.eventNow = null; runtimeState.eventNow = null;
runtimeState.eventNext = null; runtimeState.eventNext = null;
runtimeState.offset = { ...runtimePlaceholder.offset }; runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.rundown.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now(); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration; runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
} }
@@ -208,18 +160,22 @@ export function clearState() {
runtimeState.rundown.actualStart = null; runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.offset = { ...runtimePlaceholder.offset }; runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState._end = null; runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now(); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration; runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
@@ -261,9 +217,9 @@ export function updateRundownData(rundownData: {
runtimeState._rundown.totalDelay = rundownData.totalDelay; runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.rundown.numEvents = rundownData.numEvents; runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay; runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.rundown.plannedEnd = runtimeState.rundown.plannedEnd =
rundownData.firstStart === null ? null : ((rundownData.firstStart + rundownData.totalDuration) as TimeOfDay); rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes(); if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
} }
@@ -297,7 +253,7 @@ export function load(
// update state // update state
runtimeState.timer.playback = Playback.Armed; runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = event.duration as Duration; runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.rundown.numEvents = metadata.timedEventOrder.length; runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
@@ -311,13 +267,13 @@ export function load(
(firstStart === null || typeof firstStart === 'number') && (firstStart === null || typeof firstStart === 'number') &&
(startEpoch === null || typeof startEpoch === 'number') (startEpoch === null || typeof startEpoch === 'number')
) { ) {
runtimeState.rundown.actualStart = firstStart as TimeOfDay; runtimeState.rundown.actualStart = firstStart;
runtimeState._startEpoch = startEpoch; runtimeState._startEpoch = startEpoch;
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) { if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart); runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
} }
if (currentDay !== undefined) { if (currentDay !== undefined) {
runtimeState.rundown.currentDay = currentDay as Day; runtimeState.rundown.currentDay = currentDay;
} }
const { absolute, relative } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.offset.absolute = absolute; runtimeState.offset.absolute = absolute;
@@ -409,16 +365,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll // handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) { if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute; const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// if waiting to roll, we update the targets and potentially start the timer // if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) { if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) { if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start // if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay; runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
} }
@@ -435,7 +391,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.startedAt = null; runtimeState.timer.startedAt = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
runtimeState.timer.addedTime = 0 as Duration; runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
// this could be looked after by the timer // this could be looked after by the timer
@@ -458,7 +414,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex); loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
} }
export function start(state: InternalRuntimeState = runtimeState): boolean { export function start(state: RuntimeState = runtimeState): boolean {
if (state.eventNow === null) { if (state.eventNow === null) {
return false; return false;
} }
@@ -466,19 +422,22 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return false; return false;
} }
state._now = timeCore.now(); const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null; state.timer.secondaryTimer = null;
// add paused time if it exists // add paused time if it exists
if (state._timer.pausedAt) { if (state._timer.pausedAt) {
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration; const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd); state.timer.addedTime += timeToAdd;
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd); state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
} }
if (state.timer.startedAt === null) { if (state.timer.startedAt === null) {
state.timer.startedAt = state._now; state.timer.startedAt = state.clock;
} }
state.timer.playback = Playback.Play; state.timer.playback = Playback.Play;
@@ -486,15 +445,14 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
state.timer.elapsed = getElapsed(state); state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) + state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset; state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now; state._startEpoch = epoch;
state.rundown.actualStart = timeCore.toTimeOfDay(state._now); state.rundown.actualStart = state.clock;
} }
if (state.groupNow !== null && state.rundown.actualGroupStart === null) { if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now); state.rundown.actualGroupStart = state.clock;
} }
// update timer phase // update timer phase
@@ -517,18 +475,18 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return true; return true;
} }
export function pause(state: InternalRuntimeState = runtimeState): boolean { export function pause(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback !== Playback.Play) { if (state.timer.playback !== Playback.Play) {
return false; return false;
} }
state.timer.playback = Playback.Pause; state.timer.playback = Playback.Pause;
state._now = timeCore.now(); state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = timeCore.toTimeOfDay(state._now); state._timer.pausedAt = state.clock;
return true; return true;
} }
export function stop(state: InternalRuntimeState = runtimeState): boolean { export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) { if (state.timer.playback === Playback.Stop) {
return false; return false;
} }
@@ -558,7 +516,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) { if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered // set finished time so side effects are triggered
runtimeState._timer.forceFinish = true; runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else { } else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) { if (willGoPositive) {
@@ -567,8 +525,8 @@ export function addTime(amount: number) {
} }
// we can update the state after handling the side effects // we can update the state after handling the side effects
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration); runtimeState.timer.addedTime += amount;
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration); runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState); runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under // update runtime delays: over - under
@@ -588,8 +546,10 @@ export type UpdateResult = {
export function update(): UpdateResult { export function update(): UpdateResult {
// 0. there are some things we always do // 0. there are some things we always do
const previousClock = timeCore.toTimeOfDay(runtimeState._now); const previousClock = runtimeState.clock;
runtimeState._now = timeCore.now(); // we update the clock on every update call const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle? // 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) { if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -598,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started) // calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now); const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart); runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
} }
// 2. are we waiting to roll? // 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, timeCore.toTimeOfDay(runtimeState._now)); const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight); return updateIfWaitingToRoll(clockHasCrossedMidnight);
} }
@@ -658,24 +618,22 @@ export function update(): UpdateResult {
} }
// account for offset // account for offset
const offsetClock = timeCore.toTimeOfDay( const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
);
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) { if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target // if we crossed midnight, we need to update the target
// this is the same logic from the roll function // this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
} }
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return { return {
hasTimerFinished: false, hasTimerFinished: false,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer! <= 0, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
}; };
} }
} }
@@ -692,7 +650,9 @@ export function roll(
} }
// we will need to do some calculations, update the time first // we will need to do some calculations, update the time first
runtimeState._now = timeCore.now(); const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
// 2. if there is an event armed, we use it // 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) { if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
@@ -704,9 +664,7 @@ export function roll(
} }
if (offset) { if (offset) {
runtimeState.offset = { runtimeState.offset = { ...offset };
...offset,
} as InternalRuntimeState['offset'];
} }
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
@@ -715,16 +673,15 @@ export function roll(
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs ? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd; : runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay; runtimeState.timer.expectedFinish = normalisedEndTime;
// account for offset // account for offset
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration); const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration; runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0 as Duration; runtimeState.timer.elapsed = 0;
// check if the event is ready to start or if needs to be pending // check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock); const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
@@ -733,8 +690,8 @@ export function roll(
* If we are starting an event in roll mode * If we are starting an event in roll mode
* we backtrace all the start times to the supposed start time of the event * we backtrace all the start times to the supposed start time of the event
*/ */
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay; const plannedStart = runtimeState.eventNow.timeStart;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now); runtimeState.timer.startedAt = plannedStart;
// reset the secondary timer to cancel any countdowns // reset the secondary timer to cancel any countdowns
runtimeState.timer.secondaryTimer = null; runtimeState.timer.secondaryTimer = null;
@@ -752,20 +709,18 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day; runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started // backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay); const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration); runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch // calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = timeCore.combineValues( runtimeState.rundown.currentDay =
runtimeState._startDayOffset, runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
);
} }
} else { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
} }
@@ -782,9 +737,9 @@ export function roll(
// account for offset but we only keep it if passed to us // account for offset but we only keep it if passed to us
if (offset) { if (offset) {
runtimeState.offset = { ...offset } as InternalRuntimeState['offset']; runtimeState.offset = { ...offset };
} }
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute; const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const { index, isPending } = loadRoll(rundown, metadata, offsetClock); const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -811,7 +766,7 @@ export function roll(
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after // we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay; runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties // preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -834,18 +789,18 @@ export function roll(
* when we start in roll mode * when we start in roll mode
* we need to backtrace all times to the supposed start time of the event * we need to backtrace all times to the supposed start time of the event
*/ */
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay; const plannedStart = runtimeState.eventNow.timeStart;
const endTime = const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs ? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd; : runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now); runtimeState.timer.startedAt = plannedStart;
runtimeState.timer.expectedFinish = endTime as TimeOfDay; runtimeState.timer.expectedFinish = endTime;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0 as Duration; runtimeState.timer.elapsed = 0;
// update runtime // update runtime
runtimeState.rundown.actualStart = plannedStart; runtimeState.rundown.actualStart = plannedStart;
@@ -858,11 +813,11 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day; runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started // backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay); const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration); runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch // calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset + runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now)) as Day; timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true }; return { eventId: runtimeState.eventNow.id, didStart: true };
} }
@@ -897,11 +852,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.offset.expectedGroupEnd = getExpectedEnd( state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
lastEvent,
lastEventExpectedStart,
state.rundown.currentDay!,
) as TimeOfDay;
} }
} }
@@ -918,7 +869,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.offset.expectedFlagStart = expectedStart as TimeOfDay; state.offset.expectedFlagStart = expectedStart;
} }
} }
@@ -933,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay; state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
} }
} }
@@ -1006,13 +957,13 @@ export function loadGroupFlagAndEnd(
if (!foundFlag && metadata.flags.includes(entry.id)) { if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true; foundFlag = true;
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration }; state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
} }
} }
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) { if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true; foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration }; state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
} }
} }
} }
@@ -1020,7 +971,7 @@ export function loadGroupFlagAndEnd(
const lastID = playableEventOrder.at(-1); const lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null; const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) { if (lastEvent) {
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration }; state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
} }
} }
@@ -1,6 +1,7 @@
export enum RefetchKey { export enum RefetchKey {
All = 'all', All = 'all',
CustomFields = 'custom-fields', CustomFields = 'custom-fields',
ProjectFiles = 'project-files',
ProjectData = 'project-data', ProjectData = 'project-data',
ProjectRundowns = 'project-rundowns', ProjectRundowns = 'project-rundowns',
Report = 'report', Report = 'report',
@@ -1,4 +1,4 @@
import type { Duration, OntimeEvent } from 'ontime-types'; import type { OntimeEvent } from 'ontime-types';
import { TimeStrategy } from 'ontime-types'; import { TimeStrategy } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js'; import { dayInMs } from '../date-utils/conversionUtils.js';
@@ -103,17 +103,16 @@ export function validateTimes(
/** /**
* @description calculates event duration considering midnight * @description calculates event duration considering midnight
* @deprecated - duration is the more useful value from the event data, prefer using that directly
* @param {number} timeStart * @param {number} timeStart
* @param {number} timeEnd * @param {number} timeEnd
* @returns {number} * @returns {number}
*/ */
export function calculateDuration(timeStart: number, timeEnd: number): Duration { export function calculateDuration(timeStart: number, timeEnd: number): number {
// Durations must be positive // Durations must be positive
if (timeEnd < timeStart) { if (timeEnd < timeStart) {
return (timeEnd + dayInMs - timeStart) as Duration; return timeEnd + dayInMs - timeStart;
} }
return (timeEnd - timeStart) as Duration; return timeEnd - timeStart;
} }
export function calculateEnd(timeStart: number, duration: number): number { export function calculateEnd(timeStart: number, duration: number): number {