Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente be99fbdcb9 feat: allow naming aux timers 2026-08-25 21:45:58 +02:00
163 changed files with 1588 additions and 4929 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"
}
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');
});
});
-16
View File
@@ -1,12 +1,9 @@
import axios from 'axios';
import type {
Automation,
AutomationComposition,
AutomationCompositionDTO,
AutomationDTO,
AutomationOutput,
AutomationSettings,
AutomationUsage,
Trigger,
TriggerDTO,
} from 'ontime-types';
@@ -24,11 +21,6 @@ export async function getAutomationSettings(options?: RequestOptions): Promise<A
return res.data;
}
export async function getAutomationUsage(options?: RequestOptions): Promise<AutomationUsage> {
const res = await axios.get(`${automationsPath}/usage`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
@@ -70,14 +62,6 @@ export async function addAutomation(automation: AutomationDTO): Promise<Automati
return res.data;
}
/** Creates a definition and optional global lifecycle bindings atomically. */
export async function createAutomationComposition(
composition: AutomationCompositionDTO,
): Promise<AutomationComposition> {
const res = await axios.post(`${automationsPath}/composition`, composition);
return res.data;
}
/**
* HTTP request to update a automation
*/
+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,11 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getAutomationUsage } from '../api/automation';
import { AUTOMATION } from '../api/constants';
export default function useAutomationUsage() {
return useQuery({
queryKey: [...AUTOMATION, 'usage'],
queryFn: ({ signal }) => getAutomationUsage({ signal }),
});
}
@@ -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 };
}
@@ -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;
}
+19
View File
@@ -95,6 +95,22 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
};
});
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
return {
aux1: state.auxtimer1.name,
aux2: state.auxtimer2.name,
aux3: state.auxtimer3.name,
};
});
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
return (
state.auxtimer1.playback === SimplePlayback.Start ||
state.auxtimer2.playback === SimplePlayback.Start ||
state.auxtimer3.playback === SimplePlayback.Start
);
});
export const useAuxTimerTime = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1) return state.auxtimer1.current;
@@ -108,15 +124,18 @@ export const useAuxTimerControl = (index: number) =>
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
name: state.auxtimer1.name,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
name: state.auxtimer2.name,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
name: state.auxtimer3.name,
};
})();
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/
export function useWindowTitle(title: string) {
useEffect(() => {
document.title = `Ontime - ${title}`;
document.title = `ontime - ${title}`;
}, []);
}
@@ -6,4 +6,5 @@ export const ontimePlaceholderSettings: Settings = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
@@ -13,6 +13,7 @@ type EditorSettingsStore = {
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
auxTimersCollapsed: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
@@ -21,6 +22,7 @@ type EditorSettingsStore = {
setDangerTime: (dangerTime: string) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
};
export const editorSettingsDefaults = {
@@ -32,6 +34,7 @@ export const editorSettingsDefaults = {
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
auxTimersCollapsed: false,
};
enum EditorSettingsKeys {
@@ -43,6 +46,7 @@ enum EditorSettingsKeys {
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -67,6 +71,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
auxTimersCollapsed: booleanFromLocalStorage(
EditorSettingsKeys.AuxTimersCollapsed,
editorSettingsDefaults.auxTimersCollapsed,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -110,5 +118,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
setAuxTimersCollapsed: (auxTimersCollapsed) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
return { auxTimersCollapsed };
}),
};
});
@@ -1,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 }));
}
@@ -0,0 +1,10 @@
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
const custom = name?.trim();
return custom ? custom : fallback;
}
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
const custom = name?.trim();
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
}
-29
View File
@@ -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' };
}
@@ -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;
@@ -19,19 +20,6 @@
gap: 0.5rem;
}
.lifecycleGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.5rem;
}
.lifecycleOption {
display: flex;
align-items: center;
gap: 0.5rem;
color: $ui-white;
}
.ruleSection {
display: flex;
flex-direction: column;
@@ -39,7 +27,10 @@
}
.titleSection,
.filterSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid;
grid-gap: 0.5rem;
@@ -50,7 +41,10 @@
.titleSection,
.ruleSection,
.filterSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
@@ -67,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,59 +1,58 @@
import { Automation, AutomationDTO, TimerLifeCycle, 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';
import { createAutomationComposition, editAutomation, testOutput } from '../../../../common/api/automation';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
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 useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage';
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 { cycles, 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;
initialLifecycles?: TimerLifeCycle[];
onClose: () => void;
}
export default function AutomationForm({ automation, initialLifecycles = [], onClose }: AutomationFormProps) {
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation);
const { data } = useCustomFields();
const { refetch } = useAutomationSettings();
const { refetch: refetchUsage } = useAutomationUsage();
const fieldList = useMemo(() => makeFieldList(data), [data]);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [submitError, setSubmitError] = useState<string>();
const [lifecycles, setLifecycles] = useState<TimerLifeCycle[]>(initialLifecycles);
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const {
control,
handleSubmit,
getValues,
register,
setError,
setFocus,
setValue,
watch,
@@ -94,28 +93,6 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
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: '' });
};
@@ -129,97 +106,84 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
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 {
await handleCreate(values);
}
refetch();
refetchUsage();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setSubmitError(maybeAxiosError(error));
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await createAutomationComposition({ automation: values, lifecycles });
await addAutomation(values);
onClose();
} catch (error) {
setSubmitError(maybeAxiosError(error));
setError('root', { message: maybeAxiosError(error) });
}
}
};
const canSubmit = !isSubmitting && isValid && (isDirty || initialLifecycles.length > 0);
const toggleLifecycle = (lifecycle: TimerLifeCycle, checked: boolean) => {
setLifecycles((current) =>
checked ? [...current, lifecycle] : current.filter((selectedLifecycle) => selectedLifecycle !== lifecycle),
);
};
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>
);
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Modal
@@ -231,26 +195,6 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerColumn}>
{!isEdit && (
<>
<h3>Global trigger (optional)</h3>
<Panel.Description>
Choose when this automation should run globally. Leave all unchecked to save a reusable definition
only.
</Panel.Description>
<div className={style.lifecycleGrid}>
{cycles.map(({ value, label }) => (
<label key={value} className={style.lifecycleOption}>
<Checkbox
checked={lifecycles.includes(value)}
onCheckedChange={(checked) => toggleLifecycle(value, checked)}
/>
{label}
</label>
))}
</div>
</>
)}
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
@@ -258,7 +202,7 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Automation title'
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
@@ -279,9 +223,6 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
{ 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}`;
@@ -323,7 +264,11 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
{ 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>
@@ -360,52 +305,150 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
<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}
@@ -413,19 +456,44 @@ export default function AutomationForm({ automation, initialLifecycles = [], onC
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;
}
@@ -1,4 +1,4 @@
import { AutomationDTO, NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
@@ -9,13 +9,8 @@ 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 useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
import RecipeLibraryModal from './RecipeLibraryModal';
import style from './AutomationsList.module.scss';
const automationPlaceholder: AutomationDTO = {
title: '',
@@ -32,10 +27,7 @@ interface AutomationsListProps {
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
const { refetch } = useAutomationSettings();
const { data: usage, refetch: refetchUsage } = useAutomationUsage();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [initialLifecycles, setInitialLifecycles] = useState<TimerLifeCycle[]>([]);
const [isRecipeLibraryOpen, setIsRecipeLibraryOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
@@ -46,7 +38,6 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
refetchUsage();
}
};
@@ -56,52 +47,32 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section>
<Panel.Card>
{automationFormData !== null && (
<AutomationForm
automation={automationFormData}
initialLifecycles={initialLifecycles}
onClose={() => setAutomationFormData(null)}
/>
)}
{isRecipeLibraryOpen && (
<RecipeLibraryModal
onClose={() => setIsRecipeLibraryOpen(false)}
onSelect={(automation, lifecycles) => {
setInitialLifecycles(lifecycles);
setAutomationFormData(automation);
setIsRecipeLibraryOpen(false);
}}
/>
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.SubHeader>
Manage automations
<Button
onClick={() => {
setInitialLifecycles([]);
setAutomationFormData(automationPlaceholder);
}}
>
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
New <IoAdd />
</Button>
<Button onClick={() => setIsRecipeLibraryOpen(true)}>Recipes</Button>
</Panel.SubHeader>
<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 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: '10%' }}>Usage</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
@@ -109,15 +80,9 @@ 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={() => {
setInitialLifecycles([]);
setAutomationFormData(automationPlaceholder);
}}
>
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
</Button>
}
@@ -135,20 +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>
{usage?.[automationId]
? `${usage[automationId].global} global, ${usage[automationId].event} event`
: '—'}
</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -171,7 +123,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
})}
{deleteError && (
<tr>
<td colSpan={6}>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
@@ -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,72 +0,0 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils';
import { automationRecipes, defaultRecipeValues, type AutomationRecipe, type RecipeValues } from './automationRecipes';
interface RecipeLibraryModalProps {
onClose: () => void;
onSelect: (automation: AutomationDTO, lifecycles: TimerLifeCycle[]) => void;
}
export default function RecipeLibraryModal({ onClose, onSelect }: RecipeLibraryModalProps) {
const [recipe, setRecipe] = useState<AutomationRecipe>();
const [values, setValues] = useState<RecipeValues>({});
const selectRecipe = (nextRecipe: AutomationRecipe) => {
setRecipe(nextRecipe);
setValues(defaultRecipeValues(nextRecipe));
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={recipe ? recipe.title : 'Automation recipes'}
bodyElements={
recipe ? (
<div>
<Panel.Description>{recipe.description}</Panel.Description>
{recipe.params.map((param) => (
<label key={param.name}>
{param.label}
<Input
fluid
type={param.type ?? 'text'}
value={values[param.name] ?? ''}
onChange={(event) => setValues((current) => ({ ...current, [param.name]: event.target.value }))}
/>
</label>
))}
</div>
) : (
<Panel.ListGroup>
{automationRecipes.map((candidate) => (
<Panel.ListItem key={candidate.id}>
<Panel.Field title={candidate.title} description={candidate.description} />
<Button onClick={() => selectRecipe(candidate)}>Choose</Button>
</Panel.ListItem>
))}
</Panel.ListGroup>
)
}
footerElements={
recipe ? (
<>
<Button onClick={() => setRecipe(undefined)}>Back</Button>
<Button variant='primary' onClick={() => onSelect(recipe.build(values), recipe.lifecycles)}>
Continue
</Button>
</>
) : (
<Button onClick={onClose}>Cancel</Button>
)
}
/>
);
}
@@ -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,18 +0,0 @@
import { TimerLifeCycle, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
import { automationRecipes, defaultRecipeValues } from '../automationRecipes';
describe('automation recipes', () => {
it('builds valid OSC, HTTP, and Ontime definitions with explicit global lifecycles', () => {
const built = automationRecipes.map((recipe) => ({
recipe,
automation: recipe.build(defaultRecipeValues(recipe)),
}));
expect(built.some(({ automation }) => automation.outputs.some(isOSCOutput))).toBe(true);
expect(built.some(({ automation }) => automation.outputs.some(isHTTPOutput))).toBe(true);
expect(built.some(({ automation }) => automation.outputs.some(isOntimeAction))).toBe(true);
expect(built.every(({ recipe }) => recipe.lifecycles.length > 0)).toBe(true);
expect(built.flatMap(({ recipe }) => recipe.lifecycles)).toContain(TimerLifeCycle.onStart);
});
});
@@ -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,64 +0,0 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeValues = Record<string, string>;
export type AutomationRecipe = {
id: string;
title: string;
description: string;
lifecycles: TimerLifeCycle[];
params: { name: string; label: string; defaultValue: string; type?: 'number' }[];
build: (values: RecipeValues) => AutomationDTO;
};
function definition(title: string, outputs: AutomationDTO['outputs']): AutomationDTO {
return { title, filterRule: 'all', filters: [], outputs };
}
export const automationRecipes: AutomationRecipe[] = [
{
id: 'qlab-go',
title: 'QLab — fire the matching cue',
description: 'Sends an OSC GO message for the event cue when an event starts.',
lifecycles: [Cycle.onStart],
params: [
{ name: 'host', label: 'QLab host', defaultValue: '127.0.0.1' },
{ name: 'port', label: 'OSC port', defaultValue: '53000', type: 'number' },
],
build: ({ host, port }) =>
definition('QLab GO on event start', [
{
type: 'osc',
targetIP: host.trim(),
targetPort: Number(port),
address: '/cue/{{eventNow.cue}}/start',
args: '',
},
]),
},
{
id: 'webhook-event-title',
title: 'Webhook — send the event title',
description: 'Calls a webhook with the running event title when an event starts.',
lifecycles: [Cycle.onStart],
params: [{ name: 'url', label: 'Webhook URL', defaultValue: 'http://127.0.0.1:3000/ontime' }],
build: ({ url }) =>
definition('Send event title to webhook', [
{ type: 'http', url: `${url.trim()}${url.includes('?') ? '&' : '?'}title={{url:eventNow.title}}` },
]),
},
{
id: 'ontime-stage-warning',
title: 'Ontime — show a stage warning',
description: 'Shows a message in Ontime when an event reaches its danger window.',
lifecycles: [Cycle.onDanger],
params: [{ name: 'message', label: 'Message', defaultValue: 'Please wrap up' }],
build: ({ message }) =>
definition('Warn the stage at danger', [{ type: 'ontime', action: 'message-set', text: message, visible: true }]),
},
];
export function defaultRecipeValues(recipe: AutomationRecipe): RecipeValues {
return Object.fromEntries(recipe.params.map((param) => [param.name, param.defaultValue]));
}
@@ -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);
}
@@ -0,0 +1,95 @@
import { Settings } from 'ontime-types';
import { auxTimerNameMaxLength } from 'ontime-utils';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AuxTimerSettings() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, errors },
} = useForm<Settings>({
defaultValues: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='aux-timer-settings'
>
<Panel.Card>
<Panel.SubHeader>
Aux timers
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -3,6 +3,7 @@ import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import AuxTimerSettings from './AuxTimerSettings';
import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData';
@@ -12,6 +13,7 @@ import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
@@ -26,6 +28,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={generalRef}>
<GeneralSettings />
</div>
<div ref={auxTimersRef}>
<AuxTimerSettings />
</div>
<div ref={viewRef}>
<ViewSettings />
</div>
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && (
<Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown Ontime
Shutdown ontime
</Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section>
@@ -24,6 +24,7 @@ const staticOptions = [
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone'],
},
{ id: 'settings__aux-timers', label: 'Aux timers', keywords: ['aux', 'auxiliary'] },
{
id: 'settings__view',
label: 'View settings',
@@ -3,6 +3,34 @@
margin: 0 auto;
}
.auxHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.auxHeaderButtons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.activeIndicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
}
.auxTimers {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1,4 +1,10 @@
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
@@ -8,6 +14,9 @@ import style from './PlaybackControl.module.scss';
export default function PlaybackControl() {
const data = usePlaybackControl();
const { setLocation } = useAppSettingsNavigation();
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
const isAuxTimerActive = useAuxTimersActive();
return (
<div className={style.mainContainer}>
@@ -20,11 +29,42 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
<div className={style.auxHeader}>
<span className={style.label}>
Aux timers
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
</span>
<div className={style.auxHeaderButtons}>
<Tooltip
text='Name aux timers'
render={
<IconButton
size='small'
variant='subtle-white'
aria-label='Name aux timers'
onClick={() => setLocation('settings__aux-timers')}
/>
}
>
<IoSettingsOutline />
</Tooltip>
<IconButton
size='small'
variant='subtle-white'
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
>
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
</div>
{!auxTimersCollapsed && (
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
)}
</div>
);
}
@@ -1,8 +1,18 @@
.label {
display: block;
margin-top: 1rem;
// aux timers sit in a 3 column grid, without this a long name would grow its column
// instead of shrinking to it, breaking the equal column layout
min-width: 0;
}
.labelText {
display: block;
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
@@ -3,7 +3,9 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -13,10 +15,12 @@ interface AuxTimerProps {
}
export function AuxTimer({ index }: AuxTimerProps) {
const { playback, direction } = useAuxTimerControl(index);
const { playback, direction, name } = useAuxTimerControl(index);
const { stop, setDirection } = setAuxTimer;
const label = getAuxTimerIndexedLabel(name, index);
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(index, newDirection);
@@ -27,10 +31,12 @@ export function AuxTimer({ index }: AuxTimerProps) {
return (
<label className={style.label}>
Aux Timer {index}
<Tooltip text={label} render={<span />} className={style.labelText}>
{label}
</Tooltip>
<div className={style.controls}>
<div className={style.input}>
<AuxTimerInput index={index} isActive={isActive} />
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
@@ -50,9 +56,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
interface AuxTimerInputProps {
index: number;
isActive: boolean;
placeholder: string;
}
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer;
@@ -70,7 +77,7 @@ function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
}
return (
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
);
}
+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,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>
@@ -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>
@@ -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;
+3 -31
View File
@@ -100,20 +100,7 @@ $item-height: 3.5rem;
flex-direction: column;
overflow-y: auto;
padding-bottom: 95vh;
}
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* ====================== LIST-ITEM ======================*/
@@ -209,14 +196,9 @@ $item-height: 3.5rem;
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
/* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
var(--card-background-color-override, $viewer-card-bg-color);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -248,16 +230,6 @@ $item-height: 3.5rem;
}
}
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
@@ -22,7 +22,6 @@ import {
CountdownTarget,
extendEventData,
getIsLive,
groupSubscriptionTargets,
isOutsideRange,
preferredFormat12,
preferredFormat24,
@@ -49,22 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
getTopOffset: getStickyOffset,
topOffset: 0,
followTrigger: selectedEventId,
});
@@ -87,16 +75,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// 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 > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
};
const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -111,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{sections.map((section) => {
const rows = section.group ? [section.group, ...section.events] : section.events;
// the running event anchors the scroll, the group header stays pinned above it
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
{subscribedEvents.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
{rows.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
// only ever hand the ref to a single row, sharing it would null it out on the next commit
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
const countdownEvent = extendEventData(
event,
currentDay,
actualStart,
plannedStart,
offset,
mode,
reportData,
);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
import { resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull();
});
});
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,42 +252,6 @@ export function resolveSubscriptionTarget(
};
}
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
sections.push({ group: target, events: [] });
continue;
}
const previousSection = sections.at(-1);
if (previousSection?.group?.id === target.parent) {
previousSection.events.push(target);
continue;
}
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData(
event: CountdownTarget,
currentDay: number,
@@ -105,10 +105,10 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
return null;
}
const { hideTableSeconds } = table.options.meta.options;
const event = row.original;
if (!isOntimeEvent(event)) {
const duration = getValue() as number;
return <MutedText numeric>{formatDuration(duration, false)}</MutedText>;
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
@@ -117,7 +117,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
const duration = getValue() as number;
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, false);
const formattedDuration = formatDuration(duration, hideTableSeconds);
const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) {
@@ -3,14 +3,11 @@
.error {
padding-inline: 0.5rem;
font-size: 1rem;
// rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.entry[data-selected='true'] {
@@ -21,47 +18,21 @@
color: $label-gray;
}
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error {
color: $error-red;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title'
'index match';
'index title';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
min-width: 0;
.index {
grid-area: index;
// background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
background-color: var(--color, $gray-1000);
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
@@ -71,33 +42,14 @@
.title {
grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
}
}
@@ -111,14 +63,13 @@
color: $label-gray;
}
.count {
.filterHint {
text-align: right;
white-space: nowrap;
}
.go {
white-space: nowrap;
padding-left: 1rem;
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.hints {
@@ -147,7 +98,7 @@
flex-direction: column;
}
.count {
.filterHint {
text-align: left;
}
}
+52 -117
View File
@@ -1,12 +1,11 @@
import { MaybeString } from 'ontime-types';
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import useFinder from './useFinder';
import style from './Finder.module.scss';
@@ -16,76 +15,46 @@ interface FinderProps {
}
export default function Finder({ isOpen, onClose }: FinderProps) {
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const { find, select, results, error } = useFinder();
const [selected, setSelected] = useState(0);
/**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelectedId(results[(activeIndex + 1) % results.length].id);
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit(activeEntry);
submit();
}
};
const submit = (entry: FinderResult | undefined) => {
if (!entry) {
return;
}
select(entry);
const submit = () => {
const selectedEvent = results[selected];
select(selectedEvent);
onClose();
};
/** Scopes the search to a single field, or back to all fields when tapped again */
const handleFilter = (filterKey: string) => {
setFilter((previous) => (previous === filterKey ? null : filterKey));
inputRef.current?.focus();
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
};
const hiddenResults = total - results.length;
return (
<Modal
title=''
@@ -94,68 +63,35 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop
bodyElements={
<div onKeyDown={navigate}>
<Input
ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.map((entry) => {
const isSelected = activeEntry?.id === entry.id;
// the title and cue are already on the row, a match anywhere else needs showing
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = 'cue' in entry ? entry.cue : '';
return (
<li
key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry}
data-testid='finder-result'
data-selected={isSelected}
onClick={() => submit(entry)}
onPointerEnter={() => setSelectedId(entry.id)}
>
<div className={style.data}>
<div className={style.index} style={getAccessibleColour(entry.colour)}>
{entry.eventIndex ?? '-'}
</div>
<div className={style.cue}>{entry.cue}</div>
<div className={style.title}>{entry.title}</div>
{showMatch && (
<div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
return (
<li
key={entry.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
</div>
)}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li>
);
})}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
<div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
</ul>
</div>
}
@@ -176,11 +112,10 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close
</span>
</div>
{total > 0 && (
<div className={style.count} data-testid='finder-count'>
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div>
)}
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
</div>
}
/>
@@ -1,162 +0,0 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+209 -229
View File
@@ -1,259 +1,239 @@
import {
CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
/** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
const maxResults = 12;
const indexFilter = 'index';
/** Everything except delays, which carry no text to search */
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
type FilterableGroup = {
type: SupportedEntry.Group;
id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number;
/** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string;
/** groups have no cue */
colour: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: EntryId;
index: number;
eventIndex: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
};
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type FilterableMilestone = {
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
/** Groups are the only searchable entry with neither a cue nor a parent */
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
return {
id: entry.id,
index,
eventIndex,
title: entry.title,
cue: 'cue' in entry ? entry.cue : '',
colour: entry.colour,
parent: 'parent' in entry ? entry.parent : null,
match,
} satisfies FinderResult;
}
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
}
const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
}
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
}
// groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
}
// custom fields are named by the project, so these can only be reached generically
for (const [key, value] of Object.entries(entry.custom)) {
const definition = customFields[key];
if (definition?.type !== 'text') {
continue;
}
const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
return null;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
eventIndex++;
if (eventIndex === target) {
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
}
}
return noResults;
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
}
const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
}
const match = findMatch(entry, customFields, filterKey, searchString);
if (match === null) {
continue;
}
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
return { results, error: null, total };
}
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
const { data: customFields } = useCustomFields();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
const customFilters = Object.entries(customFields)
.filter(([_key, field]) => field.type === 'text')
.map(([key, field]) => ({ key, label: field.label || key }));
return [...staticFilters, ...customFilters];
}, [customFields]);
/** Filters the rundown to a given evaluation */
const find = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
const { results, error, total, appliedFilter } = useMemo(() => {
if (data.length === 0) {
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
}
if (event.target.value === '') {
setResults([]);
return;
}
const normalised = searchValue.trim().toLowerCase();
if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
/**
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
*/
const { filterKey, searchString } = activeFilter
? { filterKey: activeFilter, searchString: normalised }
: parseQuery(normalised, filters);
if (searchValue.startsWith('index ')) {
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
if (filterKey === indexFilter) {
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
}
if (searchString === '') {
// a filter is selected, but there is nothing to match on yet
return { ...noResults, appliedFilter: filterKey };
}
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
}, [data, customFields, filters, searchValue, activeFilter]);
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
}
},
[data],
);
const select = useCallback(
(result: FinderResult) => {
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
(selectedEvent: FilterableEntry) => {
selectAndRevealEntry({
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
},
[selectAndRevealEntry],
);
return { select, results, error, total, filters, appliedFilter };
/** clear results when source data changes */
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
}
@@ -1,7 +1,8 @@
import { Playback, TimerPhase, ViewSettings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { useAuxTimersName, useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { getAuxTimerLabel } from '../../common/utils/auxTimerUtils';
import { getOffsetState } from '../../common/utils/offset';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -121,22 +122,23 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
function StudioTimersAux() {
const auxTimer = useAuxTimersTime();
const auxName = useAuxTimersName();
return (
<div className='card' id='card-aux'>
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='label'>{getAuxTimerLabel(auxName.aux1, 'Aux 1')}</div>
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='label center'>{getAuxTimerLabel(auxName.aux2, 'Aux 2')}</div>
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='label right'>{getAuxTimerLabel(auxName.aux3, 'Aux 3')}</div>
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
</div>
</div>
-18
View File
@@ -195,24 +195,6 @@
font-weight: 600;
}
.sound-prompt {
position: absolute;
bottom: $view-block-padding;
left: $view-inline-padding;
padding: 0.5em 0.75em;
border-radius: $element-border-radius;
background-color: $viewer-card-bg-color;
color: $viewer-secondary-color;
font-size: $timer-label-size;
text-transform: uppercase;
pointer-events: none;
transition: opacity $viewer-transition-time;
&--hidden {
opacity: 0;
}
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
-17
View File
@@ -8,7 +8,6 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
@@ -31,7 +30,6 @@ import {
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss';
@@ -68,7 +66,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime,
freezeMessage,
hidePhase,
endSound,
font,
keyColour,
timerColour,
@@ -78,8 +75,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
@@ -161,8 +156,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
@@ -234,13 +227,3 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div>
);
}
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -1,34 +0,0 @@
import { TimerPhase } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
'sounds when a running timer goes into overtime from %s',
(previousPhase) => {
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
},
);
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
});
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
});
it('stays silent for a roll timer waiting to start', () => {
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
});
it('sounds once, not on every update while in overtime', () => {
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
});
it('stays silent on phases which are not the end of the timer', () => {
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
});
});
@@ -16,7 +16,6 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [
@@ -77,15 +76,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean',
defaultValue: false,
},
{
id: 'endSound',
title: 'Play sound on timer end',
description:
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
type: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
],
},
{
@@ -203,7 +193,6 @@ type TimerOptions = {
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
endSound: EndSound;
font?: string;
keyColour?: string;
timerColour?: string;
@@ -219,7 +208,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return {
hideClock: isStringBoolean(getValue('hideClock')),
@@ -239,7 +227,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
@@ -1,20 +0,0 @@
import arpeggio from '../../assets/sounds/arpeggio.wav';
import bell from '../../assets/sounds/bell.wav';
import chime from '../../assets/sounds/chime.wav';
import type { SelectOption } from '../../common/components/select/Select';
// synthesised tones bundled with the app, no external source or licence to track
export const endSoundSources = { chime, bell, arpeggio } as const;
export type EndSound = 'none' | keyof typeof endSoundSources;
export const endSoundOptions: SelectOption[] = [
{ value: 'none', label: 'None' },
{ value: 'chime', label: 'Chime' },
{ value: 'bell', label: 'Bell' },
{ value: 'arpeggio', label: 'Arpeggio' },
];
export function isEndSound(value: string | null): value is EndSound {
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
}
@@ -189,18 +189,3 @@ export function getCardData(
nextSecondary,
};
}
/**
* Whether the end of timer sound should play for a given phase transition
* We only sound the transition into overtime from a phase that was already counting,
* which keeps a client that connects or reloads mid-overtime silent
*/
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
if (phase !== TimerPhase.Overtime) {
return false;
}
return (
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
);
}
@@ -1,99 +0,0 @@
import { TimerPhase } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { endSoundSources, type EndSound } from './timer.sound';
import { shouldPlayEndSound } from './timer.utils';
/**
* Plays a sound when the timer reaches its end
*
* Browsers reject playback until the document has been interacted with, and that permission
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
* audio element on the first interaction and let the view prompt for one if it never comes.
* Safari grants the permission per element, so priming has to call play() on this element from
* inside the event handler, it is not enough to know that an interaction happened.
*/
export function useTimerSound(phase: TimerPhase, sound: EndSound): boolean {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previousPhaseRef = useRef<TimerPhase | null>(null);
const [isArmed, setIsArmed] = useState(false);
const enabled = sound !== 'none';
// Create and clean up the audio element; changing sounds requires re-arming it in Safari.
useEffect(() => {
setIsArmed(false);
if (sound === 'none') {
return;
}
const audio = new Audio(endSoundSources[sound]);
audioRef.current = audio;
return () => {
audio.pause();
audioRef.current = null;
};
}, [sound]);
// Listen for user interaction until muted playback succeeds and arms the selected audio element.
useEffect(() => {
if (!enabled || isArmed) {
return;
}
const controller = new AbortController();
const prime = () => {
const audio = audioRef.current;
if (!audio) {
return;
}
const wasMuted = audio.muted;
audio.muted = true;
audio
.play()
.then(() => {
if (audioRef.current !== audio) {
return;
}
audio.pause();
audio.currentTime = 0;
setIsArmed(true);
})
.catch(() => {
// playback is still blocked, a later interaction will try again
})
.finally(() => {
audio.muted = wasMuted;
});
};
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
return () => {
controller.abort();
};
}, [enabled, isArmed]);
// Track phase transitions and play only when a running timer enters overtime.
useEffect(() => {
const previousPhase = previousPhaseRef.current;
previousPhaseRef.current = phase;
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
return;
}
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => {
// the screen has not been interacted with, the view shows a prompt for it
});
}, [enabled, phase]);
return enabled && !isArmed;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.13.0",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/>
<title>Ontime</title>
<title>ontime</title>
<style>
body {
-webkit-user-select: none;
@@ -92,7 +92,7 @@
<body>
<div class="container">
<img src="../assets/logo.png" />
<h1>Ontime · event timers</h1>
<h1>ontime · event timers</h1>
<div class="lds-ellipsis">
<div></div>
<div></div>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.13.0",
"version": "4.12.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
@@ -17,7 +17,7 @@
"parser"
],
"author": "",
"license": "ISC",
"license": "AGPL-3.0-only",
"devDependencies": {
"ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.13.0",
"version": "4.12.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -1,109 +0,0 @@
import type { Request, Response } from 'express';
import type { Automation, AutomationComposition, ErrorResponse } from 'ontime-types';
import { editAutomation, postAutomation, postAutomationComposition } from '../automation.controller.js';
import * as automationDao from '../automation.dao.js';
vi.mock('../automation.dao.js', () => ({
addAutomation: vi.fn(),
createAutomationComposition: vi.fn(),
editAutomation: vi.fn(),
}));
function makeResponse() {
return {
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response<Automation | ErrorResponse>;
}
const requestBody = {
title: 'OSC definition',
filterRule: 'all',
filters: [],
outputs: [{ type: 'osc', targetIP: ' 127.0.0.1 ', targetPort: 53000, address: '/test', args: '' }],
};
describe('automation controllers', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation }));
vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation }));
vi.mocked(automationDao.createAutomationComposition).mockImplementation(async ({ automation, lifecycles }) => ({
automation: { id: 'new-id', ...automation },
triggers: lifecycles.map((trigger, index) => ({
id: String(index),
title: `Global ${trigger}`,
trigger,
automationId: 'new-id',
})),
}));
});
it('persists normalized outputs when creating an automation', async () => {
const request = { body: requestBody } as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
);
});
it('persists normalized outputs when editing an automation', async () => {
const request = { body: requestBody, params: { id: 'automation-id' } } as unknown as Request;
await editAutomation(request, makeResponse());
expect(automationDao.editAutomation).toHaveBeenCalledWith(
'automation-id',
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
);
});
it('persists definitions using the not_contains filter operator', async () => {
const request = {
body: {
...requestBody,
filters: [{ field: 'eventNow.title', operator: 'not_contains', value: 'break' }],
},
} as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ filters: [expect.objectContaining({ operator: 'not_contains' })] }),
);
});
it('rejects trigger bindings when editing a definition', async () => {
const request = {
body: { ...requestBody, triggers: [{ trigger: 'onStart', automationId: 'other-definition' }] },
params: { id: 'automation-id' },
} as unknown as Request;
const response = makeResponse();
await editAutomation(request, response);
expect(automationDao.editAutomation).not.toHaveBeenCalled();
expect(response.status).toHaveBeenCalledWith(400);
expect(response.send).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
);
});
it('creates a definition and global lifecycle bindings through the composition command', async () => {
const response = makeResponse() as unknown as Response<AutomationComposition | ErrorResponse>;
const request = {
body: { automation: requestBody, lifecycles: ['onStart', 'onFinish'] },
} as Request;
await postAutomationComposition(request, response);
expect(automationDao.createAutomationComposition).toHaveBeenCalledWith({
automation: expect.objectContaining({ title: 'OSC definition' }),
lifecycles: ['onStart', 'onFinish'],
});
expect(response.status).toHaveBeenCalledWith(201);
});
});
@@ -3,7 +3,6 @@ import { Automation, AutomationDTO, ProjectRundowns, TimerLifeCycle, TriggerDTO
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import {
addAutomation,
createAutomationComposition,
addTrigger,
deleteAll,
deleteAllTriggers,
@@ -138,37 +137,6 @@ describe('addAutomation()', () => {
});
});
describe('createAutomationComposition()', () => {
beforeEach(async () => {
await deleteAll();
});
it('writes a definition and all requested global bindings together', async () => {
const composition = await createAutomationComposition({
automation: { title: 'New definition', filterRule: 'all', filters: [], outputs: [makeHTTPAction()] },
lifecycles: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
});
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
expect(getAutomationTriggers()).toEqual(
expect.arrayContaining([
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onStart }),
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onFinish }),
]),
);
});
it('writes only a reusable definition when no lifecycle is selected', async () => {
const composition = await createAutomationComposition({
automation: { title: 'Reusable definition', filterRule: 'all', filters: [], outputs: [] },
lifecycles: [],
});
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
expect(getAutomationTriggers()).toEqual([]);
});
});
describe('editAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
@@ -178,7 +146,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [makeOSCAction(), makeHTTPAction()],
outputs: [],
});
await addAutomation({
title: 'test-http',
@@ -196,7 +164,7 @@ describe('editAutomation()', () => {
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: [makeOSCAction(), makeHTTPAction()],
outputs: expect.any(Array),
});
const editedOSC = await editAutomation(firstAutomation.id, {
@@ -211,34 +179,9 @@ describe('editAutomation()', () => {
title: 'edited-title',
filterRule: 'any',
filters: expect.any(Array),
outputs: [],
outputs: expect.any(Array),
});
});
it('replaces all outputs when an automation definition is edited', async () => {
const editedWithOneOutput = await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
expect(editedWithOneOutput.outputs).toEqual([makeHTTPAction()]);
});
it('preserves global triggers when a definition is edited', async () => {
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'all',
filters: [],
outputs: [],
});
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onStart }),
]);
});
});
describe('deleteAutomation()', () => {
@@ -282,35 +225,4 @@ describe('deleteAutomation()', () => {
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('refuses an automation attached to a global trigger', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId });
await expect(deleteAutomation({}, automationId)).rejects.toThrow(/used in trigger/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(getAutomations()[automationId]).toBeDefined();
});
it('refuses an automation attached to an event', async () => {
const automationId = Object.keys(getAutomations())[0];
const projectRundowns: ProjectRundowns = {
'rundown-1': {
id: 'rundown-1',
title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
}),
},
revision: 1,
},
};
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
expect(getAutomations()[automationId]).toBeDefined();
});
});

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