Compare commits

..

19 Commits

Author SHA1 Message Date
Carlos Valente 982cb8e495 fix(rundown): preserve revision and runtime semantics
Advance swapped-entry revisions correctly and rename loaded rundowns without restarting playback, while documenting the shared revision invariant.
2026-09-13 20:23:53 +02:00
Carlos Valente 48c76947c5 refactor(rundown): bind each surface to its data source
Editable panels now share a rundown scope for data, selection, and mutations; runtime-only views resolve the loaded rundown explicitly. This also prevents stale data while switching scopes and provides the Operator editor with its mutation scope.
2026-09-13 20:23:53 +02:00
Carlos Valente 2a992a8595 refactor(rundown): add scoped rundown data primitives
Centralise rundown identity, queries, entry actions, and selection state so a surface can safely target either the loaded or a background rundown.
2026-09-13 20:23:34 +02:00
Carlos Valente b07544ab84 feat(automation): improve definition and trigger editing 2026-09-13 15:40:12 +02:00
Carlos Valente 79c21daf73 fix(automation): validate template-aware output targets 2026-09-13 15:40:12 +02:00
Carlos Valente 1175b3c641 fix(report): harden empty report check 2026-09-05 20:51:53 +02:00
Carlos Valente afc3a44455 refactor(report): improve report summary 2026-09-05 20:35:44 +02:00
Carlos Valente bc6a321172 fix(cuesheet): prevent empty seconds field 2026-09-05 20:32:02 +02:00
Carlos Valente 977b01a072 fix: keep group visible as header for the running event 2026-09-05 09:24:42 +02:00
SMPTY dd718230ad fix(client): fix PWA manifest so "Install as App" works properly (#2192) 2026-09-05 09:15:08 +02:00
Carlos Valente 9ec12f4927 docs(llm): request for useeffect documentation 2026-08-28 12:29:12 +02:00
Carlos Valente 4c08ca2908 feat(timer): playing sound on time end 2026-08-28 12:29:12 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
Carlos Valente 9806f97d27 test(rundown): improve rundown utils coverage 2026-08-25 22:41:37 +02:00
193 changed files with 4917 additions and 2072 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.12.0",
"version": "4.13.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+4 -2
View File
@@ -7,13 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<title>Ontime</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.12.0",
"version": "4.13.0",
"private": true,
"type": "module",
"dependencies": {
+9 -7
View File
@@ -1,19 +1,21 @@
{
"name": "ontime",
"short_name": "ontime",
"name": "Ontime",
"short_name": "Ontime",
"icons": [
{
"src": "favicon.ico",
"type": "image/x-icon"
"src": "ontime-logo-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "ontime-logo.png",
"src": "ontime-logo-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"scope": "./",
"start_url": "./",
"display": "",
"theme_color": "#121212",
"display": "standalone",
"theme_color": "#101010",
"background_color": "#101010"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
+3
View File
@@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -112,7 +113,9 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
</EditorFeatureWrapper>
}
/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
import { maybeAxiosError } from '../utils';
describe('maybeAxiosError', () => {
it('shows the validation message without echoing the submitted value', () => {
const error = {
isAxiosError: true,
response: {
statusText: 'Unprocessable Entity',
data: {
errors: [
{
type: 'field',
value: { title: 'Automation definition', outputs: [{ targetIP: 'not a host' }] },
msg: 'Invalid OSC target',
path: '',
location: 'body',
},
],
},
},
};
expect(maybeAxiosError(error)).toBe('Unprocessable Entity: Invalid OSC target');
});
});
+8
View File
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
+3 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import type { ReportData } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -8,18 +8,13 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
* HTTP request to fetch the report
*/
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
export async function fetchReport(options?: RequestOptions): Promise<ReportData> {
const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
+9
View File
@@ -18,6 +18,15 @@ export function maybeAxiosError(error: unknown) {
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else if ('errors' in data && Array.isArray(data.errors)) {
const firstError = data.errors.at(0);
data =
typeof firstError === 'object' &&
firstError !== null &&
'msg' in firstError &&
typeof firstError.msg === 'string'
? firstError.msg
: JSON.stringify(data);
} else {
data = JSON.stringify(data);
}
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -0,0 +1,13 @@
import { TimerLifeCycle } from 'ontime-types';
import { getLifecycleLabel } from '../timerLifecycle';
describe('getLifecycleLabel', () => {
it('returns the shared label for known lifecycle values', () => {
expect(getLifecycleLabel(TimerLifeCycle.onClock)).toBe('Every second');
});
it('keeps unknown lifecycle values visible', () => {
expect(getLifecycleLabel('future-lifecycle')).toBe('future-lifecycle');
});
});
@@ -0,0 +1,17 @@
import { TimerLifeCycle } from 'ontime-types';
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -0,0 +1,77 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -0,0 +1,81 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
@@ -1,17 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types';
import type { ReportData } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
const emptyReport: ReportData = {
eventReports: {},
rundown: null,
show: {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
actualStart: null,
actualEnd: null,
actualDuration: null,
},
};
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
const { data: report, refetch } = useQuery<ReportData>({
queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
placeholderData: (previousData) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? {}, refetch };
return {
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
}
+31 -100
View File
@@ -1,62 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useRundownScope } from '../context/RundownScopeContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Normalised rundown data for the currently loaded rundown.
*
* 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`.
* Normalised rundown data for the rundown of the enclosing scope
*/
export default function useRundown() {
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
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 };
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const selectedEventId = useScopedSelectedEventId();
// key on the fields the derivation reads, a revision only change must not churn the list
const { entries, flatOrder } = data;
const rundownMetadata = useMemo(
() => getRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data, status, rundownMetadata };
}
@@ -66,41 +42,23 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useSelectedEventId();
const selectedEventId = useScopedSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
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 };
}
@@ -0,0 +1,65 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -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],
);
}
@@ -1,13 +1,13 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
getRundownSelectionStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
} from '../useRundownSelection';
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',
describe('useRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project',
);
});
+47 -18
View File
@@ -8,7 +8,6 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -32,9 +31,10 @@ import {
swapEventData,
} from 'ontime-utils';
import { useCallback, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import { getRundownCacheKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,6 +52,7 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -64,22 +65,38 @@ export type EventOptions = Partial<{
lastEventId: MaybeString;
}>;
/**
* Applies a patch the way the server does, revision included.
*
* An entry revision advances on every change to that entry, which makes it a
* cheap marker for whether an entry has moved on. The optimistic entry has to
* carry the revision the server will return, otherwise the two disagree and the
* refetch resolves to a different object for no reason.
* Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
*/
function patchEntry(entry: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
}
return { ...entry, ...patch } as OntimeEntry;
}
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
* Gather utilities for actions on entries in the rundown of the enclosing scope.
*/
export const useEntryActions = () => useEntryActionsForRundown(undefined);
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
const previousEntry = newRundown[eventId];
if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Return a context with the previous and new events
return { previousData, newEvent, queryKey };
},
// the server is the authority on the applied patch, it may normalise what we sent
onSuccess: (response, _variables, context) => {
const serverEntry = response.data;
if (!serverEntry || !context?.queryKey) return;
const cachedRundown = queryClient.getQueryData<Rundown>(context.queryKey);
if (!cachedRundown) return;
// our optimistic entry usually describes the change exactly, writing an
// identical entry would discard the cached reference for nothing
if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
queryClient.setQueryData<Rundown>(context.queryKey, {
...cachedRundown,
entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
});
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) {
@@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
newRundown[eventId] = patchEntry(event, data.data);
}
}
});
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef | null>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
});
}
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [followRef, scrollRef, getTopOffset]);
return scrollToRefComponent;
}
@@ -2,14 +2,21 @@ 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';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
/** each surface keeps its own selection, so panels can point at different rundowns */
export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
/**
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() {
return {
loadedRundownId,
selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
@@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
-19
View File
@@ -95,22 +95,6 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
};
});
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
return {
aux1: state.auxtimer1.name,
aux2: state.auxtimer2.name,
aux3: state.auxtimer3.name,
};
});
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
return (
state.auxtimer1.playback === SimplePlayback.Start ||
state.auxtimer2.playback === SimplePlayback.Start ||
state.auxtimer3.playback === SimplePlayback.Start
);
});
export const useAuxTimerTime = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1) return state.auxtimer1.current;
@@ -124,18 +108,15 @@ export const useAuxTimerControl = (index: number) =>
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
name: state.auxtimer1.name,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
name: state.auxtimer2.name,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
name: state.auxtimer3.name,
};
})();
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `ontime - ${title}`;
document.title = `Ontime - ${title}`;
}, []);
}
@@ -6,5 +6,4 @@ export const ontimePlaceholderSettings: Settings = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
@@ -0,0 +1,64 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -13,7 +13,6 @@ type EditorSettingsStore = {
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
auxTimersCollapsed: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
@@ -22,7 +21,6 @@ type EditorSettingsStore = {
setDangerTime: (dangerTime: string) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
};
export const editorSettingsDefaults = {
@@ -34,7 +32,6 @@ export const editorSettingsDefaults = {
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
auxTimersCollapsed: false,
};
enum EditorSettingsKeys {
@@ -46,7 +43,6 @@ enum EditorSettingsKeys {
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -71,10 +67,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
auxTimersCollapsed: booleanFromLocalStorage(
EditorSettingsKeys.AuxTimersCollapsed,
editorSettingsDefaults.auxTimersCollapsed,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -118,10 +110,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
setAuxTimersCollapsed: (auxTimersCollapsed) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
return { auxTimersCollapsed };
}),
};
});
@@ -1,14 +1,21 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
}));
@@ -0,0 +1,158 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -0,0 +1,23 @@
import type { OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getEventVariance } from '../report';
it('uses captured days when measuring an event across midnight', () => {
const report: OntimeEventReport = {
startedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 5 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
};
expect(getEventVariance(report)).toMatchObject({
actualDuration: 10 * MILLIS_PER_MINUTE,
delta: 0,
status: 'ontime',
});
expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' });
});
@@ -58,4 +58,16 @@ describe('formatDuration()', () => {
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s');
});
it('formats durations differently with and without seconds', () => {
expect(formatDuration(0, false)).toBe('0m');
expect(formatDuration(0, true)).toBe('0m');
expect(formatDuration(30 * MILLIS_PER_SECOND, false)).toBe('30s');
expect(formatDuration(30 * MILLIS_PER_SECOND, true)).toBe('');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, false)).toBe('2h30s');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, true)).toBe('2h');
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, false)).toBe(
'2h10m30s',
);
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, true)).toBe('2h10m');
});
});
@@ -0,0 +1,26 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
@@ -1,10 +0,0 @@
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
const custom = name?.trim();
return custom ? custom : fallback;
}
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
const custom = name?.trim();
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
}
+29
View File
@@ -0,0 +1,29 @@
import type { MaybeNumber, OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
type EventVariance = {
actualDuration: MaybeNumber;
delta: number;
status: 'ontime' | 'over' | 'under' | 'not-run';
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
export function getReportTimePosition(time: number, day: number): number;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber {
return time === null || day === null ? null : day * dayInMs + time;
}
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) return notRun;
const start = getReportTimePosition(entry.startedAt, entry.startedAtDay);
const end = getReportTimePosition(entry.endedAt, entry.endedAtDay);
if (start === null || end === null) return notRun;
const actualDuration = end - start;
const delta = actualDuration - entry.scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' };
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision === cachedRundown?.revision) {
// we already have the latest change
// we already have this change, or something newer
// messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
return;
}
@@ -30,7 +30,8 @@ $content-max-width: 1280px;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
overflow-y: auto;
// Own both scroll axes so sticky table headers stay anchored to the panel viewport.
overflow: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
@@ -5,9 +5,8 @@
font-size: calc(1rem - 1px);
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
padding-right: 0.5rem;
h3 {
font-size: 1rem;
@@ -27,10 +26,7 @@
}
.titleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
.filterSection {
display: grid;
grid-gap: 0.5rem;
@@ -41,10 +37,7 @@
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
.filterSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
@@ -61,26 +54,85 @@
}
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
grid-template-columns: minmax(11rem, 1.25fr) minmax(10rem, 1fr) minmax(13rem, 1.75fr) auto;
align-items: end;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
@media (max-width: $min-tablet) {
.filterSection {
grid-template-columns: 1fr 1fr auto;
label:last-of-type {
grid-column: 1 / -1;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
.card {
border: 1px solid $white-10;
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
border-radius: $component-border-radius-md;
background-color: $black-10;
label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
color: $secondary-text-gray;
font-size: $aux-text-size;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: $green-400;
font-size: $aux-text-size;
}
.testError {
padding: 0 0.75rem 0.5rem;
}
.tagOsc {
background-color: $blue-1000;
color: $blue-300;
}
.tagHttp {
background-color: $green-1000;
color: $green-300;
}
.tagOntime {
background-color: $gray-1000;
color: $gray-200;
}
@@ -1,14 +1,5 @@
import {
Automation,
AutomationDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
@@ -16,25 +7,28 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { startsWithHttp } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm';
import TemplateInput from './template-input/TemplateInput';
import OscOutputForm from './OscOutputForm';
import OutputCard, { type TestState } from './OutputCard';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
const testFeedbackDuration = 2000;
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -46,13 +40,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const { data } = useCustomFields();
const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [submitError, setSubmitError] = useState<string>();
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const {
control,
handleSubmit,
getValues,
register,
setError,
setFocus,
setValue,
watch,
@@ -93,6 +89,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title');
}, [setFocus]);
// Clear delayed output-test feedback when the modal unmounts.
useEffect(() => {
const timers = feedbackTimers.current;
return () => Object.values(timers).forEach(clearTimeout);
}, []);
const reportTest = (key: string, state: TestState) => {
setTestResults((prev) => ({ ...prev, [key]: state }));
clearTimeout(feedbackTimers.current[key]);
if (state.status === 'ok') {
feedbackTimers.current[key] = setTimeout(() => {
setTestResults((prev) => {
const { [key]: _discarded, ...rest } = prev;
return rest;
});
}, testFeedbackDuration);
}
};
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' });
};
@@ -106,57 +124,33 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' });
};
const handleAddnewOntimeAction = () => {
const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' });
};
const handleTestOSCOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) {
const handleTest = async (index: number, key: string) => {
const values = getValues(`outputs.${index}`);
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return;
}
await testOutput({
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestHTTPOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestOntimeAction = async (index: number) => {
reportTest(key, { status: 'sending' });
try {
const values = getValues(`outputs.${index}`) as OntimeAction;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
await testOutput(values);
reportTest(key, { status: 'ok', message: 'Request sent' });
} catch (error) {
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
}
};
const onSubmit = async (values: AutomationDTO) => {
setSubmitError(undefined);
if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values });
} else {
@@ -169,7 +163,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
setSubmitError(maybeAxiosError(error));
}
}
@@ -178,12 +172,43 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
setSubmitError(maybeAxiosError(error));
}
}
};
const canSubmit = !isSubmitting && isDirty && isValid;
const addOutputMenu = (
<DropdownMenu
render={<Button />}
items={[
...(isOntimeCloud
? []
: [
{
type: 'item' as const,
label: 'OSC',
description: 'Send an OSC message to a device on the network',
onClick: handleAddNewOSCOutput,
},
]),
{
type: 'item' as const,
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item' as const,
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddNewOntimeAction,
},
]}
>
Add output <IoAdd />
</DropdownMenu>
);
return (
<Modal
@@ -202,7 +227,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
placeholder='Automation title'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -223,6 +248,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ value: 'any', label: 'Any filter passes' },
]}
/>
<Panel.Description>
All filters pass requires every condition to match. Any filter passes requires at least one match.
</Panel.Description>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
@@ -264,11 +292,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
@@ -305,150 +329,52 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<span>
Use Ontime runtime data in these fields with template strings. Type{' '}
<Panel.Highlight>{'{{'}</Panel.Highlight> to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</span>
</Info>
{fieldOutputs.length === 0 && (
<Panel.EmptyState
title='This automation does nothing yet'
description='An automation without outputs will be triggered, but it has nothing to send.'
action={addOutputMenu}
/>
)}
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
const cardProps = {
testState: testResults[output.id],
onTest: () => handleTest(index, output.id),
onDelete: () => removeOutput(index),
};
const rowErrors = getOutputErrors(index);
if (isOSCOutput(output)) {
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
<OutputCard
key={output.id}
label='OSC'
kindClass={style.tagOsc}
summary={watch(`outputs.${index}.address`)}
unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
{...cardProps}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
<OntimeActionForm
value={output.action}
index={index}
@@ -456,44 +382,19 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
/>
</OutputCard>
);
}
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
{fieldOutputs.length > 0 && addOutputMenu}
</div>
</form>
}
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
@@ -0,0 +1,3 @@
.table {
min-width: 36rem;
}
@@ -9,9 +9,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
import style from './AutomationsList.module.scss';
const automationPlaceholder: AutomationDTO = {
title: '',
filterRule: 'all',
@@ -60,19 +63,18 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
</Info>
)}
<Panel.Table>
<Panel.Table className={style.table}>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th style={{ width: '15%' }}>Sends</th>
<th />
</tr>
</thead>
@@ -80,7 +82,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
description='Create a reusable definition, then attach it to a global or an event trigger.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
@@ -100,7 +102,15 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<td>
{automations[automationId].outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
summariseOutputs(automations[automationId].outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -0,0 +1,34 @@
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface HttpOutputFormProps {
index: number;
output: HTTPOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
return (
<label className={style.spanFull}>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
);
}
@@ -1,10 +1,11 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
import { PropsWithChildren, useState } from 'react';
import { useState } from 'react';
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps {
index: number;
register: UseFormRegister<AutomationDTO>;
rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
rowErrors?: OutputErrors;
value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>;
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
setValue,
rowErrors,
value,
children,
watch,
}: PropsWithChildren<OntimeActionFormProps>) {
}: OntimeActionFormProps) {
const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => {
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
};
return (
<div className={style.actionSection}>
<>
<label>
Action
<Select
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && (
<>
<label>
<label className={style.spanFull}>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
@@ -105,7 +99,7 @@ export default function OntimeActionForm({
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
<label className={style.spanFull}>
Visibility
<Select
onValueChange={(value) => {
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
</label>
</>
)}
<div className={style.test}>{children}</div>
</div>
</>
);
}
@@ -0,0 +1,63 @@
import type { AutomationDTO, OSCOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface OscOutputFormProps {
index: number;
output: OSCOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
return (
<>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
</>
);
}
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
import { IoCheckmark, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
interface OutputCardProps {
label: string;
kindClass?: string;
summary?: string;
testState?: TestState;
unavailableReason?: string;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
export default function OutputCard({
label,
kindClass,
summary,
testState,
unavailableReason,
onTest,
onDelete,
children,
}: OutputCardProps) {
return (
<div className={style.card}>
<div className={style.cardHeader}>
<Tag className={kindClass}>{label}</Tag>
<span className={style.cardSummary}>{summary}</span>
{testState?.status === 'ok' && (
<span className={style.testOk}>
<IoCheckmark />
{testState.message}
</span>
)}
{unavailableReason ? (
<Tag variant='warning'>{unavailableReason}</Tag>
) : (
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
Test
</Button>
)}
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
<IoTrash />
</IconButton>
</div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={style.cardBody}>{children}</div>
</div>
);
}
@@ -0,0 +1,25 @@
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleField {
display: block;
}
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
label {
min-width: 0;
}
}
@media (max-width: $min-tablet) {
.fields {
grid-template-columns: 1fr;
}
}
@@ -11,6 +11,8 @@ import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import style from './TriggerForm.module.scss';
const formId = 'trigger-form';
interface TriggerFormProps {
@@ -83,18 +85,20 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<label className={style.titleField}>
<Panel.Description>Title</Panel.Description>
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={trigger?.title}
placeholder='Trigger title'
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<div className={style.fields}>
<label>
Lifecycle trigger
<Panel.Description>When to run</Panel.Description>
<Select
value={watch('trigger')}
onValueChange={(value) => {
@@ -107,7 +111,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Panel.Description>Automation</Panel.Description>
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
@@ -119,6 +123,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</div>
</form>
}
footerElements={
@@ -0,0 +1,3 @@
.table {
min-width: 36rem;
}
@@ -1,5 +1,5 @@
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation';
@@ -12,6 +12,8 @@ import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
import style from './TriggersList.module.scss';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
@@ -50,6 +52,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
};
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
const orphans = useMemo(
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
[triggers, automations],
);
// there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0;
@@ -66,7 +72,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
/>
)}
<Panel.SubHeader>
Manage triggers
Global triggers
<Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd />
</Button>
@@ -74,17 +80,25 @@ export default function TriggersList({ triggers, automations, enabledAutomations
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
</Info>
)}
<Info>Actions in this section affect the entire project runtime, not just a single event.</Info>
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance
issues.
You have created multiple links between the same trigger and automation. Duplicate combinations will only
fire once per lifecycle event.
</Panel.Error>
)}
<Panel.Table>
{orphans > 0 && (
<Panel.Error>
{orphans === 1
? '1 trigger points at an automation that no longer exists and will never run.'
: `${orphans} triggers point at automations that no longer exist and will never run.`}
</Panel.Error>
)}
<Panel.Table className={style.table}>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
@@ -100,7 +114,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
: 'Create an automation definition first, then add a global trigger to decide when it should run.'
}
action={
canAdd && (
@@ -111,19 +125,16 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
{triggers.map((trigger, index) => (
<TriggersListItem
key={trigger.id}
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
))}
{deleteError && (
<tr>
<td colSpan={5}>
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const automation = automations[trigger.automationId];
return (
<tr data-warn={duplicate}>
@@ -30,9 +31,7 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<td>{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
@@ -1,6 +1,19 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils';
import { checkDuplicates, operators } from '../automationUtils';
describe('automation form options', () => {
it('offers the complete filter operator contract', () => {
expect(operators.map(({ value }) => value)).toEqual([
'equals',
'not_equals',
'contains',
'not_contains',
'greater_than',
'less_than',
]);
});
});
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
@@ -1,21 +1,33 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
value: TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
{ label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
export type OutputErrors = Partial<Record<string, { message?: string }>>;
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
];
/**
@@ -66,19 +78,16 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger
*/
export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
const seen = new Set<string>();
const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
for (const [index, trigger] of triggers.entries()) {
const key = `${trigger.trigger}:${trigger.automationId}`;
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
if (seen.has(key)) {
duplicates.push(index);
} else {
triggersMap[trigger.trigger].push(trigger.automationId);
seen.add(key);
}
}
return duplicates.length > 0 ? duplicates : undefined;
@@ -1,7 +0,0 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,103 +1,72 @@
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
import ReportShowSummary from './composite/ReportShowSummary';
import ReportTable from './composite/ReportTable';
import { getCombinedReport, getGroupReports, getRunSummary, makeReportCSV } from './reportSettings.utils';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const { report } = useReport();
const { eventReports, rundown, show } = report;
const { combinedReport, groups, summary } = useMemo(() => {
const entries = rundown?.entries ?? {};
return {
combinedReport: rundown ? getCombinedReport(eventReports, entries, rundown.flatOrder) : [],
groups: rundown ? getGroupReports(eventReports, entries, rundown.order) : [],
summary: getRunSummary(eventReports, entries, rundown?.flatOrder ?? []),
};
}, [eventReports, rundown]);
const hasReport = rundown !== null && Object.keys(eventReports).length > 0;
const downloadCSV = () => {
if (!hasReport) return;
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.SubHeader>
Report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
<Button onClick={downloadCSV} disabled={!hasReport}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<Button variant='subtle-destructive' onClick={deleteAllReport} disabled={!hasReport}>
<IoTrashBin />
Clear All
Clear Report
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.SubHeader>
<Panel.Divider />
{!hasReport ? (
<Panel.Section>
<Panel.EmptyState
title='No report yet'
description='Start an event to record actual timings against the schedule.'
/>
</Panel.Section>
) : (
<>
<Panel.Section>
<ReportShowSummary rundownTitle={rundown.title} show={show} summary={summary} />
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
<ReportTable rows={combinedReport} groups={groups} />
</Panel.Section>
</>
)}
</Panel.Card>
</Panel.Section>
);
@@ -0,0 +1,156 @@
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
import {
createDelay,
createEvent,
createGroup,
dayInMs,
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
} from 'ontime-utils';
import {
formatOffset,
getCombinedReport,
getGroupReports,
getRunSummary,
getShowOffsets,
makeReportCSV,
} from '../reportSettings.utils';
function makeEvent(id: string, patch = {}) {
const event = createEvent({ id, title: id, ...patch });
if (!event) throw new Error('Failed to create test event');
return event;
}
function makeReport(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
return {
startedAt: 5 * MILLIS_PER_MINUTE,
startedAtDay: 1,
endedAt: 15 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
...patch,
};
}
describe('getCombinedReport()', () => {
it('uses the captured schedule and absolute day when calculating offsets', () => {
const entry = makeEvent('a', { timeStart: 0, duration: 99 * MILLIS_PER_MINUTE });
const rows = getCombinedReport({ a: makeReport() }, { a: entry }, ['a']);
expect(rows[0]).toMatchObject({
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledEnd: dayInMs + 5 * MILLIS_PER_MINUTE,
startOffset: 10 * MILLIS_PER_MINUTE,
endOffset: 10 * MILLIS_PER_MINUTE,
});
});
it('includes unplayed events but excludes skipped and non-event entries', () => {
const ran = makeEvent('ran');
const unplayed = makeEvent('unplayed', { timeStart: 20 * MILLIS_PER_MINUTE });
const skipped = makeEvent('skipped', { skip: true });
const delay = createDelay({ id: 'delay' });
const report: OntimeReport = { ran: makeReport({ scheduledStart: 0, scheduledDay: 0 }) };
const rows = getCombinedReport(report, { ran, unplayed, skipped, delay }, ['ran', 'unplayed', 'skipped', 'delay']);
expect(rows.map(({ id }) => id)).toEqual(['ran', 'unplayed']);
expect(rows[1]).toMatchObject({ scheduledStart: unplayed.timeStart, actualStart: null, actualEnd: null });
});
});
describe('report calculations', () => {
it('keeps finishing time separate from running time', () => {
const offsets = getShowOffsets({
plannedStart: 19 * MILLIS_PER_HOUR,
plannedEnd: 21 * MILLIS_PER_HOUR,
plannedDuration: 2 * MILLIS_PER_HOUR,
actualStart: 19 * MILLIS_PER_HOUR - 10 * MILLIS_PER_MINUTE,
actualEnd: 21 * MILLIS_PER_HOUR - 6 * MILLIS_PER_MINUTE,
actualDuration: 2 * MILLIS_PER_HOUR + 4 * MILLIS_PER_MINUTE,
});
expect(offsets).toMatchObject({
startOffset: -10 * MILLIS_PER_MINUTE,
endOffset: -6 * MILLIS_PER_MINUTE,
durationOffset: 4 * MILLIS_PER_MINUTE,
});
});
it('measures completed groups against their target', () => {
const group = createGroup({ id: 'group', entries: ['a', 'b'], targetDuration: 30 * MILLIS_PER_MINUTE });
const entries = {
group,
a: makeEvent('a', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }),
b: makeEvent('b', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }),
};
const report: OntimeReport = {
a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 10 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
b: makeReport({
startedAt: 15 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 25 * MILLIS_PER_MINUTE,
endedAtDay: 0,
}),
};
expect(getGroupReports(report, entries, [group.id])[0]).toMatchObject({
elapsed: 25 * MILLIS_PER_MINUTE,
variance: -5 * MILLIS_PER_MINUTE,
eventsRun: 2,
eventsPlanned: 2,
});
expect(getGroupReports({ a: report.a }, entries, [group.id])[0].variance).toBeNull();
});
it('summarises completed events and excludes skipped events from the plan', () => {
const entries = { a: makeEvent('a'), b: makeEvent('b', { skip: true }) };
const report = {
a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 15 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
b: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 30 * MILLIS_PER_MINUTE, endedAtDay: 0 }),
};
expect(getRunSummary(report, entries, ['a', 'b'])).toEqual({ eventsRun: 2, eventsPlanned: 1 });
});
});
describe('report formatting', () => {
it.each([
[null, ''],
[MILLIS_PER_SECOND / 2, 'On time'],
[4 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND, '+4m12s'],
[-MILLIS_PER_MINUTE, '-1m'],
])('formats offset %s', (value, expected) => {
expect(formatOffset(value)).toBe(expected);
});
it('exports group context and leaves missing actual times empty', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
parent: 'act1',
groupTitle: 'Act 1',
scheduledStart: 0,
scheduledEnd: 10 * MILLIS_PER_MINUTE,
actualStart: null,
startOffset: null,
actualEnd: null,
endOffset: null,
},
]);
const fields = csv.trim().split('\n')[1].split(',');
expect(csv).toContain('Group');
expect(fields[1]).toBe('Act 1');
expect(fields[5]).toBe('');
expect(fields[7]).toBe('');
});
});
@@ -0,0 +1,104 @@
// Panel.Table nests its own padding inside the section it sits in, so the
// summary takes the same inset to keep one left edge down the whole panel
.inset {
padding: 0 var(--panel-card-padding, 2rem);
}
.summary {
padding: 1.25rem;
background-color: $gray-1200;
border-radius: 3px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.title {
margin: 0;
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.body {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem 2.5rem;
}
.headline {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.headlineLabel,
.metricLabel {
color: $gray-300;
font-size: calc(1rem - 2px);
}
.headlineValue {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.unavailable {
max-width: 32rem;
color: $warning-orange;
font-size: calc(1rem - 2px);
line-height: 1.4;
}
.metrics {
display: grid;
grid-template-columns: auto auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
}
.metricValue,
.metricOffset {
margin: 0;
font-variant-numeric: tabular-nums;
}
.metricValue {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.planned,
.arrow {
color: $gray-300;
}
.actual {
color: $ui-white;
font-weight: 600;
}
.metricOffset {
justify-self: end;
font-weight: 600;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $gray-300;
}
@@ -0,0 +1,118 @@
import type { MaybeNumber, ShowReport } from 'ontime-types';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import { formatOffset, getShowOffsets, offsetTone } from '../reportSettings.utils';
import type { RunSummary } from '../reportSettings.utils';
import style from './ReportShowSummary.module.scss';
interface ReportShowSummaryProps {
rundownTitle: string;
show: ShowReport;
summary: RunSummary;
}
/**
* Leads the report with whether the show ran to the length it was planned for.
*
* Running time is the headline rather than finishing time because it is the
* part the team controls and the part that carries into the next run of the
* same rundown. Finishing time is the other question a report is asked, and
* the two can point opposite ways, so it stays beside it as its own row
* rather than being folded into a single figure.
*/
export default function ReportShowSummary({ rundownTitle, show, summary }: ReportShowSummaryProps) {
const offsets = getShowOffsets(show);
/**
* A show that stopped early has no meaningful end: its last event is simply
* where it got to. Measuring that against the plan would report a show which
* never finished as having come in comfortably short.
*/
const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned;
const hasPlan = offsets.startOffset !== null;
return (
<div className={style.inset}>
<section className={style.summary} aria-labelledby='report-summary-title'>
<h4 id='report-summary-title' className={style.title}>
{rundownTitle || 'Untitled rundown'}
</h4>
<div className={style.body}>
<div className={style.headline}>
<span className={style.headlineLabel}>{didReachEnd ? 'Show duration' : 'Show incomplete'}</span>
{didReachEnd && offsets.durationOffset !== null ? (
<span className={cx([style.headlineValue, style[offsetTone(offsets.durationOffset)]])}>
{formatOffset(offsets.durationOffset)}
</span>
) : (
<span className={style.unavailable}>The show did not reach the end of the rundown.</span>
)}
</div>
<dl className={style.metrics}>
{hasPlan && (
<Metric
label='Started'
planned={formatMaybeTime(show.plannedStart)}
actual={formatMaybeTime(show.actualStart)}
offset={offsets.startOffset}
/>
)}
{hasPlan && didReachEnd && (
<Metric
label='Ended'
planned={formatMaybeTime(show.plannedEnd)}
actual={formatMaybeTime(show.actualEnd)}
offset={offsets.endOffset}
/>
)}
{didReachEnd && (
<Metric
label='Duration'
planned={formatMaybeDuration(show.plannedDuration)}
actual={formatMaybeDuration(show.actualDuration)}
/>
)}
</dl>
</div>
</section>
</div>
);
}
function Metric({
label,
planned,
actual,
offset,
}: {
label: string;
planned: string;
actual: string;
offset?: MaybeNumber;
}) {
return (
<>
<dt className={style.metricLabel}>{label}</dt>
<dd className={style.metricValue}>
<span className={style.planned}>{planned}</span>
<span className={style.arrow}></span>
<span className={style.actual}>{actual}</span>
</dd>
<dd className={cx([style.metricOffset, offset !== undefined && style[offsetTone(offset)]])}>
{offset === undefined ? '' : formatOffset(offset)}
</dd>
</>
);
}
function formatMaybeTime(value: MaybeNumber): string {
return value === null ? enDash : formatTime(value);
}
function formatMaybeDuration(value: MaybeNumber): string {
return value === null ? enDash : formatDuration(value, false);
}
@@ -0,0 +1,144 @@
$rail: var(--user-bg, #{$gray-500});
$event-wash: var(--event-bg, transparent);
// event rows carry their values in td, not th: the panel's th styling is meant
// for column headings and renders whatever it holds small, bold and upper case
td.over {
color: $playback-over;
}
td.under {
color: $playback-under;
}
.eventRow td {
background-color: color-mix(in srgb, #{$gray-1300} 96%, #{$event-wash} 4%);
}
.groupedRow td:first-child {
box-shadow: inset 2px 0 $rail;
// clear the rail rather than sitting against it
padding-left: 0.75rem;
}
.groupRow > * {
background-color: color-mix(in srgb, #{$gray-1300} 88%, #{$rail} 12%);
vertical-align: top;
}
.groupSpacer {
height: 0.75rem;
background: transparent !important;
td {
height: 0.75rem;
padding: 0;
background: transparent !important;
}
}
th.groupSummary {
padding: 0.5rem 1rem;
box-shadow: inset 4px 0 $rail;
text-align: left;
text-transform: none;
letter-spacing: normal;
> * {
text-transform: none;
}
}
.groupTitle,
.groupLabel,
.groupValues {
display: block;
}
.groupTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
text-transform: none;
}
.groupBody {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 2.5rem;
margin-top: 0.75rem;
}
.groupHeadline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.groupLabel,
.groupMetrics dt {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.groupHeadlineValue {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.groupMetrics {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
font-variant-numeric: tabular-nums;
}
.groupMetrics dd {
margin: 0;
}
.groupValues {
display: flex;
gap: 0.5rem;
color: $gray-300;
b {
color: $ui-white;
font-weight: 600;
}
}
.unavailable {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.arrow {
color: $gray-500;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $ui-white;
}
.eventCue,
.eventIndex {
color: $gray-300;
}
@@ -0,0 +1,182 @@
import type { EntryId } from 'ontime-types';
import { useMemo } from 'react';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import { formatOffset, offsetTone } from '../reportSettings.utils';
import type { CombinedReport, GroupReport } from '../reportSettings.utils';
import style from './ReportTable.module.scss';
interface ReportTableProps {
rows: CombinedReport[];
groups: GroupReport[];
}
type ReportColourStyle = React.CSSProperties & Partial<Record<'--event-bg' | '--user-bg', string | undefined>>;
/**
* The report laid out the way the show was planned: blocks, then the events
* inside them. Each block carries how it ran against the budget set for it.
*/
export default function ReportTable({ rows, groups }: ReportTableProps) {
// groups are rendered where their first event appears, so the table follows
// the rundown rather than a separate ordering
const sections = useMemo(() => makeSections(rows, groups), [rows, groups]);
return (
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
{sections.map((section, index) => (
<tbody key={section.key}>
{section.group && index > 0 && <GroupSpacer />}
{section.group && <GroupRow group={section.group} />}
{section.rows.map((entry) => (
<EventRow
key={entry.id}
entry={entry}
groupColour={section.group?.colour}
grouped={section.group !== null}
/>
))}
</tbody>
))}
</Panel.Table>
);
}
function GroupSpacer() {
return (
<tr aria-hidden='true' className={style.groupSpacer}>
<td colSpan={7} />
</tr>
);
}
/**
* A group read the same way as the show above it: what it was measured
* against, what it actually did, and how much of it ran.
*/
function GroupRow({ group }: { group: GroupReport }) {
const hasTarget = group.targetDuration !== null;
const measuredAgainst = group.targetDuration ?? group.scheduledDuration;
const unavailableReason = group.eventsRun === 0 ? 'Did not run' : 'Still running';
return (
<tr className={style.groupRow} style={groupColourStyle(group.colour)}>
<th scope='rowgroup' colSpan={7} className={style.groupSummary}>
<span className={style.groupTitle}>{group.title || 'Untitled group'}</span>
<div className={style.groupBody}>
<div className={style.groupHeadline}>
<span className={style.groupLabel}>{hasTarget ? 'Against target' : 'Against schedule'}</span>
{group.variance === null ? (
<span className={style.unavailable}>{unavailableReason}</span>
) : (
<span className={cx([style.groupHeadlineValue, style[offsetTone(group.variance)]])}>
{formatOffset(group.variance)}
</span>
)}
</div>
<dl className={style.groupMetrics}>
<dt>{hasTarget ? 'Target' : 'Scheduled'}</dt>
<dd className={style.groupValues}>
<span>{formatDuration(measuredAgainst, false)}</span>
<span className={style.arrow}></span>
<b>{group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}</b>
</dd>
{group.actualStart !== null && group.actualEnd !== null && (
<>
<dt>Ran</dt>
<dd className={style.groupValues}>
<span>{formatTime(group.actualStart)}</span>
<span className={style.arrow}></span>
<b>{formatTime(group.actualEnd)}</b>
</dd>
</>
)}
</dl>
</div>
</th>
</tr>
);
}
function EventRow({ entry, groupColour, grouped }: { entry: CombinedReport; groupColour?: string; grouped: boolean }) {
const start = offsetTone(entry.startOffset);
const end = offsetTone(entry.endOffset);
return (
<tr className={cx([style.eventRow, grouped && style.groupedRow])} style={eventColours(entry.colour, groupColour)}>
<td className={style.eventIndex}>{entry.index}</td>
<td className={style.eventCue}>{entry.cue}</td>
<td>{entry.title}</td>
<td>{formatTime(entry.scheduledStart)}</td>
<td className={cx([style[start]])}>{formatTime(entry.actualStart)}</td>
<td>{formatTime(entry.scheduledEnd)}</td>
<td className={cx([style[end]])}>{formatTime(entry.actualEnd)}</td>
</tr>
);
}
/**
* Uses the cuesheet's custom property for group colour. Left unset when the
* group has none so the stylesheet can provide a neutral edge.
*/
function groupColourStyle(colour?: string): ReportColourStyle {
const style: ReportColourStyle = {};
if (colour) style['--user-bg'] = colour;
return style;
}
/** Keeps the event wash distinct from its parent group's identifying rail. */
function eventColours(eventColour: string, groupColour?: string): ReportColourStyle {
const style: ReportColourStyle = {};
if (eventColour) style['--event-bg'] = eventColour;
if (groupColour) style['--user-bg'] = groupColour;
return style;
}
type Section = {
key: string;
group: GroupReport | null;
rows: CombinedReport[];
};
/**
* Splits the rows into the blocks they belong to, keeping rundown order and
* leaving ungrouped events in their own run of rows.
*/
function makeSections(rows: CombinedReport[], groups: GroupReport[]): Section[] {
const byId = new Map<EntryId, GroupReport>(groups.map((group) => [group.id, group]));
const sections: Section[] = [];
let current: Section | null = null;
let currentParent: EntryId | null | undefined;
for (const row of rows) {
if (current === null || row.parent !== currentParent) {
currentParent = row.parent;
// index keeps the key unique even if a group were to appear twice
current = {
key: `${row.parent ?? 'ungrouped'}-${sections.length}`,
group: row.parent ? (byId.get(row.parent) ?? null) : null,
rows: [],
};
sections.push(current);
}
current.rows.push(row);
}
return sections;
}
@@ -1,89 +1,241 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import type { EntryId, MaybeNumber, OntimeGroup, OntimeReport, RundownEntries, ShowReport } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
/** the group this event belongs to, so the report can mirror the rundown */
parent: EntryId | null;
groupTitle: string;
scheduledStart: number;
actualStart: MaybeNumber;
startOffset: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
endOffset: MaybeNumber;
};
type ShowOffsets = {
startOffset: MaybeNumber;
endOffset: MaybeNumber;
durationOffset: MaybeNumber;
};
export type GroupReport = {
id: EntryId;
title: string;
colour: string;
targetDuration: MaybeNumber;
scheduledDuration: number;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
elapsed: MaybeNumber;
variance: MaybeNumber;
eventsRun: number;
eventsPlanned: number;
};
export type RunSummary = {
eventsRun: number;
eventsPlanned: number;
};
/**
* Creates a combined report with the rundown data
* Creates a combined report with the rundown data.
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/
export function getCombinedReport(
report: OntimeReport,
rundown: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
if (Object.keys(report).length === 0 || flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
let index = 1;
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
for (const id of flatOrder) {
const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue;
// skipped events were never meant to run, listing them alongside events
// that did would also disagree with the summary, which excludes them
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
const parent = entry.parent;
const group = parent ? rundown[parent] : undefined;
const reported = report[id];
const scheduledStart = reported?.scheduledStart ?? entry.timeStart;
const scheduledDay = reported?.scheduledDay ?? entry.dayOffset;
const scheduledStartPosition = getReportTimePosition(scheduledStart, scheduledDay);
const actualStartPosition = reported ? getReportTimePosition(reported.startedAt, reported.startedAtDay) : null;
const actualEndPosition = reported ? getReportTimePosition(reported.endedAt, reported.endedAtDay) : null;
const scheduledDuration = reported?.scheduledDuration ?? entry.duration;
if (!(id in report)) {
combinedReport.push({
id: id,
index: index,
id,
index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
colour: entry.colour,
parent,
groupTitle: group && isOntimeGroup(group) ? group.title : '',
// an event that ran is measured against the plan it ran on, one that
// did not has no snapshot and falls back to the rundown
scheduledStart,
scheduledEnd: scheduledStart + scheduledDuration,
actualStart: reported?.startedAt ?? null,
startOffset: getOffset(actualStartPosition, scheduledStartPosition),
actualEnd: reported?.endedAt ?? null,
endOffset: getOffset(actualEndPosition, scheduledStartPosition + scheduledDuration),
});
}
if (id in report) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: report[id].endedAt,
scheduledEnd: entry.timeEnd,
actualStart: report[id].startedAt,
});
}
index++;
}
return combinedReport;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
function getOffset(actual: MaybeNumber, scheduled: number): MaybeNumber {
return actual === null ? null : actual - scheduled;
}
export function getShowOffsets(show: ShowReport): ShowOffsets {
const { plannedDuration, actualDuration } = show;
return {
startOffset: getWallClockOffset(show.plannedStart, show.actualStart),
endOffset: getWallClockOffset(show.plannedEnd, show.actualEnd),
durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration,
};
}
function getWallClockOffset(planned: MaybeNumber, actual: MaybeNumber): MaybeNumber {
if (planned === null || actual === null) return null;
const offset = actual - planned;
if (offset < -dayInMs / 2) return offset + dayInMs;
if (offset > dayInMs / 2) return offset - dayInMs;
return offset;
}
export function getGroupReports(report: OntimeReport, entries: RundownEntries, order: EntryId[]): GroupReport[] {
const groups: GroupReport[] = [];
for (const id of order) {
const group = entries[id];
if (group && isOntimeGroup(group)) groups.push(getGroupReport(group, report, entries));
}
return groups;
}
function getGroupReport(group: OntimeGroup, report: OntimeReport, entries: RundownEntries): GroupReport {
let scheduledDuration = 0;
let eventsPlanned = 0;
let eventsRun = 0;
let firstStart = Number.POSITIVE_INFINITY;
let lastEnd = Number.NEGATIVE_INFINITY;
let actualStart: MaybeNumber = null;
let actualEnd: MaybeNumber = null;
for (const childId of group.entries) {
const child = entries[childId];
if (!child || !isOntimeEvent(child) || child.skip) continue;
eventsPlanned += 1;
const reported = report[childId];
scheduledDuration += reported?.scheduledDuration ?? child.duration;
const variance = getEventVariance(reported);
if (variance.actualDuration === null || !reported) continue;
eventsRun += 1;
const start = getReportTimePosition(reported.startedAt, reported.startedAtDay);
const end = getReportTimePosition(reported.endedAt, reported.endedAtDay);
if (start !== null && start < firstStart) {
firstStart = start;
actualStart = reported.startedAt;
}
if (end !== null && end > lastEnd) {
lastEnd = end;
actualEnd = reported.endedAt;
}
}
const elapsed = actualStart === null || actualEnd === null ? null : lastEnd - firstStart;
const measuredAgainst = group.targetDuration ?? scheduledDuration;
const isComplete = eventsRun > 0 && eventsRun === eventsPlanned;
return {
id: group.id,
title: group.title,
colour: group.colour,
targetDuration: group.targetDuration,
scheduledDuration,
actualStart,
actualEnd,
elapsed,
variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst,
eventsRun,
eventsPlanned,
};
}
export function getRunSummary(report: OntimeReport, entries: RundownEntries, order: EntryId[]): RunSummary {
const eventsPlanned = order.filter((id) => {
const entry = entries[id];
return entry && isOntimeEvent(entry) && !entry.skip;
}).length;
const eventsRun = Object.values(report).filter((entry) => getEventVariance(entry).status !== 'not-run').length;
return { eventsRun, eventsPlanned };
}
/**
* Transforms a CombinedReport into a CSV string
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that
* positive means behind schedule.
*/
export function formatOffset(value: MaybeNumber): string {
if (value === null) return enDash;
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
}
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
return value > 0 ? 'over' : 'under';
}
function formatCsvTime(value: MaybeNumber): string {
return value === null ? '' : formatTime(value);
}
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string.
*
* Exported as one row per event with its group named, rather than with
* rollups baked in, so it stays the dataset a report is built from.
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv: string[][] = [];
csv.push(csvHeader);
for (const entry of combinedReport) {
csv.push([
const csv = combinedReport.map((entry) => [
String(entry.index),
entry.title,
entry.groupTitle,
entry.cue,
entry.title,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatCsvTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
formatCsvTime(entry.actualEnd),
]);
}
return makeCSVFromArrayOfArrays(csv);
return makeCSVFromArrayOfArrays([csvHeader, ...csv]);
}
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { data: currentRundown } = useLoadedRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -1,95 +0,0 @@
import { Settings } from 'ontime-types';
import { auxTimerNameMaxLength } from 'ontime-utils';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AuxTimerSettings() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, errors },
} = useForm<Settings>({
defaultValues: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='aux-timer-settings'
>
<Panel.Card>
<Panel.SubHeader>
Aux timers
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -3,7 +3,6 @@ import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import AuxTimerSettings from './AuxTimerSettings';
import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData';
@@ -13,7 +12,6 @@ import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
@@ -28,9 +26,6 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={generalRef}>
<GeneralSettings />
</div>
<div ref={auxTimersRef}>
<AuxTimerSettings />
</div>
<div ref={viewRef}>
<ViewSettings />
</div>
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime
Shutdown Ontime
</Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section>
@@ -24,7 +24,6 @@ const staticOptions = [
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
},
{ id: 'settings__aux-timers', label: 'Aux timers', keywords: ['aux', 'auxiliary'] },
{
id: 'settings__view',
label: 'View settings',
@@ -3,34 +3,6 @@
margin: 0 auto;
}
.auxHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.auxHeaderButtons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.activeIndicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
}
.auxTimers {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1,10 +1,4 @@
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
@@ -14,9 +8,6 @@ import style from './PlaybackControl.module.scss';
export default function PlaybackControl() {
const data = usePlaybackControl();
const { setLocation } = useAppSettingsNavigation();
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
const isAuxTimerActive = useAuxTimersActive();
return (
<div className={style.mainContainer}>
@@ -29,42 +20,11 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<div className={style.auxHeader}>
<span className={style.label}>
Aux timers
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
</span>
<div className={style.auxHeaderButtons}>
<Tooltip
text='Name aux timers'
render={
<IconButton
size='small'
variant='subtle-white'
aria-label='Name aux timers'
onClick={() => setLocation('settings__aux-timers')}
/>
}
>
<IoSettingsOutline />
</Tooltip>
<IconButton
size='small'
variant='subtle-white'
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
>
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
</div>
{!auxTimersCollapsed && (
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
)}
</div>
);
}
@@ -1,18 +1,8 @@
.label {
display: block;
margin-top: 1rem;
// aux timers sit in a 3 column grid, without this a long name would grow its column
// instead of shrinking to it, breaking the equal column layout
min-width: 0;
}
.labelText {
display: block;
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
@@ -3,9 +3,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -15,12 +13,10 @@ interface AuxTimerProps {
}
export function AuxTimer({ index }: AuxTimerProps) {
const { playback, direction, name } = useAuxTimerControl(index);
const { playback, direction } = useAuxTimerControl(index);
const { stop, setDirection } = setAuxTimer;
const label = getAuxTimerIndexedLabel(name, index);
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(index, newDirection);
@@ -31,12 +27,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
return (
<label className={style.label}>
<Tooltip text={label} render={<span />} className={style.labelText}>
{label}
</Tooltip>
Aux Timer {index}
<div className={style.controls}>
<div className={style.input}>
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
<AuxTimerInput index={index} isActive={isActive} />
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
@@ -56,10 +50,9 @@ export function AuxTimer({ index }: AuxTimerProps) {
interface AuxTimerInputProps {
index: number;
isActive: boolean;
placeholder: string;
}
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer;
@@ -77,7 +70,7 @@ function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
}
return (
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
);
}
+19 -30
View File
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -55,72 +56,60 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<Button
variant={showUser ? 'primary' : 'subtle'}
<ToggleButton
pressed={showUser}
size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showClient}
size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showServer}
size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showPlayback}
size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showRx}
size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
</ToggleButton>
<ToggleButton
pressed={showTx}
size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</Button>
</ToggleButton>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
@@ -18,6 +18,12 @@
padding-bottom: 95vh;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+25 -12
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,7 +25,10 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
const selectedOffset = 50;
/** Keeps the running event clear of the list edge when no group header is pinned above it */
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() {
const { data, status } = useOperatorData();
@@ -54,11 +57,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// The header height varies with the viewport, so measure it at scroll time.
const getTopOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Sit right under the pinned header, so it covers the previous event instead of half of it.
return header ? header.offsetHeight + 2 : edgeOffset;
}, []);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
getTopOffset,
followTrigger: selectedEventId,
});
@@ -82,15 +94,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) {
if (!selectedRef.current || !scrollRef.current) {
return;
}
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
// Measure the drift from where an automated scroll would place the event.
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
};
const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -186,9 +199,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<Fragment key={entry.id}>
<div className={style.groupSection} key={entry.id}>
<OperatorGroup
key={entry.id}
ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
@@ -239,7 +252,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</Fragment>
</div>
);
}
return null;
@@ -1,10 +1,13 @@
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import Operator from './Operator';
export default function OperatorExport() {
return (
<ProtectRoute permission='operator'>
<EditableRundownScopeProvider rundownId={null}>
<Operator />
</EditableRundownScopeProvider>
</ProtectRoute>
);
}
@@ -1,13 +1,20 @@
.group {
width: 100%;
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
while a taller title still grows the row. */
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
padding: 0.25rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem;
font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
@@ -1,4 +1,4 @@
import { CSSProperties, memo } from 'react';
import { type CSSProperties, type Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
@@ -10,15 +10,16 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import {
useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const group = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const entry = useLoadedEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData();
const { data: rundownData } = useLoadedRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+5 -1
View File
@@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -39,11 +36,9 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
@@ -60,7 +55,6 @@ function RundownExport() {
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -70,7 +64,6 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
@@ -93,7 +86,6 @@ function RundownExport() {
</div>
</div>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -105,8 +97,6 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog />
</div>
);
@@ -1,15 +1,22 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList);
function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -15,7 +15,7 @@
.triggerHeader {
display: grid;
grid-template-columns: 8rem 1fr 2rem;
grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: $aux-text-size;
@@ -25,7 +25,7 @@
.trigger {
padding: 0.5rem 0.75rem;
display: grid;
grid-template-columns: 8rem 1fr 2rem;
grid-template-columns: 8rem 1fr auto 2rem;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
@@ -41,6 +41,12 @@
}
}
.outputTags {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.duplicateMessage {
padding-left: 0.75rem;
font-size: $aux-text-size;
@@ -6,8 +6,11 @@ import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
label: title,
}));
const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
const duplicateIds = new Set<string>();
const seen = new Map<string, string>();
@@ -76,8 +79,10 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
<div className={style.triggerHeader}>
<span>Lifecycle</span>
<span>Automation</span>
<span>Sends</span>
</div>
{triggers.map((trigger) => {
const automation = automationSettings.automations[trigger.automationId];
const isDuplicate = duplicateIds.has(trigger.id);
const lifecycleOptions = isDuplicate
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
@@ -103,6 +108,15 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
}}
options={automationOptions}
/>
<div className={style.outputTags}>
{automation ? (
summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
) : (
<Tag variant='warning'>Missing automation</Tag>
)}
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<IoTrash />
</IconButton>
@@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
@@ -26,6 +27,7 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -43,12 +45,17 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
@@ -65,7 +72,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -74,7 +81,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.then(() => setEntryCopyId(null, null))
.catch(() => {});
return;
}
@@ -85,7 +92,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
[entries, flatOrder, clone, reorderEntry, rundownId],
);
/**
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
/**
* The empty tagsToIgnore is significant: by default the hook skips input elements,
* which would make the shortcut dead while editing an entry.
*
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick}
onContextMenu={onContextMenu}
data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})}
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -6,6 +6,7 @@ import { IoCheckmarkCircle } from 'react-icons/io5';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { usePlayback } from '../../../../common/hooks/useSocket';
import { getEventVariance } from '../../../../common/utils/report';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -20,7 +21,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +33,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,11 +44,9 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
return <EventReport className={className} id={id} />;
}
if (playbackActive) {
// we extracted the component to avoid unnecessary calculations and re-renders
return (
<Tooltip text='Expected time until start' render={<span />} className={className}>
<EventUntil
@@ -63,9 +60,6 @@ export default function RundownEventChip({
);
}
return null;
}
interface EventUntilProps {
timeStart: number;
delay: number;
@@ -86,41 +80,30 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
function EventReport({ className, id }: EventReportProps) {
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
// Use the schedule recorded when the event ran so later rundown edits do
// not change its report.
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
if (variance.status === 'ontime') {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${millisToString(absDifference)}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [value, variance.status, tooltip];
}, [currentReport]);
if (!value) {
return null;
@@ -1,9 +1,6 @@
import { memo, useEffect, useMemo } from 'react';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
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 CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -15,8 +12,6 @@ function RundownTable() {
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -32,10 +27,8 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -1,155 +1,15 @@
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useStore } from 'zustand';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
* Selection and cursor for the rundown of the enclosing scope
*/
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
const { selectionStore } = useRundownScope();
return useStore(selectionStore, selector);
}
@@ -1,6 +1,7 @@
import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
@@ -10,7 +11,8 @@ type SelectAndRevealOptions = {
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);

Some files were not shown because too many files have changed in this diff Show More