Compare commits

..

9 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:29:16 +00:00
100 changed files with 991 additions and 3203 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.13.0", "version": "4.12.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <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-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="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" 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" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>Ontime</title> <title>ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.13.0", "version": "4.12.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+7 -9
View File
@@ -1,21 +1,19 @@
{ {
"name": "Ontime", "name": "ontime",
"short_name": "Ontime", "short_name": "ontime",
"icons": [ "icons": [
{ {
"src": "ontime-logo-192.png", "src": "favicon.ico",
"sizes": "192x192", "type": "image/x-icon"
"type": "image/png"
}, },
{ {
"src": "ontime-logo-512.png", "src": "ontime-logo.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "standalone", "display": "",
"theme_color": "#101010", "theme_color": "#121212",
"background_color": "#101010" "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.
+8 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import type { ReportData } from 'ontime-types'; import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -8,13 +8,18 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; 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 }); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() { export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`); await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -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,34 +1,17 @@
import { useQuery } from '@tanstack/react-query'; 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 { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants'; import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report'; 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() { export default function useReport() {
const { data: report, refetch } = useQuery<ReportData>({ const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT, queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }), queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
return { return { data: data ?? {}, refetch };
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
} }
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef | null>, componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef | null>, scrollRef: RefObject<ScrollRef>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null topOffset?: number;
getTopOffset: () => number; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
scrollToComponent(followRef, scrollRef, getTopOffset()); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => { const scrollToRefComponent = useCallback(
scrollToComponent(followRef, scrollRef, getTopOffset()); (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
}, [followRef, scrollRef, getTopOffset]); 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; return scrollToRefComponent;
} }
+17 -12
View File
@@ -22,10 +22,14 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null, nextEventId: state.eventNext?.id ?? null,
})); }));
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({ export const useScreenControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
blink: state.message.timer.blink, blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource, isScreenModified:
state.message.timer.visible ||
state.message.timer.blink ||
state.message.timer.blackout ||
state.message.timer.secondarySource !== null,
})); }));
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
@@ -33,17 +37,12 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible, visible: state.message.timer.visible,
})); }));
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({ export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary, text: state.message.secondary,
visible: state.message.timer.secondarySource === 'secondary', source: state.message.timer.secondarySource,
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useTimerStatus = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false, countToEnd: state.eventNow?.countToEnd ?? false,
})); }));
@@ -52,10 +51,16 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }), secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), // blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) => timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }), sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
clearScreen: () =>
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
}; };
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `Ontime - ${title}`; document.title = `ontime - ${title}`;
}, []); }, []);
} }
@@ -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(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s'); 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');
});
}); });
-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' };
}
@@ -0,0 +1,7 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,72 +1,103 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5'; import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report'; import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ReportShowSummary from './composite/ReportShowSummary'; import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import ReportTable from './composite/ReportTable';
import { getCombinedReport, getGroupReports, getRunSummary, makeReportCSV } from './reportSettings.utils'; import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { report } = useReport(); const { data: reportData } = useReport();
const { eventReports, rundown, show } = report; const { data } = useRundown();
const { combinedReport, groups, summary } = useMemo(() => {
const entries = rundown?.entries ?? {};
return {
combinedReport: rundown ? getCombinedReport(eventReports, entries, rundown.flatOrder) : [],
groups: rundown ? getGroupReports(eventReports, entries, rundown.order) : [],
summary: getRunSummary(eventReports, entries, rundown?.flatOrder ?? []),
};
}, [eventReports, rundown]);
const hasReport = rundown !== null && Object.keys(eventReports).length > 0;
const downloadCSV = () => {
if (!hasReport) return;
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport); const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;'); const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv'); downloadBlob(blob, 'ontime-report.csv');
}; };
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader> <Panel.SubHeader>Report</Panel.SubHeader>
Report <Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements> <Panel.InlineElements>
<Button onClick={downloadCSV} disabled={!hasReport}> <Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownloadOutline /> <IoTrashBin />
Export CSV Export CSV
</Button> </Button>
<Button variant='subtle-destructive' onClick={deleteAllReport} disabled={!hasReport}> <Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin /> <IoTrashBin />
Clear Report Clear All
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.Title>
<Panel.Divider /> </Panel.Section>
{!hasReport ? (
<Panel.Section> <Panel.Section>
<Panel.EmptyState <Panel.Table>
title='No report yet' <thead>
description='Start an event to record actual timings against the schedule.' <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.'
/> />
</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.Card>
</Panel.Section> </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 { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report'; import { formatTime } from '../../../../common/utils/time';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = { export type CombinedReport = {
id: EntryId; id: EntryId;
index: number; index: number;
title: string; title: string;
cue: 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; scheduledStart: number;
actualStart: MaybeNumber; actualStart: MaybeNumber;
startOffset: MaybeNumber;
scheduledEnd: number; scheduledEnd: number;
actualEnd: MaybeNumber; actualEnd: MaybeNumber;
endOffset: MaybeNumber;
};
type ShowOffsets = {
startOffset: MaybeNumber;
endOffset: MaybeNumber;
durationOffset: MaybeNumber;
};
export type GroupReport = {
id: EntryId;
title: string;
colour: string;
targetDuration: MaybeNumber;
scheduledDuration: number;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
elapsed: MaybeNumber;
variance: MaybeNumber;
eventsRun: number;
eventsPlanned: number;
};
export type RunSummary = {
eventsRun: number;
eventsPlanned: number;
}; };
/** /**
* Creates a combined report with the rundown data. * Creates a combined report with the rundown data
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/ */
export function getCombinedReport( export function getCombinedReport(
report: OntimeReport, report: OntimeReport,
rundown: RundownEntries, rundown: RundownEntries,
flatOrder: EntryId[], flatOrder: EntryId[],
): CombinedReport[] { ): 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[] = []; const combinedReport: CombinedReport[] = [];
let index = 1; let index = 1;
for (const id of flatOrder) { for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id]; const entry = rundown[id];
// skipped events were never meant to run, listing them alongside events if (!entry || !isOntimeEvent(entry)) continue;
// that did would also disagree with the summary, which excludes them
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
const parent = entry.parent;
const group = parent ? rundown[parent] : undefined;
const reported = report[id];
const scheduledStart = reported?.scheduledStart ?? entry.timeStart;
const scheduledDay = reported?.scheduledDay ?? entry.dayOffset;
const scheduledStartPosition = getReportTimePosition(scheduledStart, scheduledDay);
const actualStartPosition = reported ? getReportTimePosition(reported.startedAt, reported.startedAtDay) : null;
const actualEndPosition = reported ? getReportTimePosition(reported.endedAt, reported.endedAtDay) : null;
const scheduledDuration = reported?.scheduledDuration ?? entry.duration;
if (!(id in report)) {
combinedReport.push({ combinedReport.push({
id, id: id,
index, index: index,
title: entry.title, title: entry.title,
cue: entry.cue, cue: entry.cue,
colour: entry.colour, scheduledStart: entry.timeStart,
parent, actualEnd: null,
groupTitle: group && isOntimeGroup(group) ? group.title : '', scheduledEnd: entry.timeEnd,
// an event that ran is measured against the plan it ran on, one that actualStart: null,
// 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++; index++;
} }
return combinedReport; return combinedReport;
} }
function getOffset(actual: MaybeNumber, scheduled: number): MaybeNumber { const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
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 };
}
/** /**
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that * Transforms a CombinedReport into a CSV string
* positive means behind schedule.
*/
export function formatOffset(value: MaybeNumber): string {
if (value === null) return enDash;
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
}
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
return value > 0 ? 'over' : 'under';
}
function formatCsvTime(value: MaybeNumber): string {
return value === null ? '' : formatTime(value);
}
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string.
*
* Exported as one row per event with its group named, rather than with
* rollups baked in, so it stays the dataset a report is built from.
*/ */
export function makeReportCSV(combinedReport: CombinedReport[]) { export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv = combinedReport.map((entry) => [ const csv: string[][] = [];
String(entry.index), csv.push(csvHeader);
entry.groupTitle,
entry.cue,
entry.title,
formatTime(entry.scheduledStart),
formatCsvTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatCsvTime(entry.actualEnd),
]);
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);
} }
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && ( {!isOntimeCloud && (
<Panel.Section> <Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}> <Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown Ontime Shutdown ontime
</Button> </Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>} {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section> </Panel.Section>
@@ -2,14 +2,12 @@
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: $element-spacing; gap: $element-spacing;
margin-top: $element-inner-spacing;
&.withSource {
grid-template-columns: auto 1fr auto;
}
} }
.label { .label.active {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color; color: $action-text-color;
} }
}
@@ -1,5 +1,6 @@
import { PropsWithChildren, useEffect, useRef, useState } from 'react'; import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -9,12 +10,15 @@ interface InputRowProps {
label: string; label: string;
placeholder: string; placeholder: string;
text: string; text: string;
/** whether this text is currently on the audience screen */
visible: boolean; visible: boolean;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
/** control which picks where the text is shown, rendered before the input */
sourcePicker?: ReactNode;
} }
export default function InputRow(props: PropsWithChildren<InputRowProps>) { export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, children } = props; const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const [value, setValue] = useState(text); const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return ( return (
<div> <div>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}> <Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
{label} {label}
</label> </Editor.Label>
<div className={style.inputItems}> <div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} /> <Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children} {children}
</div> </div>
@@ -0,0 +1,17 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
than growing to whatever height the panel happens to have free */
.screenGroup {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
}
}
@@ -1,18 +1,22 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5'; import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import { import Select from '../../../common/components/select/Select';
setMessage, import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function MessageControl() { export default function MessageControl() {
return ( return (
<> <>
<TimerControlsPreview /> <div className={style.screenGroup}>
<TimerPreview />
<ScreenControl />
</div>
<TimerMessageInput /> <TimerMessageInput />
<SecondaryInput /> <SecondaryInput />
</> </>
@@ -24,7 +28,7 @@ function TimerMessageInput() {
return ( return (
<InputRow <InputRow
label='Timer Message' label='Timer message'
placeholder='Message shown fullscreen in stage timer' placeholder='Message shown fullscreen in stage timer'
text={text} text={text}
visible={visible} visible={visible}
@@ -32,6 +36,7 @@ function TimerMessageInput() {
> >
<IconButton <IconButton
aria-label='Toggle timer message visibility' aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)} onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'} variant={visible ? 'primary' : 'subtle'}
> >
@@ -41,31 +46,46 @@ function TimerMessageInput() {
); );
} }
/**
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
* The select owns which one, the eye owns whether the line is shown at all.
*/
function SecondaryInput() { function SecondaryInput() {
const { text, visible } = useSecondaryMessageInput(); const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const toggleSecondary = () => { const selectedSource = source ?? 'aux1';
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
return ( return (
<InputRow <InputRow
label='Secondary Message' label='Secondary'
placeholder='Message shown as secondary text in stage timer' placeholder='Message shown as secondary text in stage timer'
text={text} text={text}
visible={visible} visible={source === 'secondary'}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)} changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
sourcePicker={
<Select
value={selectedSource}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
setMessage.timerSecondarySource(value);
}}
/>
}
> >
<IconButton <IconButton
aria-label='Toggle secondary message visibility' aria-label='Toggle secondary visibility'
onClick={toggleSecondary} aria-pressed={isShowingSecondaryLine}
variant={visible ? 'primary' : 'subtle'} onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
> >
{visible ? <IoEye /> : <IoEyeOffOutline />} {isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
</IconButton> </IconButton>
</InputRow> </InputRow>
); );
@@ -1,8 +1,21 @@
.growPanel { .growPanel {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* the extracted route has no flex parent to grow into */
.extractedPanel {
height: 100%;
} }
.contentLayout { .contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $section-spacing; gap: $section-spacing;
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals'; import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
@@ -16,7 +17,10 @@ function MessageControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'> <Editor.Panel
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
data-testid='panel-messages-control'
>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
@@ -0,0 +1,14 @@
.screenControl {
display: grid;
grid-auto-flow: row;
gap: $element-spacing;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
width: 100%;
}
}
@@ -0,0 +1,40 @@
import Button from '../../../common/components/buttons/Button';
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
import style from './ScreenControl.module.scss';
export default function ScreenControl() {
const { blackout, blink, isScreenModified } = useScreenControl();
return (
<div className={style.screenControl}>
<Button
variant={blink ? 'primary' : 'subtle'}
aria-pressed={blink}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'destructive' : 'subtle'}
aria-pressed={blackout}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout
</Button>
<Button
variant='subtle'
fluid
disabled={!isScreenModified}
onClick={() => setMessage.clearScreen()}
data-testid='clear screen'
>
Clear screen
</Button>
</div>
);
}
@@ -1,49 +1,26 @@
.preview { /* fixed shape, not stretched to whatever height the panel has free - it fills the
background-color: $ui-black; available width and keeps a 16:9 proportion, which is also what the font size
display: grid; estimate in the embedded timer assumes */
place-content: center; .stage {
text-align: center;
position: relative;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
width: 100%; width: 100%;
color: var(--override-colour, $ui-white); aspect-ratio: 16 / 9;
min-width: 0;
&[data-phase='pending'] { position: relative;
color: $ontime-roll; /* a query container so that the embedded timer scales to the preview, not to the viewport */
} container-type: size;
&[data-phase='overtime'] { contain: paint;
color: $playback-negative; overflow: hidden;
} border-radius: $component-border-radius-md;
&[data-phase='none'] {
opacity: $opacity-disabled;
}
} }
.secondaryContent { /* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
border-top: 1px solid $white-7; .stageChrome {
}
.blackout {
display: none;
}
.eventStatus {
position: absolute; position: absolute;
left: 0; inset: 0;
margin: 0.5rem 0.25rem; z-index: calc($zindex-floating + 2);
display: flex; pointer-events: none;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon { > * {
color: $gray-1000; pointer-events: auto;
&[data-active='true'] {
color: $active-indicator;
} }
} }
@@ -1,110 +1,21 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot'; import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import TimerStatus from './TimerStatus';
import style from './TimerPreview.module.scss'; import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
aux3: 'Aux 3',
secondary: 'Secondary message',
};
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
if (timerType === TimerType.Clock) return 'Clock';
if (countToEnd) return 'Count to End';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
return ( return (
<div className={style.preview}> <div className={style.stage}>
<PipTimer viewSettings={data} />
<div className={style.stageChrome}>
<TimerStatus />
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,17 @@
.timerStatus {
position: absolute;
top: 0;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -0,0 +1,58 @@
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useTimerStatus } from '../../../common/hooks/useSocket';
import style from './TimerStatus.module.scss';
/** Read only summary of how the loaded event drives the stage timer */
export default function TimerStatus() {
const { countToEnd, timerType } = useTimerStatus();
return (
<div className={style.timerStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
);
}
@@ -1,11 +0,0 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -1,92 +0,0 @@
import { SecondarySource } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Select from '../../../common/components/select/Select';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<SecondarySourceControl />
<Editor.Separator orientation='horizontal' />
<Button
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
function SecondarySourceControl() {
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
useEffect(() => {
if (secondarySource !== null) {
setValue(secondarySource);
}
}, [secondarySource]);
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource(value);
}
};
return (
<>
<Select
value={value}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value);
}
setValue(value);
}}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
+30 -19
View File
@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; 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])}> <div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}> <Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span> <span className={style.filterLabel}>Filter by</span>
<ToggleButton <Button
pressed={showUser} variant={showUser ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.User} {LogOrigin.User}
</ToggleButton> </Button>
<ToggleButton <Button
pressed={showClient} variant={showClient ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Client} {LogOrigin.Client}
</ToggleButton> </Button>
<ToggleButton <Button
pressed={showServer} variant={showServer ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Server} {LogOrigin.Server}
</ToggleButton> </Button>
<ToggleButton <Button
pressed={showPlayback} variant={showPlayback ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Playback} {LogOrigin.Playback}
</ToggleButton> </Button>
<ToggleButton <Button
pressed={showRx} variant={showRx ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Rx} {LogOrigin.Rx}
</ToggleButton> </Button>
<ToggleButton <Button
pressed={showTx} variant={showTx ? 'primary' : 'subtle'}
size='small' size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Tx} {LogOrigin.Tx}
</ToggleButton> </Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}> <Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
@@ -18,12 +18,6 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+12 -25
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; 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 EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,10 +25,7 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
/** Keeps the running event clear of the list edge when no group header is pinned above it */ const selectedOffset = 50;
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() { export default function OperatorLoader() {
const { data, status } = useOperatorData(); const { data, status } = useOperatorData();
@@ -57,20 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = 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({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset, topOffset: selectedOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -94,16 +82,15 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) { if (selectedRef?.current && scrollRef?.current) {
return;
}
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
// Measure the drift from where an automated scroll would place the event. if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset(); const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -199,9 +186,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<div className={style.groupSection} key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup <OperatorGroup
ref={isCurrentParent ? stickyHeaderRef : undefined} key={entry.id}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
@@ -252,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</div> </Fragment>
); );
} }
return null; return null;
@@ -1,20 +1,13 @@
.group { .group {
width: 100%; 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; min-height: 2.5rem;
padding: 0.25rem 0.75rem; padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; 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 { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -10,16 +10,15 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( 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.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -8,16 +8,10 @@ export default memo(FinderPlacement);
function FinderPlacement() { function FinderPlacement() {
const [isOpen, handler] = useDisclosure(); const [isOpen, handler] = useDisclosure();
/** useHotkeys([
* The empty tagsToIgnore is significant: by default the hook skips input elements, ['mod + f', handler.toggle, { preventDefault: true }],
* which would make the shortcut dead while editing an entry. ['Escape', handler.close, { preventDefault: true }],
* ]);
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) { if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />; return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,7 +312,6 @@ export default function RundownEvent({
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-testid='rundown-event' data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})} {...(isPlaying ? { 'data-running': true } : {})}
> >
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} /> <RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -141,6 +141,7 @@ function RundownEventInner({
isPast={isPast} isPast={isPast}
isLoaded={loaded} isLoaded={loaded}
totalGap={totalGap} totalGap={totalGap}
duration={duration}
/> />
)} )}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}> <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 Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { usePlayback } from '../../../../common/hooks/useSocket'; import { usePlayback } from '../../../../common/hooks/useSocket';
import { getEventVariance } from '../../../../common/utils/report';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -21,6 +20,7 @@ interface RundownEventChipProps {
isLoaded: boolean; isLoaded: boolean;
className: string; className: string;
totalGap: number; totalGap: number;
duration: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
@@ -33,6 +33,7 @@ export default function RundownEventChip({
className, className,
totalGap, totalGap,
id, id,
duration,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEventChipProps) { }: RundownEventChipProps) {
const playback = usePlayback(); const playback = usePlayback();
@@ -44,9 +45,11 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback); const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) { if (!playbackActive || isPast) {
return <EventReport className={className} id={id} />; return <EventReport className={className} id={id} duration={duration} />;
} }
if (playbackActive) {
// we extracted the component to avoid unnecessary calculations and re-renders
return ( return (
<Tooltip text='Expected time until start' render={<span />} className={className}> <Tooltip text='Expected time until start' render={<span />} className={className}>
<EventUntil <EventUntil
@@ -60,6 +63,9 @@ export default function RundownEventChip({
); );
} }
return null;
}
interface EventUntilProps { interface EventUntilProps {
timeStart: number; timeStart: number;
delay: number; delay: number;
@@ -80,30 +86,41 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps { interface EventReportProps {
className: string; className: string;
id: string; id: string;
duration: number;
} }
function EventReport({ className, id }: EventReportProps) { function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { data } = useReport(); const { data } = useReport();
const currentReport = data[id]; const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => { const [value, overUnderStyle, tooltip] = useMemo(() => {
// Use the schedule recorded when the event ran so later rundown edits do if (!currentReport) {
// not change its report.
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', '']; 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']; return ['ontime', 'under', 'Event finished on time'];
} }
const absDifference = Math.abs(variance.delta); const isOver = difference > 0;
const isOver = variance.status === 'over';
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${millisToString(absDifference)}`; const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, variance.status, tooltip]; return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport]); }, [currentReport, duration]);
if (!value) { if (!value) {
return null; return null;
+3 -31
View File
@@ -100,20 +100,7 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: 95vh; padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* 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;
}
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -209,14 +196,9 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); 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:
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), 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 { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); 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 { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; 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 { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,7 +22,6 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -49,22 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = 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({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
getTopOffset: getStickyOffset, topOffset: 0,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -87,16 +75,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (!selectedRef.current || !scrollRef.current) { if (selectedRef?.current && scrollRef?.current) {
return;
}
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header. if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset(); const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -111,14 +98,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{sections.map((section) => { {subscribedEvents.map((event) => {
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;
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 // 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 liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
@@ -127,24 +107,12 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// a subscribed group is live when any of its children is the selected/running event // a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId; 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 countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
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'); const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div
key={event.id} key={event.id}
ref={rowRef} ref={isLive ? selectedRef : undefined}
className={cx([ className={cx([
'sub', 'sub',
isLive && 'sub--live', isLive && 'sub--live',
@@ -168,9 +136,6 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
</div> </div>
); );
})} })}
</div>
);
})}
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}> <div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoPencil /> Edit <IoPencil /> Edit
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; 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. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); 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( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
@@ -105,10 +105,10 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
return null; return null;
} }
const { hideTableSeconds } = table.options.meta.options;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
const duration = getValue() as number; return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
return <MutedText numeric>{formatDuration(duration, false)}</MutedText>;
} }
const { handleUpdateTimer } = table.options.meta; const { handleUpdateTimer } = table.options.meta;
@@ -117,7 +117,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
const duration = getValue() as number; const duration = getValue() as number;
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration; const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, false); const formattedDuration = formatDuration(duration, hideTableSeconds);
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { if (!canWrite) {
@@ -3,14 +3,11 @@
.error { .error {
padding-inline: 0.5rem; padding-inline: 0.5rem;
font-size: 1rem; font-size: 1rem;
// rows grow when a match is shown from a note or custom field height: 3rem;
min-height: 3rem;
padding-block: 0.35rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem;
} }
.entry[data-selected='true'] { .entry[data-selected='true'] {
@@ -21,47 +18,21 @@
color: $label-gray; 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 { .error {
color: $error-red; 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 { .data {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'index cue' 'index cue'
'index title' 'index title';
'index match';
column-gap: 1rem; column-gap: 1rem;
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
min-width: 0;
.index { .index {
grid-area: index; grid-area: index;
// background and text colour come from getAccessibleColour, which keeps the background-color: var(--color, $gray-1000);
// number legible whatever colour the user gave the entry
border-radius: 2px; border-radius: 2px;
padding-block: 0.25rem; padding-block: 0.25rem;
width: 3.5rem; width: 3.5rem;
@@ -71,33 +42,14 @@
.title { .title {
grid-area: title; grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.cue { .cue {
grid-area: cue; grid-area: cue;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $label-gray; color: $label-gray;
max-height: 1em;
min-height: 0; 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; color: $label-gray;
} }
.count { .filterHint {
text-align: right; text-align: right;
white-space: nowrap;
} }
.go { .em {
white-space: nowrap; color: $ui-white;
padding-left: 1rem; margin-inline: 0.25rem;
} }
.hints { .hints {
@@ -147,7 +98,7 @@
flex-direction: column; flex-direction: column;
} }
.count { .filterHint {
text-align: left; text-align: left;
} }
} }
+39 -104
View File
@@ -1,12 +1,11 @@
import { MaybeString } from 'ontime-types'; import { useDebouncedCallback } from '@mantine/hooks';
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react'; 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 Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd'; import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import useFinder from './useFinder';
import useFinder, { FinderResult } from './useFinder';
import style from './Finder.module.scss'; import style from './Finder.module.scss';
@@ -16,76 +15,46 @@ interface FinderProps {
} }
export default function Finder({ isOpen, onClose }: FinderProps) { export default function Finder({ isOpen, onClose }: FinderProps) {
const [search, setSearch] = useState(''); const { find, select, results, error } = useFinder();
const [filter, setFilter] = useState<MaybeString>(null); const [selected, setSelected] = useState(0);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
/** const debouncedFind = useDebouncedCallback(find, 100);
* 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 navigate = (event: KeyboardEvent<HTMLDivElement>) => { 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 // all operations need results
if (results.length === 0) { if (results.length === 0) {
return; return;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
setSelectedId(results[(activeIndex + 1) % results.length].id); setSelected((prev) => (prev + 1) % results.length);
} }
if (event.key === 'ArrowUp') { 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') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
submit(activeEntry); submit();
} }
}; };
const submit = (entry: FinderResult | undefined) => { const submit = () => {
if (!entry) { const selectedEvent = results[selected];
return; select(selectedEvent);
}
select(entry);
onClose(); onClose();
}; };
/** Scopes the search to a single field, or back to all fields when tapped again */ const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const handleFilter = (filterKey: string) => { const target = event.target as HTMLElement;
setFilter((previous) => (previous === filterKey ? null : filterKey)); const li = target.closest('li');
inputRef.current?.focus(); if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
}; };
const hiddenResults = total - results.length;
return ( return (
<Modal <Modal
title='' title=''
@@ -94,68 +63,35 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop showBackdrop
bodyElements={ bodyElements={
<div onKeyDown={navigate}> <div onKeyDown={navigate}>
<Input <Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
ref={inputRef} <ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
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}>
{error && <li className={style.error}>{error}</li>} {error && <li className={style.error}>{error}</li>}
{!error && results.length === 0 && <li className={style.empty}>No results</li>} {results.length === 0 && <li className={style.empty}>No results</li>}
{results.map((entry) => { {results.length > 0 &&
const isSelected = activeEntry?.id === entry.id; results.map((entry, index) => {
// the title and cue are already on the row, a match anywhere else needs showing const isSelected = selected === index;
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue'; const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = 'cue' in entry ? entry.cue : '';
return ( return (
<li <li
key={entry.id} key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry} className={style.entry}
data-testid='finder-result'
data-selected={isSelected} data-selected={isSelected}
onClick={() => submit(entry)} data-index={index}
onPointerEnter={() => setSelectedId(entry.id)} onClick={submit}
> >
<div className={style.data}> <div className={style.data}>
<div className={style.index} style={getAccessibleColour(entry.colour)}> <div className={style.index} style={{ '--color': entry.colour }}>
{entry.eventIndex ?? '-'} {displayIndex}
</div> </div>
<div className={style.cue}>{entry.cue}</div> <div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</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}
</div> </div>
)} {isSelected && <span>Go </span>}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li> </li>
); );
})} })}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul> </ul>
</div> </div>
} }
@@ -176,11 +112,10 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close Close
</span> </span>
</div> </div>
{total > 0 && ( <div className={style.filterHint}>
<div className={style.count} data-testid='finder-count'> Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`} <span className={style.em}>title</span>
</div> </div>
)}
</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 });
});
});
+205 -225
View File
@@ -1,259 +1,239 @@
import { import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
CustomFields, import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
/** How many results we render, the total number of matches is reported separately */ const maxResults = 12;
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
const indexFilter = 'index'; type FilterableGroup = {
type: SupportedEntry.Group;
/** 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 = {
id: EntryId; id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number; index: number;
/** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string; title: string;
/** groups have no cue */ colour: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: EntryId;
index: number;
eventIndex: number;
title: string;
cue: string; cue: string;
colour: string; colour: string;
parent: MaybeString; 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 */ export default function useFinder() {
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) {
const { data, rundownId } = useFlatRundown(); 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); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** The filters offered to the user: the fixed fields plus whatever the project defines */ /** Filters the rundown to a given evaluation */
const filters = useMemo<FinderFilter[]>(() => { const find = useCallback(
const customFilters = Object.entries(customFields) (event: ChangeEvent<HTMLInputElement>) => {
.filter(([_key, field]) => field.type === 'text') if (!data || data.length === 0) {
.map(([key, field]) => ({ key, label: field.label || key })); setError('No data');
return [...staticFilters, ...customFilters]; return;
}, [customFields]); }
setError(null);
const { results, error, total, appliedFilter } = useMemo(() => { if (event.target.value === '') {
if (data.length === 0) { setResults([]);
return { ...noResults, error: 'No data', appliedFilter: activeFilter }; return;
} }
const normalised = searchValue.trim().toLowerCase(); const searchValue = event.target.value.toLowerCase();
if (normalised === '') { lastSearchString.current = searchValue;
return { ...noResults, appliedFilter: activeFilter };
if (searchValue.startsWith('index ')) {
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
} }
/** if (searchValue.startsWith('cue ')) {
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input const searchString = searchValue.slice('cue '.length).trim();
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted. const { results, error } = searchByCue(searchString);
*/ setResults(results);
const { filterKey, searchString } = activeFilter setError(error);
? { filterKey: activeFilter, searchString: normalised } return;
: parseQuery(normalised, filters); }
if (filterKey === indexFilter) { const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
return { ...searchByIndex(data, searchString), appliedFilter: filterKey }; 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 (searchString === '') {
// a filter is selected, but there is nothing to match on yet if (searchIndex > data.length) {
return { ...noResults, appliedFilter: filterKey }; return { results: [], error: null };
} }
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
}, [data, customFields, filters, searchValue, activeFilter]); // 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( const select = useCallback(
(result: FinderResult) => { (selectedEvent: FilterableEntry) => {
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent }); selectAndRevealEntry({
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
}, },
[selectAndRevealEntry], [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 };
} }
@@ -6,8 +6,9 @@
padding: 0; padding: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%;
height: 100vh; height: 100%; /* fill the pip window or the editor preview frame */
position: relative; /* anchor for the blackout and message overlays */
transition: opacity 0.5s ease-in-out; transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family; font-family: $viewer-font-family;
@@ -19,8 +20,8 @@
flex-direction: column; flex-direction: column;
&--finished { &--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color; outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1); outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
transition: $viewer-transition-time; transition: $viewer-transition-time;
} }
@@ -107,10 +108,24 @@
/* =================== OVERLAY ===================*/ /* =================== OVERLAY ===================*/
.message-overlay { .blackout {
position: fixed; position: absolute;
inset: 0; inset: 0;
padding: 2vw; z-index: 0;
background-color: #000;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: calc($zindex-floating + 1);
opacity: 1;
}
}
.message-overlay {
position: absolute;
inset: 0;
padding: 2cqw;
background: $viewer-background-color; background: $viewer-background-color;
opacity: 0; opacity: 0;
transition: opacity $viewer-transition-time; transition: opacity $viewer-transition-time;
@@ -41,9 +41,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather timer data // gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime); const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow); const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
// match the defaults of the timer view, which is what the preview is standing in for
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false, removeSeconds: false,
removeLeadingZero: false, removeLeadingZero: true,
}); });
const currentAux = (() => { const currentAux = (() => {
@@ -63,23 +64,27 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
// the estimate is tuned for a 16:9 screen, so cap it by height for containers wider than that
const timerFontSize = getEstimatedFontSize(display, secondaryContent); const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const timerFontRule = `min(${timerFontSize}cqw, ${((timerFontSize * 16) / 9).toFixed(2)}cqh)`;
const userStyles = { const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }), ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
}; };
return ( return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}> <div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}> <div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}> <FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text} {message.timer.text}
</FitText> </FitText>
</div> </div>
<div className='timer-container'> <div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: timerFontRule }}
data-phase={time.phase} data-phase={time.phase}
> >
{display} {display}
-18
View File
@@ -195,24 +195,6 @@
font-weight: 600; 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 ===================*/
.logo { .logo {
position: absolute; 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 ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket'; import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -31,7 +30,6 @@ import {
getTotalTime, getTotalTime,
} from './timer.utils'; } from './timer.utils';
import { TimerData, useTimerData } from './useTimerData'; import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss'; import './Timer.scss';
@@ -68,7 +66,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime, freezeOvertime,
freezeMessage, freezeMessage,
hidePhase, hidePhase,
endSound,
font, font,
keyColour, keyColour,
timerColour, timerColour,
@@ -78,8 +75,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -161,8 +156,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} /> <ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} /> <div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && ( {!hideMessage && (
@@ -234,13 +227,3 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div> </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 { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils'; import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None // manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [ const timerDisplayOptions: SelectOption[] = [
@@ -77,15 +76,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean', type: 'boolean',
defaultValue: false, 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; freezeOvertime: boolean;
freezeMessage: string; freezeMessage: string;
hidePhase: boolean; hidePhase: boolean;
endSound: EndSound;
font?: string; font?: string;
keyColour?: string; keyColour?: string;
timerColour?: string; timerColour?: string;
@@ -219,7 +208,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source // Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None); const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return { return {
hideClock: isStringBoolean(getValue('hideClock')), hideClock: isStringBoolean(getValue('hideClock')),
@@ -239,7 +227,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')), freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '', freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')), hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined, font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')), 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, 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", "name": "ontime-electron",
"version": "4.13.0", "version": "4.12.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/> />
<title>Ontime</title> <title>ontime</title>
<style> <style>
body { body {
-webkit-user-select: none; -webkit-user-select: none;
@@ -92,7 +92,7 @@
<body> <body>
<div class="container"> <div class="container">
<img src="../assets/logo.png" /> <img src="../assets/logo.png" />
<h1>Ontime · event timers</h1> <h1>ontime · event timers</h1>
<div class="lds-ellipsis"> <div class="lds-ellipsis">
<div></div> <div></div>
<div></div> <div></div>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.13.0", "version": "4.12.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
@@ -17,7 +17,7 @@
"parser" "parser"
], ],
"author": "", "author": "",
"license": "ISC", "license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"ontime-types": "workspace:^4.2.1", "ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4", "tsdown": "^0.22.4",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.13.0", "version": "4.12.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -1,164 +0,0 @@
import type { PlayableEvent } from 'ontime-types';
import { RefetchKey, TimerLifeCycle } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { vi } from 'vitest';
import { sendRefetch } from '../../../adapters/WebsocketAdapter.js';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { clear, generate, generateReport, triggerReportEntry } from '../report.service.js';
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({ sendRefetch: vi.fn() }));
const eventA = makeOntimeEvent({
id: 'event-a',
dayOffset: 0,
timeStart: 0,
timeEnd: MILLIS_PER_MINUTE,
duration: MILLIS_PER_MINUTE,
}) as PlayableEvent;
const eventB = makeOntimeEvent({
id: 'event-b',
dayOffset: 0,
timeStart: MILLIS_PER_MINUTE,
timeEnd: 2 * MILLIS_PER_MINUTE,
duration: MILLIS_PER_MINUTE,
}) as PlayableEvent;
beforeEach(() => {
clear();
vi.clearAllMocks();
});
it('records lifecycle times while keeping the schedule captured at start', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, _startEpoch: 1 });
triggerReportEntry(TimerLifeCycle.onStart, start);
const edited = { ...eventA, timeStart: 999, duration: 999 } as PlayableEvent;
const stop = makeRuntimeStateData({ eventNow: edited, clock: 2 * MILLIS_PER_MINUTE, rundown: { currentDay: 1 } });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toEqual({
startedAt: 500,
startedAtDay: 0,
endedAt: 2 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: eventA.timeStart,
scheduledDay: eventA.dayOffset,
scheduledDuration: eventA.duration,
});
expect(sendRefetch).toHaveBeenCalledTimes(2);
expect(sendRefetch).toHaveBeenLastCalledWith(RefetchKey.Report);
});
it('falls back to the current event when a stop arrives without a start', () => {
const stop = makeRuntimeStateData({ eventNow: eventA, clock: MILLIS_PER_MINUTE });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: null,
endedAt: MILLIS_PER_MINUTE,
scheduledDuration: eventA.duration,
});
});
it('captures the rundown plan when a stop is the first report entry', () => {
const rundown = makeRundown({
id: 'run-1',
title: 'Stopped without start',
order: [eventA.id],
entries: { [eventA.id]: eventA },
});
const stop = makeRuntimeStateData({
eventNow: eventA,
clock: MILLIS_PER_MINUTE,
rundown: { plannedStart: 0, plannedEnd: MILLIS_PER_MINUTE },
});
triggerReportEntry(TimerLifeCycle.onStop, stop, rundown);
expect(generateReport()).toMatchObject({
eventReports: { [eventA.id]: { endedAt: MILLIS_PER_MINUTE } },
rundown: { id: rundown.id, title: rundown.title },
});
});
it('accumulates entries until the report is explicitly cleared', () => {
const firstRun = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 });
triggerReportEntry(TimerLifeCycle.onStart, firstRun);
triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, eventNow: eventB, _startEpoch: 2 });
expect(Object.keys(generate())).toEqual([eventA.id, eventB.id]);
});
it('returns the report with the rundown plan captured at its first event', () => {
const rundown = makeRundown({
id: 'run-1',
title: 'Original title',
order: [eventA.id, eventB.id],
entries: { [eventA.id]: eventA, [eventB.id]: eventB },
});
const start = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 500 },
_startEpoch: 1,
rundown: { plannedStart: 0, plannedEnd: 2 * MILLIS_PER_MINUTE },
});
triggerReportEntry(TimerLifeCycle.onStart, start, rundown);
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: MILLIS_PER_MINUTE });
rundown.title = 'Edited later';
expect(generateReport()).toMatchObject({
rundown: { id: 'run-1', title: 'Original title' },
eventReports: { [eventA.id]: { scheduledDuration: MILLIS_PER_MINUTE } },
show: {
plannedStart: 0,
plannedEnd: 2 * MILLIS_PER_MINUTE,
plannedDuration: 2 * MILLIS_PER_MINUTE,
actualStart: 500,
actualEnd: MILLIS_PER_MINUTE,
},
});
});
it('clears the retained report and rundown snapshot together', () => {
const rundown = makeRundown({ order: [eventA.id], entries: { [eventA.id]: eventA } });
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 });
triggerReportEntry(TimerLifeCycle.onStart, state, rundown);
vi.clearAllMocks();
clear();
expect(generateReport()).toMatchObject({ eventReports: {}, rundown: null });
expect(sendRefetch).toHaveBeenCalledOnce();
expect(sendRefetch).toHaveBeenCalledWith(RefetchKey.Report);
});
it('captures a new plan after removing the final event by id', () => {
const firstRundown = makeRundown({
id: 'run-1',
title: 'First run',
order: [eventA.id],
entries: { [eventA.id]: eventA },
});
const secondRundown = makeRundown({
id: 'run-2',
title: 'Second run',
order: [eventB.id],
entries: { [eventB.id]: eventB },
});
const firstStart = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 });
const secondStart = makeRuntimeStateData({
eventNow: eventB,
timer: { startedAt: MILLIS_PER_MINUTE },
_startEpoch: 2,
});
triggerReportEntry(TimerLifeCycle.onStart, firstStart, firstRundown);
clear(eventA.id);
triggerReportEntry(TimerLifeCycle.onStart, secondStart, secondRundown);
expect(generateReport()).toMatchObject({
eventReports: { [eventB.id]: { startedAt: MILLIS_PER_MINUTE } },
rundown: { id: secondRundown.id, title: secondRundown.title },
});
});
@@ -1,72 +0,0 @@
import type { OntimeEventReport, OntimeReport, PlayableEvent } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { getActualShowTimes, getPlannedShowDuration } from '../report.utils.js';
function makeReport(patch: Partial<OntimeEventReport>): OntimeEventReport {
return {
startedAt: 0,
startedAtDay: 0,
endedAt: 0,
endedAtDay: 0,
scheduledStart: 0,
scheduledDay: 0,
scheduledDuration: 0,
...patch,
};
}
describe('getActualShowTimes()', () => {
it('preserves long gaps within the same day', () => {
const report: OntimeReport = {
morning: makeReport({ startedAt: 6 * MILLIS_PER_HOUR, endedAt: 7 * MILLIS_PER_HOUR }),
evening: makeReport({ startedAt: 20 * MILLIS_PER_HOUR, endedAt: 21 * MILLIS_PER_HOUR }),
};
expect(getActualShowTimes(report)).toEqual({
actualStart: 6 * MILLIS_PER_HOUR,
actualEnd: 21 * MILLIS_PER_HOUR,
actualDuration: 15 * MILLIS_PER_HOUR,
});
});
it('orders events by their captured day across midnight', () => {
const report: OntimeReport = {
beforeMidnight: makeReport({
startedAt: dayInMs - 10 * MILLIS_PER_MINUTE,
endedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
}),
afterMidnight: makeReport({
startedAt: 0,
startedAtDay: 1,
endedAt: 10 * MILLIS_PER_MINUTE,
endedAtDay: 1,
}),
};
expect(getActualShowTimes(report).actualDuration).toBe(20 * MILLIS_PER_MINUTE);
});
});
it('derives planned duration from playable, non-skipped events', () => {
const first = makeOntimeEvent({
id: 'first',
dayOffset: 0,
timeStart: 23 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
}) as PlayableEvent;
const last = makeOntimeEvent({
id: 'last',
dayOffset: 1,
timeStart: MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR,
}) as PlayableEvent;
const skipped = makeOntimeEvent({ id: 'skipped', dayOffset: 2, timeStart: 0, duration: MILLIS_PER_HOUR, skip: true });
const rundown = makeRundown({
order: [first.id, last.id, skipped.id],
entries: { [first.id]: first, [last.id]: last, [skipped.id]: skipped },
});
expect(getPlannedShowDuration(rundown)).toBe(3 * MILLIS_PER_HOUR);
});
@@ -7,7 +7,7 @@ import * as report from './report.service.js';
export const router: Router = express.Router(); export const router: Router = express.Router();
router.get('/', (_req: Request, res: Response) => { router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generateReport()); res.status(200).json(report.generate());
}); });
router.delete('/all', (_req: Request, res: Response) => { router.delete('/all', (_req: Request, res: Response) => {
@@ -1,28 +1,12 @@
import type { OntimeEventReport, OntimeReport, PlayableEvent, ReportData, Rundown, ShowReport } from 'ontime-types'; import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
import { RefetchKey, TimerLifeCycle } from 'ontime-types'; import { DeepReadonly } from 'ts-essentials';
import type { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import type { RuntimeState } from '../../stores/runtimeState.js'; import { RuntimeState } from '../../stores/runtimeState.js';
import { getCurrentRundown } from '../rundown/rundown.dao.js';
import { getActualShowTimes, getPlannedShowDuration } from './report.utils.js';
const report = new Map<string, OntimeEventReport>(); const report = new Map<string, OntimeEventReport>();
let formattedReport: OntimeReport | null = null; let formattedReport: OntimeReport | null = null;
const emptyPlannedTimes: Pick<ShowReport, 'plannedStart' | 'plannedEnd' | 'plannedDuration'> = {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
};
/**
* The plan the show was measured against, taken when it starts.
* Snapshotted for the same reason the per event schedule is: editing the
* rundown afterwards must not move the target a past show was judged by.
*/
let plannedTimes = emptyPlannedTimes;
let rundownSnapshot: Rundown | null = null;
/** /**
* generates a full report * generates a full report
@@ -43,14 +27,9 @@ export function clear(id?: string) {
formattedReport = null; formattedReport = null;
if (id) { if (id) {
report.delete(id); report.delete(id);
if (report.size === 0) resetReportPlan();
} else { } else {
// A full clear makes the next event start a new report instead of resuming this run.
report.clear(); report.clear();
resetReportPlan();
} }
sendRefetch(RefetchKey.Report);
} }
/** /**
@@ -62,7 +41,6 @@ export function clear(id?: string) {
export function triggerReportEntry( export function triggerReportEntry(
cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop, cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop,
state: DeepReadonly<RuntimeState>, state: DeepReadonly<RuntimeState>,
rundown: Readonly<Rundown> = getCurrentRundown(),
) { ) {
if (!state.eventNow?.id) { if (!state.eventNow?.id) {
return; return;
@@ -71,80 +49,15 @@ export function triggerReportEntry(
const eventId = state.eventNow.id; const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) { if (cycle === TimerLifeCycle.onStart) {
captureReportPlan(state, rundown); report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
report.set(eventId, {
...getScheduleSnapshot(state.eventNow),
startedAt: state.timer.startedAt,
startedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset,
endedAt: null,
endedAtDay: null,
});
formattedReport = null; formattedReport = null;
sendRefetch(RefetchKey.Report);
return; return;
} }
if (cycle === TimerLifeCycle.onStop) { if (cycle === TimerLifeCycle.onStop) {
captureReportPlan(state, rundown); const startedAt = report.get(eventId)?.startedAt ?? null;
const previous = report.get(eventId); report.set(eventId, { startedAt, endedAt: state.clock });
const schedule = previous ?? getScheduleSnapshot(state.eventNow);
report.set(eventId, {
startedAt: previous?.startedAt ?? null,
startedAtDay: previous?.startedAtDay ?? null,
endedAt: state.clock,
endedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset,
scheduledStart: schedule.scheduledStart,
scheduledDay: schedule.scheduledDay,
scheduledDuration: schedule.scheduledDuration,
});
formattedReport = null; formattedReport = null;
sendRefetch(RefetchKey.Report); sendRefetch(RefetchKey.Report);
} }
} }
function getScheduleSnapshot(
event: Pick<PlayableEvent, 'timeStart' | 'dayOffset' | 'duration'>,
): Pick<OntimeEventReport, 'scheduledStart' | 'scheduledDay' | 'scheduledDuration'> {
return {
scheduledStart: event.timeStart,
scheduledDay: event.dayOffset,
scheduledDuration: event.duration,
};
}
/**
* Captures the plan once, when the first event in the report is recorded.
*/
function captureReportPlan(state: DeepReadonly<RuntimeState>, rundown: Readonly<Rundown>) {
if (rundownSnapshot !== null) return;
rundownSnapshot = structuredClone(rundown);
plannedTimes = {
plannedStart: state.rundown.plannedStart,
plannedEnd: state.rundown.plannedEnd,
plannedDuration: getPlannedShowDuration(rundownSnapshot),
};
}
function resetReportPlan() {
plannedTimes = emptyPlannedTimes;
rundownSnapshot = null;
}
/**
* Show level times for the report.
* Planned times are the ones captured when the show started, actual times are
* derived from the events that ran.
*/
function generateShowReport(): ShowReport {
return { ...plannedTimes, ...getActualShowTimes(generate()) };
}
export function generateReport(): ReportData {
return {
eventReports: generate(),
rundown: rundownSnapshot,
show: generateShowReport(),
};
}
@@ -1,52 +0,0 @@
import type { OntimeReport, Rundown, ShowReport } from 'ontime-types';
import { isOntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
export function getActualShowTimes(
report: OntimeReport,
): Pick<ShowReport, 'actualStart' | 'actualEnd' | 'actualDuration'> {
let firstStart = Number.POSITIVE_INFINITY;
let lastEnd = Number.NEGATIVE_INFINITY;
let actualStart: number | null = null;
let actualEnd: number | null = null;
for (const entry of Object.values(report)) {
if (entry.startedAt !== null && entry.startedAtDay !== null) {
const start = entry.startedAtDay * dayInMs + entry.startedAt;
if (start < firstStart) {
firstStart = start;
actualStart = entry.startedAt;
}
}
if (entry.endedAt !== null && entry.endedAtDay !== null) {
const end = entry.endedAtDay * dayInMs + entry.endedAt;
if (end > lastEnd) {
lastEnd = end;
actualEnd = entry.endedAt;
}
}
}
return {
actualStart,
actualEnd,
actualDuration: actualStart === null || actualEnd === null ? null : lastEnd - firstStart,
};
}
export function getPlannedShowDuration(rundown: Rundown): number | null {
let firstStart = Number.POSITIVE_INFINITY;
let lastEnd = Number.NEGATIVE_INFINITY;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
const start = entry.dayOffset * dayInMs + entry.timeStart;
firstStart = Math.min(firstStart, start);
lastEnd = Math.max(lastEnd, start + entry.duration);
}
return Number.isFinite(firstStart) ? lastEnd - firstStart : null;
}
@@ -1,7 +1,5 @@
import { deepEqual } from 'fast-equals';
import { import {
EndAction, EndAction,
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
@@ -10,21 +8,13 @@ import {
TimerType, TimerType,
Trigger, Trigger,
} from 'ontime-types'; } from 'ontime-types';
import { import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { assertType } from 'vitest'; import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js'; import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -725,68 +715,3 @@ describe('eventDurationMatchGroupTarget()', () => {
expect(result).toStrictEqual(null); expect(result).toStrictEqual(null);
}); });
}); });
describe('cloneEntryData()', () => {
const trigger: Trigger = {
id: 'trigger-1',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-1',
};
// the real factories, so these are complete entries exactly as the rundown holds them
const entries: [string, OntimeEntry][] = [
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
['delay', createDelay({ id: 'delay-1', duration: 10 })],
];
/**
* Fails if any nested object or array in the clone is the same reference as the source,
* so a field added later that needs a copy of its own is caught here without the clone
* having to enumerate fields.
*/
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
if (typeof source !== 'object' || source === null) return;
expect(clone, `${path} is shared with the source`).not.toBe(source);
const cloneRecord = clone as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
for (const key of Object.keys(sourceRecord)) {
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
}
}
/** the two halves of the structuredClone contract: same value, no shared references */
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
});
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
});
/**
* Regression: normalising an absent container to an empty one makes deepEqual report a
* change on every comparison, which would have the runtime re-broadcast and re-save the
* restore point on every tick. See PR #2178.
*/
it.each([
['event', makeOntimeEvent({ id: 'partial' })],
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
const clone = cloneEntryData(entry);
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
// key holding undefined, and it is key presence that decides the deepEqual below
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
// entry; if the clone gains a key, every tick looks like a change
expect(deepEqual(clone, entry)).toBe(true);
});
it('throws on an entry type it does not know how to clone', () => {
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
'Unsupported entry type for cloning',
);
});
});
+14 -38
View File
@@ -27,7 +27,6 @@ import {
isPlayableEvent, isPlayableEvent,
} from 'ontime-types'; } from 'ontime-types';
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils'; import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
import type { DeepReadonly } from 'ts-essentials';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js'; import { consoleError } from '../../utils/console.js';
@@ -35,7 +34,6 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
applyPatchToEntry, applyPatchToEntry,
cloneRundown,
cloneSimpleRundownEntry, cloneSimpleRundownEntry,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
@@ -45,14 +43,9 @@ import {
} from './rundown.utils.js'; } from './rundown.utils.js';
/** /**
* The currently loaded rundown in cache. * The currently loaded rundown in cache
*
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
* layer stores this object by reference, so repurposing it for another rundown would rewrite
* the previously loaded rundown's stored record. Mutating it in place while it represents the
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
*/ */
let cachedRundown: Rundown = { const cachedRundown: Rundown = {
id: '', id: '',
title: '', title: '',
order: [], order: [],
@@ -86,16 +79,9 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields; export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId]; export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
/** type Transaction = {
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
* - which would silently corrupt the live cache without going through commit() - is a
* compile-time error instead of a runtime bug.
*/
type Transaction<R> = {
customFields: CustomFields; customFields: CustomFields;
rundown: R; rundown: Rundown;
commit: (shouldProcess?: boolean) => Promise<{ commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>; rundown: Readonly<Rundown>;
@@ -116,17 +102,11 @@ type TransactionOptions = {
rundownId?: string; rundownId?: string;
}; };
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>; export function createTransaction(options: TransactionOptions): Transaction {
export function createTransaction(
options: TransactionOptions & { mutableRundown?: false },
): Transaction<DeepReadonly<Rundown>>;
export function createTransaction(
options: TransactionOptions,
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
const targetId = options.rundownId ?? cachedRundown.id; const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id; const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown); const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown; const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/** /**
@@ -727,25 +707,21 @@ export const customFieldMutation = {
* Expose function to add an initial rundown to the system * Expose function to add an initial rundown to the system
*/ */
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) { export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = cloneRundown(initialRundown); const rundown = structuredClone(initialRundown);
const customFields = structuredClone(initialCustomFields); const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields, { mutate: true }); const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
projectCustomFields = customFields; projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
// a fresh object, so that the record already stored for a previously loaded rundown keeps cachedRundown.order = order;
// pointing at that rundown's data - see the note on cachedRundown cachedRundown.flatOrder = metadata.flatEntryOrder;
cachedRundown = { cachedRundown.revision = rundown.revision;
id: rundown.id,
title: rundown.title,
entries,
order,
flatOrder: metadata.flatEntryOrder,
revision: rundown.revision,
};
rundownMetadata = metadata; rundownMetadata = metadata;
// defer writing to the database // defer writing to the database
@@ -33,7 +33,7 @@ import {
import { makeNewRundown } from '../../models/dataModel.js'; import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js'; import { RundownMetadata } from './rundown.types.js';
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js'; import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
/** /**
* Parse a rundowns object along with the project custom fields * Parse a rundowns object along with the project custom fields
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
}; };
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T { function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup); return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
} }
function getMetadata(): ProcessedRundownMetadata { function getMetadata(): ProcessedRundownMetadata {
@@ -39,7 +39,6 @@ import {
import { parseRundown } from './rundown.parser.js'; import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
cloneRundown,
generateEvent, generateEvent,
getFirstInsertId, getFirstInsertId,
getIntegerAndFraction, getIntegerAndFraction,
@@ -627,7 +626,7 @@ export async function editCustomField(
// ... reassign references in the background rundowns // ... reassign references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = cloneRundown(projectRundowns[rundownId]); const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey); customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -667,7 +666,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// remove references in the background rundowns // remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = cloneRundown(projectRundowns[rundownId]); const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key); customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -847,7 +846,7 @@ export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider(); const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id); const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = cloneRundown(rundown); const duplicatedRundown: Rundown = structuredClone(rundown);
duplicatedRundown.id = generateId(); duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`; duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0; duplicatedRundown.revision = 0;
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
const structure = isOntimeGroup(incomingEntry) const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries } ? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent }; : { parent: incomingEntry.parent };
entries[id] = cloneEntryData({ ...merged, ...structure }); entries[id] = structuredClone({ ...merged, ...structure });
} }
return { return {
@@ -499,61 +499,6 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
throw new Error(`Unsupported entry type for cloning: ${entry}`); throw new Error(`Unsupported entry type for cloning: ${entry}`);
} }
/**
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
* Drop-in replacement for `structuredClone(entry)`
*/
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
switch (entry.type) {
case SupportedEntry.Event: {
const clone: OntimeEvent = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
return clone as T;
}
case SupportedEntry.Group: {
const clone: OntimeGroup = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.entries) clone.entries = clone.entries.slice();
return clone as T;
}
case SupportedEntry.Milestone: {
const clone: OntimeMilestone = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
return clone as T;
}
case SupportedEntry.Delay:
return { ...entry } as T;
default: {
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
const unhandled: never = entry;
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
}
}
}
/**
* Fast, shape-aware clone of a whole rundown.
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
* the result freely without touching the source - same contract as structuredClone,
* at a fraction of the cost since we skip the generic serialization algorithm.
*/
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
const entries: RundownEntries = {};
for (const id in rundown.entries) {
entries[id] = cloneEntryData(rundown.entries[id]);
}
return {
id: rundown.id,
title: rundown.title,
revision: rundown.revision,
order: rundown.order.slice(),
flatOrder: rundown.flatOrder.slice(),
entries,
};
}
/** /**
* Utility for calculating if the current events should have a day offset * Utility for calculating if the current events should have a day offset
* @param current the current event under test * @param current the current event under test
@@ -464,8 +464,9 @@ export async function upload(sheetId: string, options: ImportMap) {
sheetOrder.forEach((entryId, index) => { sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-'); const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed const entry = isGroupEnd
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id]; ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
}); });
} catch (e) { } catch (e) {
@@ -101,16 +101,8 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields; return db.data.customFields;
} }
/**
* Stores a rundown, replacing any existing entry for the same key.
* Takes ownership of `newData` and stores it by reference - the caller must not mutate it
* afterward. Every call site either hands over a freshly-built object it never touches again,
* or (for the loaded rundown) the cache's own long-lived object, which is already the single
* source of truth for that data - aliasing it here costs nothing and avoids a second full
* deep copy of the rundown on every commit.
*/
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> { async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
db.data.rundowns[rundownKey] = newData; db.data.rundowns[rundownKey] = structuredClone(newData);
await persist(); await persist();
return db.data.rundowns; return db.data.rundowns;
} }
@@ -4,17 +4,12 @@ import { DatabaseModel } from 'ontime-types';
* Merges a partial ontime project into a given ontime project * Merges a partial ontime project into a given ontime project
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
// rundowns are merged separately below by reference (only the top-level map is copied, const deepExisting = structuredClone(existing);
// same as the other properties here) - deep-cloning them here would be wasted work, const deepNewData = structuredClone(newData);
// since a project's rundowns are by far the largest part of this object
const { rundowns: existingRundowns, ...existingRest } = existing;
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
const deepExisting = structuredClone(existingRest);
const deepNewData = structuredClone(newDataRest);
// destructure each property to simplify merging not provided ie: ...{} has no effect // destructure each property to simplify merging not provided ie: ...{} has no effect
const { const {
rundowns = {},
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
@@ -24,7 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
} = deepNewData; } = deepNewData;
return { return {
rundowns: { ...existingRundowns, ...newRundowns }, rundowns: { ...existing.rundowns, ...rundowns },
project: { ...deepExisting.project, ...project }, project: { ...deepExisting.project, ...project },
settings: { ...deepExisting.settings, ...settings }, settings: { ...deepExisting.settings, ...settings },
viewSettings: { ...deepExisting.viewSettings, ...viewSettings }, viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
+1 -1
View File
@@ -16,7 +16,7 @@
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #101010; background: #121212;
color: #ffffff; color: #ffffff;
font-family: sans-serif; font-family: sans-serif;
overflow: hidden; overflow: hidden;
@@ -29,19 +29,15 @@ function makeHeadersWithFailingAuthorization(cookie?: string) {
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true); expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
}); });
it('allows prefixed public assets in cloud deployments', () => { it('allows prefixed public assets in cloud deployments', () => {
expect(isPublicAssetRequest('/stage-hash/manifest.json', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true);
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
}); });
it('allows the PWA install icons', () => {
expect(isPublicAssetRequest('/ontime-logo-192.png', '')).toBe(true);
expect(isPublicAssetRequest('/ontime-logo-512.png', '')).toBe(true);
});
it('keeps non-public paths protected', () => { it('keeps non-public paths protected', () => {
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false); expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
+1 -2
View File
@@ -17,9 +17,8 @@ const publicAssets = new Set([
'/favicon.ico', '/favicon.ico',
'/manifest.json', '/manifest.json',
'/ontime-logo.png', '/ontime-logo.png',
'/ontime-logo-192.png',
'/ontime-logo-512.png',
'/robots.txt', '/robots.txt',
'/site.webmanifest',
]); ]);
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean { export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
@@ -20,7 +20,6 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
import { triggerReportEntry } from '../../api-data/report/report.service.js'; import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
@@ -755,7 +754,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
} }
// at this point we know that either the id or the contents has changed // at this point we know that either the id or the contents has changed
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null; RuntimeService.previousState[key] = structuredClone(currentEntry);
return true; return true;
} }
-4
View File
@@ -36,10 +36,6 @@ Remove comments that:
Update adjacent comments with code. Stale comments are defects. Update adjacent comments with code. Stale comments are defects.
Add a short comment immediately above every React `useEffect` describing the external synchronization or lifecycle
responsibility it owns. Explain non-obvious reasons or constraints when they matter; for files with multiple effects, a
brief responsibility label is useful even when the mechanics are straightforward.
## Naming and types ## Naming and types
- Prefer Ontime terms over vague `data`, `result`, `item`. - Prefer Ontime terms over vague `data`, `result`, `item`.
-6
View File
@@ -38,12 +38,6 @@ When relevant, cover interactions among:
Pass time/state explicitly to keep rules deterministic and unit-testable. Pass time/state explicitly to keep rules deterministic and unit-testable.
## Reports
- A report is one aggregate containing its event records, show timing, and rundown snapshot.
- Capture the rundown plan when the report starts; later rundown edits must not change it.
- Store day offsets with report timestamps. Use absolute timeline positions for ordering and duration, and wall-clock values only for display.
## Imports and migrations ## Imports and migrations
- Treat project files, spreadsheets, custom fields, migrated data as untrusted. - Treat project files, spreadsheets, custom fields, migrated data as untrusted.
+7 -7
View File
@@ -6,7 +6,7 @@ test.describe('pages routes are available', () => {
test('editor', async ({ page }) => { test('editor', async ({ page }) => {
await page.goto('/editor'); await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('editor-container')).toBeVisible(); await expect(page.getByTestId('editor-container')).toBeVisible();
await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible();
@@ -16,38 +16,38 @@ test.describe('pages routes are available', () => {
test('cuesheet', async ({ page }) => { test('cuesheet', async ({ page }) => {
await page.goto('/cuesheet'); await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('cuesheet')).toBeVisible(); await expect(page.getByTestId('cuesheet')).toBeVisible();
}); });
test('operator', async ({ page }) => { test('operator', async ({ page }) => {
await page.goto('/op'); await page.goto('/op');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('timer', async ({ page }) => { test('timer', async ({ page }) => {
await page.goto('/timer'); await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('backstage', async ({ page }) => { test('backstage', async ({ page }) => {
await page.goto('/backstage'); await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('studio', async ({ page }) => { test('studio', async ({ page }) => {
await page.goto('/studio'); await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
}); });
test('countdown', async ({ page }) => { test('countdown', async ({ page }) => {
await page.goto('/countdown?sub=32d31'); await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByText('Albania')).toBeVisible(); await expect(page.getByText('Albania')).toBeVisible();
await expect(page.getByText('Latvia')).toBeHidden(); await expect(page.getByText('Latvia')).toBeHidden();
+2 -2
View File
@@ -92,12 +92,12 @@ test.describe('test view navigation feature', () => {
test('not-found', async ({ page }) => { test('not-found', async ({ page }) => {
await page.goto('/not-found'); await page.goto('/not-found');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
await page.goto('/preset/not-found'); await page.goto('/preset/not-found');
await expect(page).toHaveTitle(/ontime/i); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
}); });
}); });
@@ -19,3 +19,29 @@ test('message control sends messages to screens', async ({ context }) => {
await expect(featurePage.getByText('TIME NOW')).toBeVisible(); await expect(featurePage.getByText('TIME NOW')).toBeVisible();
}); });
test('message control drives the stage screen state', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
await editorPage.goto('/messagecontrol');
await featurePage.goto('/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
// the secondary line defaults to an aux timer, the select is what puts our text on screen
await editorPage.getByPlaceholder('Message shown as secondary text in stage timer').fill('testing secondary');
await editorPage.getByRole('combobox').click();
await editorPage.getByRole('option', { name: 'Message' }).click();
await expect(featurePage.getByText('testing secondary')).toBeVisible();
await editorPage.getByTestId('toggle timer blackout').click();
await expect(featurePage.locator('.blackout')).toHaveClass(/blackout--active/);
// clearing returns the screen to normal, but keeps what the operator typed
await editorPage.getByTestId('clear screen').click();
await expect(featurePage.locator('.blackout')).not.toHaveClass(/blackout--active/);
await expect(featurePage.getByText('testing secondary')).toHaveCount(0);
await expect(editorPage.getByPlaceholder('Message shown as secondary text in stage timer')).toHaveValue(
'testing secondary',
);
});
@@ -222,57 +222,15 @@ test('Delete event', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible();
}); });
test('Finder searches the rundown and reveals a result', async ({ page }) => { test('Find in rundown', async ({ page }) => {
await page.goto('/rundown'); await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// two events, where the one we are looking for is identified only by its note
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').press('Escape');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E');
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('closing');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').click();
await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish');
await page.getByLabel('Note', { exact: true }).press('Tab');
// the shortcut has to work from a focused field, which is where it is usually reached for
await page.getByTestId('entry-2').getByTestId('entry__title').click();
await page.keyboard.press('ControlOrMeta+f'); await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeFocused(); await expect(page.getByPlaceholder('Search...')).toBeVisible();
// a bare query reaches the note, and the result names the field it matched await page.keyboard.press('Escape');
await page.getByPlaceholder('Search...').fill('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-result-match')).toContainText('Note');
// a badge scopes the search to one field, without putting syntax in the input
const titleFilter = page.getByTestId('finder-filters').getByRole('button', { name: 'Title', exact: true });
await titleFilter.click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(0);
// pressing it again searches every field once more
await titleFilter.click();
await expect(page.getByTestId('finder-result')).toHaveCount(1);
// choosing a result closes the finder and selects the entry in the rundown
await page.getByPlaceholder('Search...').press('Enter');
await expect(page.getByPlaceholder('Search...')).toBeHidden(); await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
}); });
test('Open settings', async ({ page }) => { test('Open settings', async ({ page }) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "4.13.0", "version": "4.12.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "4.13.0", "version": "4.12.0",
"name": "ontime-types", "name": "ontime-types",
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -1,42 +1,8 @@
import type { MaybeNumber } from '../../utils/utils.type.js'; import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from './OntimeEntry.js';
import type { Rundown } from './Rundown.type.js';
export type OntimeEventReport = { export type OntimeEventReport = {
startedAt: MaybeNumber; startedAt: MaybeNumber;
startedAtDay: number | null;
endedAt: MaybeNumber; endedAt: MaybeNumber;
endedAtDay: number | null;
/**
* Snapshot of the schedule taken when the event ran.
* Keeping a copy is what makes a report a record: editing the rundown
* afterwards no longer changes how a show that already happened is reported.
*/
scheduledStart: number;
scheduledDay: number;
scheduledDuration: number;
}; };
export type OntimeReport = Record<EntryId, OntimeEventReport>; export type OntimeReport = Record<string, OntimeEventReport>;
/**
* Show level times for the report.
*
* Planned times are snapshotted when the show starts, for the same reason the
* per event schedule is. Actual times are derived from the events that ran.
*/
export type ShowReport = {
plannedStart: MaybeNumber;
plannedEnd: MaybeNumber;
plannedDuration: MaybeNumber;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
actualDuration: MaybeNumber;
};
/** Recorded event timings together with the rundown plan they are measured against. */
export type ReportData = {
eventReports: OntimeReport;
rundown: Rundown | null;
show: ShowReport;
};
+1 -1
View File
@@ -24,7 +24,7 @@ export { TimerType } from './definitions/TimerType.type.js';
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js'; export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report // ---> Report
export type { OntimeReport, OntimeEventReport, ReportData, ShowReport } from './definitions/core/Report.type.js'; export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
// ---> Automations // ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js'; export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';