Compare commits

..

9 Commits

Author SHA1 Message Date
Carlos Valente 7089abbb53 fix(ui): give the preview a fixed shape and top-align the buttons beside it
The stage was stretched to whatever height the panel had free via a 1fr grid
row, so its size varied with how much vertical room the editor happened to
leave rather than staying predictable. It now keeps a 16:9 box sized from
its own width - the same ratio the embedded timer's font-size estimate
already assumes - so cqw and cqh agree and the min() cap in PipTimer stays
meaningful only for the pip window, which is still freely resizable.

The button column no longer stretches or centers against that height either:
align-items: start on the group top-aligns both the stage and the buttons
when they sit side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:42:29 +00:00
Carlos Valente 99f8f4885c feat(ui): make blink/blackout exclusive and put the buttons beside the preview
Blink and blackout are two ways of hiding the same timer, and having both on
does not mean anything the audience can read differently from either alone.
Turning one on now turns the other off, enforced where the two toggles are
sent so the invariant holds regardless of which control calls it.

The screen buttons move from a row below the stage to a column on its right,
which is where they act and costs less height than a row. The stage and the
button column live in a grid so the buttons can be a fixed, content-sized
width instead of stretching to match the stage's height. A container query
against the panel's own width collapses back to a stacked layout, buttons in
a row below the stage, for a narrower fit (a resized /messagecontrol window,
for instance) where the two can't sit side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:36:52 +00:00
Carlos Valente c4ac9df8cf refactor(ui): overlay the status icons and let the preview fill the panel
Three adjustments to the panel layout:

- the timer status icons go back to overlaying the stage, which returns the
  row they were costing. They sit in the same chrome layer as the pop-out
  buttons, so the blackout no longer hides them
- the screen state buttons group with the preview they act on, and the gap
  before the message inputs widens to separate the two
- the stage drops its fixed aspect ratio and fills the panel width, which
  also removes the slack a tall panel used to leave around it

Without a 16:9 frame the width derived font size can overflow a short, wide
stage, so the timer is now capped by container height at the ratio the
estimate assumes. In the pip window, which has no ancestor query container,
both terms resolve against the viewport, so a 16:9 window is unchanged and a
wider one stops clipping its digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:30:28 +00:00
Carlos Valente 0d5228f1e5 fix(ui): make the message preview match the stage it stands in for
Three things measured wrong once the preview showed a real render:

- the pip timer kept leading zeros where the timer view drops them by
  default, so the preview read 00:59:07 against the stage's 59:07 - and its
  own secondary line already dropped them
- the extracted /messagecontrol route has no flex parent for the panel to
  grow into, so the stage collapsed to its minimum instead of filling the
  window
- a tall panel caps the stage at its own width, and the slack it leaves now
  sits either side of the stage rather than all below it

Covers the screen state controls in the e2e spec: the secondary source
select puts the typed text on the stage, blackout reaches the timer view,
and clear screen resets the state while keeping the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:04:23 +00:00
Carlos Valente fae9e030a3 feat(ui): add a clear screen control to message control
Recovering from "message up, blinking, secondary on" took four clicks in
three places, which is the wrong shape for something done under pressure.
One control now returns the stage to a plain timer in a single patch,
keeping whatever the operator has typed. It is disabled while nothing is
active, so an accidental click is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:40:01 +00:00
Carlos Valente 03fa62f3a1 feat(ui): fit the message control panel on a small laptop
The panel absorbs whatever height the playback control above it leaves, and
its content was a fixed stack taller than that space on a 1280x800 screen -
with the container set to hide overflow, the secondary message input was
silently clipped off the bottom.

The stage preview is now the only elastic element and the controls are the
fixed floor, which is the way round an operator needs it. The control stack
also gets shorter: blink and blackout share a row, and the secondary source
select moves next to the secondary text input.

That regrouping removes the duelling owners of `secondarySource`. The select
picks the source and the eye toggles the line on and off, so the "Show
secondary" button - which wrote the same field from a second place, and made
picking an aux read as "secondary message hidden" - is gone along with the
local mirror of the remote state it needed.

Also: blackout now uses the destructive button variant, the toggles expose
`aria-pressed`, and the input rows use the shared editor label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:39:02 +00:00
Carlos Valente f208f148b7 refactor(ui): move the timer status icons off the stage preview
The icon rail was absolutely positioned over the same box as the preview
content, so at narrow widths it collided with the render - and now that the
preview shows a real timer, it also sat under the blackout overlay. It moves
into the options column as a compact row, where it reads as editor chrome
rather than part of the stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:33:32 +00:00
Carlos Valente 60cdf45c36 feat(ui): render a live timer in the message control preview
The preview showed the word "Timer" where the timer goes, so it could not
tell an operator what the audience will actually see. It now embeds the same
component the picture-in-picture window uses.

Making PipTimer embeddable also fixes the pop-out window, which rendered a
running timer while the stage was blacked out and did not blink with the
stage. The timer font size, the message padding and the overtime outline move
from viewport to container units: inside the preview they resolve against the
stage frame, and in the pip window - which has no ancestor query container -
they resolve against its viewport exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:32:27 +00:00
Carlos Valente 2ef966fc14 fix(ui): restore blink and live-label feedback in message control
The blink modifier in the timer preview referenced a CSS module class that
does not exist, so `style.blink` resolved to undefined and the preview never
blinked. `.blink` is a global animation class, matching how the timer view
applies it.

The "message is live" label highlight used `??` where `&&` was meant: since
`visible` is always a boolean, the active style was unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:29:16 +00:00
174 changed files with 1915 additions and 4978 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.13.0",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -4
View File
@@ -7,15 +7,13 @@
<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.13.0",
"version": "4.12.0",
"private": true,
"type": "module",
"dependencies": {
+7 -9
View File
@@ -1,21 +1,19 @@
{
"name": "Ontime",
"short_name": "Ontime",
"name": "ontime",
"short_name": "ontime",
"icons": [
{
"src": "ontime-logo-192.png",
"sizes": "192x192",
"type": "image/png"
"src": "favicon.ico",
"type": "image/x-icon"
},
{
"src": "ontime-logo-512.png",
"sizes": "512x512",
"src": "ontime-logo.png",
"type": "image/png"
}
],
"scope": "./",
"start_url": "./",
"display": "standalone",
"theme_color": "#101010",
"display": "",
"theme_color": "#121212",
"background_color": "#101010"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
+1 -4
View File
@@ -3,7 +3,6 @@ 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';
@@ -113,9 +112,7 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
<RundownPanel />
</EditorFeatureWrapper>
}
/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,25 +0,0 @@
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,14 +22,6 @@ 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`;
+8 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ReportData } from 'ontime-types';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -8,13 +8,18 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch the report
* HTTP request to fetch all reports
*/
export async function fetchReport(options?: RequestOptions): Promise<ReportData> {
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
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,15 +18,6 @@ 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);
}
@@ -1,18 +0,0 @@
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} />;
}
@@ -1,13 +0,0 @@
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');
});
});
@@ -1,17 +0,0 @@
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;
}
@@ -1,24 +0,0 @@
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>;
}
@@ -1,77 +0,0 @@
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;
}
@@ -1,81 +0,0 @@
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,34 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import type { ReportData } from 'ontime-types';
import { OntimeReport } 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: report, refetch } = useQuery<ReportData>({
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData) => previousData,
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return {
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
return { data: data ?? {}, refetch };
}
+100 -31
View File
@@ -1,38 +1,62 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useRundownScope } from '../context/RundownScopeContext';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
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,
};
/**
* Normalised rundown data for the rundown of the enclosing scope
* 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`.
*/
export default function useRundown() {
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
/**
* 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;
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 };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
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],
);
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -42,23 +66,41 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId();
const selectedEventId = useSelectedEventId();
const { entries, flatOrder } = data;
const rundownWithMetadata = useMemo(
() => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
[entries, flatOrder, selectedEventId],
);
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, 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
*/
@@ -68,3 +110,30 @@ 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 };
}
@@ -1,65 +0,0 @@
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);
}
@@ -0,0 +1,54 @@
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],
);
}
+18 -47
View File
@@ -8,6 +8,7 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -31,10 +32,9 @@ 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 { getRundownCacheKey } from '../api/constants';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,7 +52,6 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -65,38 +64,22 @@ 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 rundown of the enclosing scope.
* Gather utilities for actions on entries in the loaded rundown.
*/
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string) {
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -110,8 +93,12 @@ function useEntryActionsForRundown(scopedRundownId: string) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -338,10 +325,8 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
const previousEntry = newRundown[eventId];
if (previousEntry) {
newRundown[eventId] = patchEntry(previousEntry, newEvent);
}
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData<Rundown>(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -355,23 +340,6 @@ function useEntryActionsForRundown(scopedRundownId: string) {
// 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) {
@@ -544,7 +512,10 @@ function useEntryActionsForRundown(scopedRundownId: string) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = patchEntry(event, data.data);
newRundown[eventId] = {
...event,
...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 | null>,
scrollRef: RefObject<ScrollRef | null>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [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],
);
return scrollToRefComponent;
}
+17 -12
View File
@@ -22,10 +22,14 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null,
}));
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
export const useScreenControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
isScreenModified:
state.message.timer.visible ||
state.message.timer.blink ||
state.message.timer.blackout ||
state.message.timer.secondarySource !== null,
}));
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
@@ -33,17 +37,12 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible,
}));
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary,
visible: state.message.timer.secondarySource === 'secondary',
source: state.message.timer.secondarySource,
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
export const useTimerStatus = createSelector((state: RuntimeStore) => ({
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
@@ -52,10 +51,16 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
// blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
clearScreen: () =>
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
};
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `Ontime - ${title}`;
document.title = `ontime - ${title}`;
}, []);
}
@@ -1,64 +0,0 @@
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());
});
});
@@ -1,21 +1,14 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyId: string | null;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: string | null, 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: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
@@ -1,158 +0,0 @@
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';
}
@@ -1,32 +0,0 @@
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']);
});
});
@@ -1,23 +0,0 @@
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,16 +58,4 @@ 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');
});
});
@@ -1,26 +0,0 @@
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 }));
}
-29
View File
@@ -1,29 +0,0 @@
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);
// 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) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return;
}
@@ -30,8 +30,7 @@ $content-max-width: 1280px;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
// Own both scroll axes so sticky table headers stay anchored to the panel viewport.
overflow: auto;
overflow-y: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
@@ -5,8 +5,9 @@
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;
@@ -26,7 +27,10 @@
}
.titleSection,
.filterSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid;
grid-gap: 0.5rem;
@@ -37,7 +41,10 @@
.titleSection,
.ruleSection,
.filterSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
@@ -54,85 +61,26 @@
}
.filterSection {
grid-template-columns: minmax(11rem, 1.25fr) minmax(10rem, 1fr) minmax(13rem, 1.75fr) auto;
align-items: end;
grid-template-columns: 2fr 1fr 2fr auto;
}
@media (max-width: $min-tablet) {
.filterSection {
grid-template-columns: 1fr 1fr auto;
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr 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;
}
}
.card {
border: 1px solid $white-10;
.outputCard {
border-left: 0.25rem solid $gray-1200;
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;
padding-left: 0.5rem;
}
@@ -1,5 +1,14 @@
import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Automation,
AutomationDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5';
@@ -7,28 +16,25 @@ 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 { isOntimeCloud } from '../../../../externals';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import { isAutomation, makeFieldList } from './automationUtils';
import OntimeActionForm from './OntimeActionForm';
import OscOutputForm from './OscOutputForm';
import OutputCard, { type TestState } from './OutputCard';
import TemplateInput from './template-input/TemplateInput';
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;
@@ -40,15 +46,13 @@ 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,
@@ -89,28 +93,6 @@ 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: '' });
};
@@ -124,33 +106,57 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' });
};
const handleAddNewOntimeAction = () => {
const handleAddnewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' });
};
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;
}
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
reportTest(key, { status: 'sending' });
const handleTestOSCOutput = async (index: number) => {
try {
await testOutput(values);
reportTest(key, { status: 'ok', message: 'Request sent' });
} catch (error) {
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) {
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) {
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) => {
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 */
}
};
const onSubmit = async (values: AutomationDTO) => {
setSubmitError(undefined);
if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values });
} else {
@@ -163,7 +169,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await editAutomation(id, values);
onClose();
} catch (error) {
setSubmitError(maybeAxiosError(error));
setError('root', { message: maybeAxiosError(error) });
}
}
@@ -172,43 +178,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await addAutomation(values);
onClose();
} catch (error) {
setSubmitError(maybeAxiosError(error));
setError('root', { message: 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
@@ -227,7 +202,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Automation title'
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -248,9 +223,6 @@ 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}`;
@@ -292,7 +264,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ shouldDirty: true },
);
}}
options={operators}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
@@ -329,52 +305,150 @@ 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 />
<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>
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</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) => {
const cardProps = {
testState: testResults[output.id],
onTest: () => handleTest(index, output.id),
onDelete: () => removeOutput(index),
};
const rowErrors = getOutputErrors(index);
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<OutputCard
key={output.id}
label='OSC'
kindClass={style.tagOsc}
summary={watch(`outputs.${index}.address`)}
unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
{...cardProps}
>
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
<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)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
<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>
);
}
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 (
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
@@ -382,19 +456,44 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
/>
</OutputCard>
>
<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>
);
}
return null;
})}
{fieldOutputs.length > 0 && addOutputMenu}
<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>
</div>
</form>
}
footerElements={
<>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
@@ -94,8 +94,7 @@ export default function AutomationSettingsForm({
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</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>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
@@ -1,3 +0,0 @@
.table {
min-width: 36rem;
}
@@ -9,12 +9,9 @@ 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',
@@ -63,18 +60,19 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section>
{enabledAutomations === false && (
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
)}
<Panel.Table className={style.table}>
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Sends</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
@@ -82,7 +80,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='Create a reusable definition, then attach it to a global or an event trigger.'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
@@ -102,15 +100,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.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>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -1,34 +0,0 @@
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,11 +1,10 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
import { useState } from 'react';
import { PropsWithChildren, 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';
@@ -13,7 +12,13 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps {
index: number;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>;
@@ -25,8 +30,9 @@ export default function OntimeActionForm({
setValue,
rowErrors,
value,
children,
watch,
}: OntimeActionFormProps) {
}: PropsWithChildren<OntimeActionFormProps>) {
const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => {
@@ -35,7 +41,7 @@ export default function OntimeActionForm({
};
return (
<>
<div className={style.actionSection}>
<label>
Action
<Select
@@ -89,7 +95,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && (
<>
<label className={style.spanFull}>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
@@ -99,7 +105,7 @@ export default function OntimeActionForm({
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
<label>
Visibility
<Select
onValueChange={(value) => {
@@ -163,6 +169,8 @@ export default function OntimeActionForm({
</label>
</>
)}
</>
<div className={style.test}>{children}</div>
</div>
);
}
@@ -1,63 +0,0 @@
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>
</>
);
}
@@ -1,60 +0,0 @@
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>
);
}
@@ -1,25 +0,0 @@
.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,8 +11,6 @@ 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 {
@@ -85,45 +83,42 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<label className={style.titleField}>
<Panel.Description>Title</Panel.Description>
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<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>
<Panel.Description>When to run</Panel.Description>
<Select
value={watch('trigger')}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
<Panel.Description>Automation</Panel.Description>
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</div>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
if (value === null) return;
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
}}
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
aria-label='Lifecycle trigger'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</form>
}
footerElements={
@@ -1,3 +0,0 @@
.table {
min-width: 36rem;
}
@@ -1,5 +1,5 @@
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { useMemo, useState } from 'react';
import { Fragment, useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation';
@@ -12,8 +12,6 @@ import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
import style from './TriggersList.module.scss';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
@@ -52,10 +50,6 @@ 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;
@@ -72,7 +66,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
/>
)}
<Panel.SubHeader>
Global triggers
Manage triggers
<Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd />
</Button>
@@ -80,25 +74,17 @@ export default function TriggersList({ triggers, automations, enabledAutomations
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info type='warning'>
Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
<Info>
Automations are disabled. You can still manage triggers here, but they will not 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. Duplicate combinations will only
fire once per lifecycle event.
You have created multiple links between the same trigger and automation which can cause performance
issues.
</Panel.Error>
)}
{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}>
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
@@ -114,7 +100,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 definition first, then add a global trigger to decide when it should run.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
@@ -125,16 +111,19 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}
/>
)}
{triggers.map((trigger, index) => (
<TriggersListItem
key={trigger.id}
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
))}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
@@ -16,7 +16,6 @@ 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}>
@@ -31,7 +30,9 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}</td>
<td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
@@ -1,19 +1,6 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
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',
]);
});
});
import { checkDuplicates } from '../automationUtils';
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
@@ -1,33 +1,21 @@
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle';
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: TimerLifeCycle;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ 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' },
{ 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' },
];
/**
@@ -78,16 +66,19 @@ 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 seen = new Set<string>();
const duplicates: number[] = [];
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
for (const [index, trigger] of triggers.entries()) {
const key = `${trigger.trigger}:${trigger.automationId}`;
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (seen.has(key)) {
duplicates.push(index);
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
} else {
seen.add(key);
triggersMap[trigger.trigger].push(trigger.automationId);
}
}
return duplicates.length > 0 ? duplicates : undefined;
@@ -0,0 +1,7 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,72 +1,103 @@
import { useMemo } from 'react';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { 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 ReportShowSummary from './composite/ReportShowSummary';
import ReportTable from './composite/ReportTable';
import { getCombinedReport, getGroupReports, getRunSummary, makeReportCSV } from './reportSettings.utils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
export default function ReportSettings() {
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 { data: reportData } = useReport();
const { data } = useRundown();
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.InlineElements>
<Button onClick={downloadCSV} disabled={!hasReport}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={deleteAllReport} disabled={!hasReport}>
<IoTrashBin />
Clear Report
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</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.'
/>
)}
{!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>
<ReportTable rows={combinedReport} groups={groups} />
</Panel.Section>
</>
)}
{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>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
@@ -1,156 +0,0 @@
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('');
});
});
@@ -1,104 +0,0 @@
// 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;
}
@@ -1,118 +0,0 @@
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);
}
@@ -1,144 +0,0 @@
$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;
}
@@ -1,182 +0,0 @@
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,241 +1,89 @@
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 { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { 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.
*
* 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.
* Creates a combined report with the rundown data
*/
export function getCombinedReport(
report: OntimeReport,
rundown: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0 || flatOrder.length === 0) return [];
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
let index = 1;
for (const id of flatOrder) {
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id];
// 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;
if (!entry || !isOntimeEvent(entry)) 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,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
}
combinedReport.push({
id,
index,
title: entry.title,
cue: entry.cue,
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;
}
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 };
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* 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.
* Transforms a CombinedReport into a CSV string
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv = combinedReport.map((entry) => [
String(entry.index),
entry.groupTitle,
entry.cue,
entry.title,
formatTime(entry.scheduledStart),
formatCsvTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatCsvTime(entry.actualEnd),
]);
const csv: string[][] = [];
csv.push(csvHeader);
return makeCSVFromArrayOfArrays([csvHeader, ...csv]);
for (const entry of combinedReport) {
csv.push([
String(entry.index),
entry.title,
entry.cue,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
]);
}
return makeCSVFromArrayOfArrays(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 { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import useRundown from '../../../../../common/hooks-query/useRundown';
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 } = useLoadedRundown();
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
@@ -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>
@@ -2,14 +2,12 @@
display: grid;
grid-template-columns: 1fr auto;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
&.withSource {
grid-template-columns: auto 1fr auto;
}
}
.label.active {
color: $action-text-color;
}
@@ -1,5 +1,6 @@
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Input from '../../../common/components/input/input/Input';
import { cx } from '../../../common/utils/styleUtils';
@@ -9,12 +10,15 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
/** whether this text is currently on the audience screen */
visible: boolean;
changeHandler: (newValue: string) => void;
/** control which picks where the text is shown, rendered before the input */
sourcePicker?: ReactNode;
}
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, children } = props;
const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
@@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return (
<div>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
<Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
{label}
</label>
<div className={style.inputItems}>
</Editor.Label>
<div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children}
</div>
@@ -0,0 +1,17 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
than growing to whatever height the panel happens to have free */
.screenGroup {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
}
}
@@ -1,18 +1,22 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import {
setMessage,
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import Select from '../../../common/components/select/Select';
import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function MessageControl() {
return (
<>
<TimerControlsPreview />
<div className={style.screenGroup}>
<TimerPreview />
<ScreenControl />
</div>
<TimerMessageInput />
<SecondaryInput />
</>
@@ -24,7 +28,7 @@ function TimerMessageInput() {
return (
<InputRow
label='Timer Message'
label='Timer message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
@@ -32,6 +36,7 @@ function TimerMessageInput() {
>
<IconButton
aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'}
>
@@ -41,31 +46,46 @@ function TimerMessageInput() {
);
}
/**
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
* The select owns which one, the eye owns whether the line is shown at all.
*/
function SecondaryInput() {
const { text, visible } = useSecondaryMessageInput();
const toggleSecondary = () => {
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const selectedSource = source ?? 'aux1';
return (
<InputRow
label='Secondary Message'
label='Secondary'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={visible}
visible={source === 'secondary'}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
sourcePicker={
<Select
value={selectedSource}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
setMessage.timerSecondarySource(value);
}}
/>
}
>
<IconButton
aria-label='Toggle secondary message visibility'
onClick={toggleSecondary}
variant={visible ? 'primary' : 'subtle'}
aria-label='Toggle secondary visibility'
aria-pressed={isShowingSecondaryLine}
onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
>
{visible ? <IoEye /> : <IoEyeOffOutline />}
{isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
@@ -1,8 +1,21 @@
.growPanel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* the extracted route has no flex parent to grow into */
.extractedPanel {
height: 100%;
}
.contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex;
flex-direction: column;
gap: $section-spacing;
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl';
@@ -16,7 +17,10 @@ function MessageControlExport() {
return (
<ProtectRoute permission='editor'>
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
<Editor.Panel
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
data-testid='panel-messages-control'
>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
@@ -0,0 +1,14 @@
.screenControl {
display: grid;
grid-auto-flow: row;
gap: $element-spacing;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
width: 100%;
}
}
@@ -0,0 +1,40 @@
import Button from '../../../common/components/buttons/Button';
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
import style from './ScreenControl.module.scss';
export default function ScreenControl() {
const { blackout, blink, isScreenModified } = useScreenControl();
return (
<div className={style.screenControl}>
<Button
variant={blink ? 'primary' : 'subtle'}
aria-pressed={blink}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'destructive' : 'subtle'}
aria-pressed={blackout}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout
</Button>
<Button
variant='subtle'
fluid
disabled={!isScreenModified}
onClick={() => setMessage.clearScreen()}
data-testid='clear screen'
>
Clear screen
</Button>
</div>
);
}
@@ -1,49 +1,26 @@
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
/* fixed shape, not stretched to whatever height the panel has free - it fills the
available width and keeps a 16:9 proportion, which is also what the font size
estimate in the embedded timer assumes */
.stage {
width: 100%;
color: var(--override-colour, $ui-white);
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
aspect-ratio: 16 / 9;
min-width: 0;
position: relative;
/* a query container so that the embedded timer scales to the preview, not to the viewport */
container-type: size;
contain: paint;
overflow: hidden;
border-radius: $component-border-radius-md;
}
.secondaryContent {
border-top: 1px solid $white-7;
}
.blackout {
display: none;
}
.eventStatus {
/* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
.stageChrome {
position: absolute;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
inset: 0;
z-index: calc($zindex-floating + 2);
pointer-events: none;
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
> * {
pointer-events: auto;
}
}
@@ -1,110 +1,21 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import TimerStatus from './TimerStatus';
import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
aux3: 'Aux 3',
secondary: 'Secondary message',
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
if (timerType === TimerType.Clock) return 'Clock';
if (countToEnd) return 'Count to End';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return (
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
<div className={style.stage}>
<PipTimer viewSettings={data} />
<div className={style.stageChrome}>
<TimerStatus />
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
</div>
</div>
);
@@ -0,0 +1,17 @@
.timerStatus {
position: absolute;
top: 0;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -0,0 +1,58 @@
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useTimerStatus } from '../../../common/hooks/useSocket';
import style from './TimerStatus.module.scss';
/** Read only summary of how the loaded event drives the stage timer */
export default function TimerStatus() {
const { countToEnd, timerType } = useTimerStatus();
return (
<div className={style.timerStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
);
}
@@ -1,11 +0,0 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -1,92 +0,0 @@
import { SecondarySource } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Select from '../../../common/components/select/Select';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<SecondarySourceControl />
<Editor.Separator orientation='horizontal' />
<Button
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
function SecondarySourceControl() {
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
useEffect(() => {
if (secondarySource !== null) {
setValue(secondarySource);
}
}, [secondarySource]);
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource(value);
}
};
return (
<>
<Select
value={value}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value);
}
setValue(value);
}}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
+30 -19
View File
@@ -3,7 +3,6 @@ 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';
@@ -56,60 +55,72 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<ToggleButton
pressed={showUser}
<Button
variant={showUser ? 'primary' : 'subtle'}
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}
</ToggleButton>
<ToggleButton
pressed={showClient}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
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}
</ToggleButton>
<ToggleButton
pressed={showServer}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
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}
</ToggleButton>
<ToggleButton
pressed={showPlayback}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
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}
</ToggleButton>
<ToggleButton
pressed={showRx}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
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}
</ToggleButton>
<ToggleButton
pressed={showTx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
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}
</ToggleButton>
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
@@ -18,12 +18,6 @@
padding-bottom: 95vh;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+14 -27
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,10 +25,7 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss';
/** 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;
const selectedOffset = 50;
export default function OperatorLoader() {
const { data, status } = useOperatorData();
@@ -57,20 +54,11 @@ 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,
getTopOffset,
topOffset: selectedOffset,
followTrigger: selectedEventId,
});
@@ -94,16 +82,15 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls
const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) {
return;
if (selectedRef?.current && scrollRef?.current) {
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;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// 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);
@@ -199,9 +186,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<div className={style.groupSection} key={entry.id}>
<Fragment key={entry.id}>
<OperatorGroup
ref={isCurrentParent ? stickyHeaderRef : undefined}
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
@@ -252,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</div>
</Fragment>
);
}
return null;
@@ -1,13 +1,10 @@
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>
<Operator />
</ProtectRoute>
);
}
@@ -1,20 +1,13 @@
.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.25rem 0.75rem;
padding: 0.4rem 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 { type CSSProperties, type Ref, memo } from 'react';
import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
@@ -10,16 +10,15 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<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 { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
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 } = useLoadedRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
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 { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useEntry } from '../../../common/hooks-query/useRundown';
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 = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const group = useEntry(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 = useLoadedEntry(id) as OntimeEvent | null;
const entry = useEntry(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 } = useLoadedRundownAuxData();
const { data: rundownData } = useRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+1 -5
View File
@@ -67,11 +67,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,6 +5,9 @@ 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';
@@ -36,25 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -64,28 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
<RundownEntryEditor />
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
)}
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -97,6 +105,8 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -105,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog />
</div>
);
@@ -1,22 +1,15 @@
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 runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const featureData = useRundownEditor();
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 auto 2rem;
grid-template-columns: 8rem 1fr 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 auto 2rem;
grid-template-columns: 8rem 1fr 2rem;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
@@ -41,12 +41,6 @@
}
}
.outputTags {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.duplicateMessage {
padding-left: 0.75rem;
font-size: $aux-text-size;
@@ -6,11 +6,8 @@ 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';
@@ -30,7 +27,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
label: title,
}));
const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
const duplicateIds = new Set<string>();
const seen = new Map<string, string>();
@@ -79,10 +76,8 @@ 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))
@@ -108,15 +103,6 @@ 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,7 +2,6 @@ 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';
@@ -27,7 +26,6 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -45,17 +43,12 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, 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];
@@ -72,7 +65,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null, null))
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
@@ -81,7 +74,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null, null))
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
@@ -92,7 +85,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry, rundownId],
[entries, flatOrder, clone, reorderEntry],
);
/**
@@ -8,16 +8,10 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
/**
* 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 }]], []);
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,7 +312,6 @@ 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,6 +141,7 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -6,7 +6,6 @@ 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';
@@ -21,6 +20,7 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,6 +33,7 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -44,20 +45,25 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} />;
return <EventReport className={className} id={id} duration={duration} />;
}
return (
<Tooltip text='Expected time until start' render={<span />} className={className}>
<EventUntil
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
</Tooltip>
);
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
timeStart={timeStart}
delay={delay}
dayOffset={dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
/>
</Tooltip>
);
}
return null;
}
interface EventUntilProps {
@@ -80,30 +86,41 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport({ className, id }: EventReportProps) {
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
// 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') {
if (!currentReport) {
return [null, 'none', ''];
}
if (variance.status === 'ontime') {
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) {
return ['ontime', 'under', 'Event finished on time'];
}
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${millisToString(absDifference)}`;
const isOver = difference > 0;
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, variance.status, tooltip];
}, [currentReport]);
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
if (!value) {
return null;
@@ -1,6 +1,9 @@
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';
@@ -12,6 +15,8 @@ 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(() => {
@@ -27,8 +32,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
}

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