Compare commits

..

9 Commits

Author SHA1 Message Date
Carlos Valente e8c90a966a chore(deps): migrate to @tanstack/react-table v9 2026-08-21 22:38:49 +02:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
Carlos Valente c6eccec30e refactor(settings): show new app indicator 2026-08-09 16:48:20 +02:00
Carlos Valente 5220c2c374 fix(settings): prevent loader overflow 2026-08-09 16:48:20 +02:00
Carlos Valente 4eeeb294f7 chore: update electron navigation 2026-08-09 16:48:20 +02:00
Alex Christoffer Rasmussen a006331fea Group duration context menu utils (#1748) 2026-08-09 16:45:47 +02:00
Carlos Valente ac0ef06459 bump version to 4.12.0 2026-08-09 10:44:13 +02:00
Carlos Valente 4d04fe35c3 refactor(e2e): improve test stability 2026-08-09 10:41:12 +02:00
56 changed files with 1790 additions and 2928 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.11.0",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+9 -8
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.11.0",
"version": "4.12.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.6.0",
"@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7",
"@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "^9.1.2",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-icons": "5.7.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,7 +60,8 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.1.1",
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
+7
View File
@@ -176,6 +176,13 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
+24 -1
View File
@@ -49,6 +49,7 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
@@ -466,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd;
}
},
[getCurrentRundownData, updateEntryMutation, queryClient],
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
);
/**
@@ -1009,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1026,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
-35
View File
@@ -1,5 +1,3 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' {
const content: Record<string, string>;
export default content;
@@ -32,39 +30,6 @@ declare global {
}
}
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/**
* Allow passing CSS Properties
*/
@@ -200,8 +200,7 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
width: 100%;
height: 100%;
inset: 0;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -0,0 +1,7 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
@@ -18,7 +20,12 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={`Ontime ${appVersion}`}
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -26,7 +33,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)}
</Panel.ListItem>
);
@@ -5,28 +5,3 @@ th.over {
th.under {
color: $playback-under;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 0 var(--panel-card-padding, 2rem);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.statLabel {
font-size: calc(1rem - 3px);
text-transform: uppercase;
letter-spacing: 0.02em;
color: $gray-300;
}
.statValue {
font-size: 1rem;
font-weight: 600;
}
@@ -1,6 +1,5 @@
import { countPlannedEvents, getRunSummary } from 'ontime-utils';
import { useMemo } from 'react';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
@@ -8,9 +7,9 @@ import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
@@ -32,10 +31,6 @@ export default function ReportSettings() {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
const summary = useMemo(() => {
return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder));
}, [reportData, data.entries, data.flatOrder]);
return (
<Panel.Section>
<Panel.Card>
@@ -46,7 +41,7 @@ export default function ReportSettings() {
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownloadOutline />
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
@@ -56,19 +51,6 @@ export default function ReportSettings() {
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
{summary.eventsRun > 0 && (
<Panel.Section>
<div className={style.summary}>
<Stat label='Events run' value={`${summary.eventsRun} / ${summary.eventsPlanned}`} />
<Stat label='Scheduled' value={formatDuration(summary.scheduledDuration, false)} />
<Stat label='Actual' value={formatDuration(summary.actualDuration, false)} />
<Stat label='Drift' value={formatDrift(summary.drift, summary.eventsRun)} />
<Stat label='On time' value={String(summary.eventsOnTime)} />
<Stat label='Over' value={String(summary.eventsOver)} />
<Stat label='Under' value={String(summary.eventsUnder)} />
</div>
</Panel.Section>
)}
<Panel.Section>
<Panel.Table>
<thead>
@@ -120,12 +102,3 @@ export default function ReportSettings() {
</Panel.Section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className={style.stat}>
<span className={style.statLabel}>{label}</span>
<span className={style.statValue}>{value}</span>
</div>
);
}
@@ -1,130 +0,0 @@
import {
EndAction,
OntimeEvent,
OntimeReport,
RundownEntries,
SupportedEntry,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
flag: false,
cue: '1',
title: 'event title',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 0,
timeEnd: 10000,
duration: 10000,
skip: false,
colour: '',
timeWarning: 0,
timeDanger: 0,
custom: {},
triggers: [],
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
...patch,
} as OntimeEvent;
}
describe('getCombinedReport()', () => {
it('returns an empty list when nothing has run', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('measures a run event against the schedule recorded at the time', () => {
// the rundown was edited after the show, the report must not follow it
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 });
const report: OntimeReport = {
a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999
actualStart: 100,
actualEnd: 10100,
});
});
it('falls back to the rundown for an event which has not run', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const report: OntimeReport = {
b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']);
expect(result[0]).toMatchObject({
id: 'a',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
it('skips entries which are not events', () => {
const entry = makeEvent({ id: 'a' });
const rundownEntries: RundownEntries = {
a: entry,
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
};
const report: OntimeReport = {
a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']);
});
});
describe('formatDrift()', () => {
it('has nothing to report when no event completed', () => {
expect(formatDrift(0, 0)).toBe('');
});
it('treats sub-second drift as on time', () => {
expect(formatDrift(500, 3)).toBe('On time');
});
it('signs the drift in both directions', () => {
expect(formatDrift(252000, 3)).toBe('+4m12s');
expect(formatDrift(-60000, 3)).toBe('-1m');
});
});
describe('makeReportCSV()', () => {
it('produces a header row and one row per entry', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: 0,
actualEnd: 12000,
},
]);
expect(csv.trim().split('\n')).toHaveLength(2);
});
});
@@ -1,9 +1,7 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
id: EntryId;
@@ -17,12 +15,7 @@ export type CombinedReport = {
};
/**
* Creates a combined report with the rundown data.
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
* Creates a combined report with the rundown data
*/
export function getCombinedReport(
report: OntimeReport,
@@ -40,9 +33,7 @@ export function getCombinedReport(
const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue;
const reported = report[id];
if (!reported) {
if (!(id in report)) {
combinedReport.push({
id: id,
index: index,
@@ -53,16 +44,18 @@ export function getCombinedReport(
scheduledEnd: entry.timeEnd,
actualStart: null,
});
} else {
}
if (id in report) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: reported.scheduledStart,
actualEnd: reported.endedAt,
scheduledEnd: reported.scheduledStart + reported.scheduledDuration,
actualStart: reported.startedAt,
scheduledStart: entry.timeStart,
actualEnd: report[id].endedAt,
scheduledEnd: entry.timeEnd,
actualStart: report[id].startedAt,
});
}
index++;
@@ -71,16 +64,6 @@ export function getCombinedReport(
return combinedReport;
}
/**
* Signed drift, eg "+4m12s" / "-1m". With nothing completed there is no
* meaningful drift to report.
*/
export function formatDrift(drift: number, eventsRun: number): string {
if (eventsRun === 0) return enDash;
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)}
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
<TextLikeInput
offset={planOffsetLabel}
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
disabled
>
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { MouseEvent, useEffect, useRef } from 'react';
import {
@@ -13,9 +13,10 @@ import {
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
@@ -102,7 +103,10 @@ export default function RundownEvent({
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect);
@@ -114,6 +118,15 @@ export default function RundownEvent({
const handleRef = useRef<null | HTMLSpanElement>(null);
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
return [false, ''];
const { targetDuration, duration } = parentGroup;
return targetDuration > duration
? [true, 'Increase event duration to fit the group target']
: [true, 'Decrease event duration to fit the group target'];
})();
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
selectedEvents.size > 1
? [
@@ -172,6 +185,17 @@ export default function RundownEvent({
updateEntry({ id: eventId, flag: !flag });
},
},
{
type: 'item',
label: 'Match Group Target Duration',
description: groupTargetDurationDescription,
icon: TbClockPin,
onClick: () => {
if (!parent) return;
matchGroupDuration(eventId);
},
disabled: !enableMatchDuration,
},
{ type: 'divider' },
{
type: 'item',
@@ -141,6 +141,7 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -1,5 +1,5 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
@@ -20,6 +20,7 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -32,6 +33,7 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -43,7 +45,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} />;
return <EventReport className={className} id={id} duration={duration} />;
}
if (playbackActive) {
@@ -84,32 +86,41 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id } = props;
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
// measured against the schedule recorded when the event ran, so this
// agrees with the report panel and survives later rundown edits
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
if (!currentReport) {
return [null, 'none', ''];
}
if (variance.status === 'ontime') {
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'under', 'Event finished on time'];
}
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const isOver = difference > 0;
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, variance.status, tooltip];
}, [currentReport]);
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
if (!value) {
return null;
@@ -74,42 +74,36 @@
.metaLabel {
color: $muted-gray;
font-size: calc(1rem - 3px);
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.strike {
text-decoration: wavy underline;
margin-right: 0.25rem;
color: $ui-white;
}
.duration {
display: flex;
align-items: center;
gap: 0.25rem;
color: $ui-white;
&.warning {
.strike {
// color: $playback-over;
text-decoration: wavy underline;
text-decoration-color: $playback-over;
}
.offsetLabel {
background-color: $playback-over;
}
}
}
.lockIcon {
opacity: 0.6;
color: $muted-gray;
}
.over {
color: $playback-over;
.strike {
text-decoration-color: $playback-over;
}
.offsetLabel {
background-color: $playback-over;
}
}
.under {
color: $playback-under;
.strike {
text-decoration-color: $playback-under;
}
.offsetLabel {
background-color: $playback-under;
}
.target {
display: contents;
}
.drag {
@@ -2,24 +2,25 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useRef } from 'react';
import { MouseEvent, useCallback, useRef } from 'react';
import {
IoChevronDown,
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo,
IoTrash,
IoLockClosed,
} from 'react-icons/io5';
import { TbClockPin } from 'react-icons/tb';
import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor';
@@ -40,12 +41,31 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
const [planOffset, offset] = (() => {
if (data.targetDuration === null) {
return [null, 0];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 0];
}
const absOffset = Math.abs(offset);
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
})();
const matchDuration = useCallback(() => {
updateEntry({ id: data.id, targetDuration: data.duration });
}, [data.duration, data.id, updateEntry]);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{
type: 'item',
@@ -62,6 +82,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
disabled: data.entries.length === 0,
},
{ type: 'divider' },
{
type: 'item',
label: 'Match Content Duration',
icon: TbClockPin,
onClick: matchDuration,
disabled: isDurationMatching,
description:
offset > 0
? "Increase group target duration to match it's contents"
: "Decrease group target duration to match it's contents",
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete Group',
@@ -105,22 +137,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const [planOffset, planOffsetLabel] = (() => {
if (data.targetDuration === null) {
return [null, null];
}
const offset = data.duration - data.targetDuration;
if (offset === 0) {
return [null, 'under'];
}
const absOffset = Math.abs(offset);
return [
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
getOffsetState(offset),
];
})();
const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
@@ -175,20 +191,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaLabel}>End</div>
<div>{formatTime(data.timeEnd)}</div>
</div>
<div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div>
<div className={style.duration}>
{planOffset === null ? (
formatDuration(data.duration)
) : (
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<Tag className={style.offsetLabel}>{planOffset}</Tag>
</span>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
<div className={style.metaEntry}>
<div className={style.metaLabel}>
Duration
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
<div className={cx([style.duration, planOffset && style.warning])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
</div>
</div>
</div>
</Tooltip>
</div>
</div>
</div>
@@ -1,15 +1,14 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
/**
* Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access
*/
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
@@ -7,14 +7,13 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { PropsWithChildren } from 'react';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[];
columns: CuesheetColumnDef[];
tableRoot?: 'editor' | 'cuesheet';
}
@@ -1,5 +1,5 @@
import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import {
CuesheetColumnDef,
CuesheetTable as CuesheetTableInstance,
cuesheetTableFeatures,
} from './cuesheetTable.features';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
columns: CuesheetColumnDef[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
@@ -120,7 +125,8 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({
const table = useTable({
features: cuesheetTableFeatures,
data: flatRundown,
columns,
columnResizeMode: 'onChange',
@@ -131,7 +137,6 @@ export default function CuesheetTable({
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta,
});
@@ -195,7 +200,7 @@ export default function CuesheetTable({
return colSizes;
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
}, [table.state.columnResizing, table.state.columnSizing]);
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
@@ -214,9 +219,7 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return (
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: CuesheetTableInstance;
handleAddNew?: (type: SupportedEntry) => void;
}
@@ -1,16 +1,16 @@
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { HeaderGroup, flexRender } from '@tanstack/react-table';
import { FlexRender } from '@tanstack/react-table';
import { CSSProperties } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps {
headerGroup: HeaderGroup<ExtendedEntry>;
headerGroup: CuesheetHeaderGroup;
cuesheetMode: AppMode;
hideIndexColumn: boolean;
}
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{header.isPlaceholder ? null : <FlexRender header={header} />}
</SortableCell>
);
})}
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{header.isPlaceholder ? null : <FlexRender header={header} />}
</TableCell>
);
})}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
import { FlexRender } from '@tanstack/react-table';
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './EventRow.module.scss';
@@ -25,7 +25,7 @@ interface EventRowProps {
skip: boolean;
parent: EntryId | null;
rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>;
table: CuesheetTable;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -133,7 +133,7 @@ function EventRow({
data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<FlexRender cell={cell} />
</td>
);
})}
@@ -1,12 +1,12 @@
import { Table, flexRender } from '@tanstack/react-table';
import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './GroupRow.module.scss';
@@ -15,7 +15,7 @@ interface GroupRowProps {
colour: string;
rowId: string;
rowIndex: number;
table: Table<ExtendedEntry>;
table: CuesheetTable;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -76,7 +76,7 @@ function GroupRow({
}}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<FlexRender cell={cell} />
</td>
);
})}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table';
import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './MilestoneRow.module.scss';
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
colour: string;
rowId: string;
rowIndex: number;
table: Table<ExtendedEntry>;
table: CuesheetTable;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -102,7 +102,7 @@ function MilestoneRow({
}}
tabIndex={-1}
>
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
{canRender && <FlexRender cell={cell} />}
</td>
);
})}
@@ -1,9 +1,8 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { CSSProperties, ReactNode } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
import style from '../CuesheetTable.module.scss';
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
}
interface DraggableProps {
header: Header<ExtendedEntry, unknown>;
header: CuesheetHeaderCell;
}
export function Draggable({ header }: DraggableProps) {
@@ -1,4 +1,3 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback } from 'react';
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
import FlagCell from './FlagCell';
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) {
return null;
}
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
);
}
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) {
return null;
}
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
);
}
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) {
return null;
}
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
);
}
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
);
}
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
function LazyImage({ row, column, table }: CuesheetCellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
}
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
);
}
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
function MakeFlagField({ row }: CuesheetCellContext) {
const event = row.original;
if (!isOntimeEvent(event) || !event.flag) {
return null;
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
return <FlagCell />;
}
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
customFields: CustomFields,
cuesheetMode: AppMode,
preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
): CuesheetColumnDef[] {
const columnsDef: CuesheetColumnDef[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) {
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions';
import type { CuesheetColumn } from '../cuesheetTable.features';
import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss';
@@ -37,7 +36,7 @@ type TableModeControls = {
};
interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[];
columns: CuesheetColumn[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
}
interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
columns: CuesheetColumn[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -203,7 +202,7 @@ function ColumnSettings({
return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
{columnHeader as ReactNode}
</Editor.Label>
);
@@ -0,0 +1,74 @@
import {
columnOrderingFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
metaHelper,
tableFeatures,
} from '@tanstack/react-table';
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
import type { TimeField } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import type { AppMode } from '../../../ontimeConfig';
/**
* Custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*/
export interface CuesheetTableMeta {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
/**
* Metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
export interface CuesheetColumnMeta {
canWrite: boolean;
colour?: string;
}
/**
* Features registered in the cuesheet and rundown tables.
* In v9 an API only exists once its feature is registered, so this list is the
* source of truth for what the table can do:
* - `columnOrderingFeature`: user reorders columns by dragging the headers
* - `columnVisibilityFeature`: user toggles columns in the table settings
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
*
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
* they scope our meta types to this table instead of every table in the app.
*/
export const cuesheetTableFeatures = tableFeatures({
columnOrderingFeature,
columnVisibilityFeature,
columnSizingFeature,
columnResizingFeature,
tableMeta: metaHelper<CuesheetTableMeta>(),
columnMeta: metaHelper<CuesheetColumnMeta>(),
});
export type CuesheetFeatures = typeof cuesheetTableFeatures;
/** Convenience aliases so consumers do not need to repeat the feature generic */
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
@@ -1,10 +1,10 @@
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
import { ColumnSizingState, Updater } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import type { CuesheetColumnDef } from './cuesheetTable.features';
type TableRoot = 'editor' | 'cuesheet';
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
};
}
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.11.0",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -14,8 +14,8 @@
"main": "src/main.js",
"devDependencies": {
"electron": "38.2.1",
"electron-builder": "26.9.1",
"wait-on": "^7.2.0"
"electron-builder": "26.15.3",
"wait-on": "^9.0.0"
},
"scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+13 -1
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
click: () => redirectWindow('/editor?settings=project__create'),
},
{
label: 'Load...',
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'),
},
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
],
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.11.0",
"version": "4.12.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.11.0",
"version": "4.12.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -10,7 +10,7 @@
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"dotenv": "^16.0.1",
"dotenv": "^17.0.0",
"express": "5.2.1",
"express-static-gzip": "3.0.1",
"express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0",
"@types/node": "catalog:",
"@types/ws": "^8.5.10",
"esbuild": "^0.24.0",
"esbuild": "^0.28.0",
"ontime-types": "workspace:*",
"server-timing": "^3.3.3",
"ts-essentials": "catalog:",
"tsx": "^4.19.2",
"tsx": "^4.23.12",
"typescript": "catalog:",
"vitest": "catalog:"
},
@@ -1,95 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
import type { PlayableEvent } from 'ontime-types';
import { vi } from 'vitest';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { clear, generate, triggerReportEntry } from '../report.service.js';
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
sendRefetch: vi.fn(),
}));
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
beforeEach(() => {
clear();
});
describe('triggerReportEntry()', () => {
it('snapshots the schedule when an event starts', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()[eventA.id]).toEqual({
startedAt: 500,
endedAt: null,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
playCount: 1,
});
});
it('keeps the snapshot taken at start when the event stops', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: 0,
endedAt: 12000,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
it('records the schedule as it was, not as it later becomes', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
// the event is edited to a different duration, then stopped
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
});
it('counts a re-run rather than losing the previous one', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state);
triggerReportEntry(TimerLifeCycle.onStart, { ...state, clock: 5000 } as typeof state);
expect(generate()[eventA.id].playCount).toBe(2);
});
it('falls back to the current event when a stop arrives with no start', () => {
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: null,
endedAt: 10000,
scheduledDuration: eventA.duration,
playCount: 1,
});
});
it('ignores events without an id', () => {
const state = makeRuntimeStateData({ eventNow: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toEqual({});
});
});
describe('clear()', () => {
it('clears a single event', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
clear(eventA.id);
expect(generate()).toEqual({});
});
});
@@ -49,31 +49,14 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
// an event started twice is a re-run, not a new record
const playCount = (report.get(eventId)?.playCount ?? 0) + 1;
report.set(eventId, {
startedAt: state.timer.startedAt,
endedAt: null,
// snapshot the schedule so later rundown edits cannot change how a show
// that already happened is reported
scheduledStart: state.eventNow.timeStart,
scheduledDuration: state.eventNow.duration,
playCount,
});
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
formattedReport = null;
return;
}
if (cycle === TimerLifeCycle.onStop) {
const previous = report.get(eventId);
report.set(eventId, {
startedAt: previous?.startedAt ?? null,
endedAt: state.clock,
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
playCount: previous?.playCount ?? 1,
});
const startedAt = report.get(eventId)?.startedAt ?? null;
report.set(eventId, { startedAt, endedAt: state.clock });
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
@@ -8,7 +8,7 @@ import {
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -22,6 +22,7 @@ import {
makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
eventDurationMatchGroupTarget,
} from '../rundown.utils.js';
describe('test event validator', () => {
@@ -610,3 +611,107 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
describe('eventDurationMatchGroupTarget()', () => {
it('returns unchanged duration when group already matches target', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR,
groupDuration: MILLIS_PER_HOUR,
eventDuration: MILLIS_PER_MINUTE * 30,
});
expect(result).toStrictEqual(null);
});
it('increases event duration when group is shorter than target', () => {
// Group is 1h short of target, so event duration increases by 1h
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR * 2, // 2h
groupDuration: MILLIS_PER_HOUR, // 1h
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
});
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
});
it('decreases event duration when group is longer than target', () => {
// Group is 30m over target, so event duration decreases by 30m
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR, // 1h
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
});
expect(result).toStrictEqual(0);
});
it('handles zero target duration', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: 0,
groupDuration: MILLIS_PER_HOUR,
eventDuration: MILLIS_PER_HOUR,
});
expect(result).toStrictEqual(0);
});
it('handles zero group duration', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR,
groupDuration: 0,
eventDuration: MILLIS_PER_MINUTE * 30,
});
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
});
it('handles zero event duration', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR,
groupDuration: MILLIS_PER_MINUTE * 30,
eventDuration: 0,
});
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
});
it('handles all zero values', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: 0,
groupDuration: 0,
eventDuration: 0,
});
expect(result).toStrictEqual(null);
});
it('returns null when result would be negative', () => {
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_MINUTE * 30,
groupDuration: MILLIS_PER_HOUR * 2,
eventDuration: MILLIS_PER_HOUR,
});
expect(result).toStrictEqual(null);
});
it('handles large durations', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: MILLIS_PER_HOUR * 24, // 24h
groupDuration: MILLIS_PER_HOUR * 12, // 12h
eventDuration: MILLIS_PER_HOUR, // 1h
});
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
});
it('returns null when targetDuration is null', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: null,
groupDuration: MILLIS_PER_HOUR,
eventDuration: MILLIS_PER_MINUTE * 30,
});
expect(result).toStrictEqual(null);
});
it('returns null when duration would be over 24h', () => {
const result = eventDurationMatchGroupTarget({
targetDuration: 30 * MILLIS_PER_HOUR,
groupDuration: MILLIS_PER_HOUR,
eventDuration: MILLIS_PER_MINUTE * 30,
});
expect(result).toStrictEqual(null);
});
});
@@ -35,6 +35,7 @@ import {
reorderEntry,
swapEvents,
ungroupEntries,
entryFitGroupDuration,
} from './rundown.service.js';
import { normalisedToRundownArray } from './rundown.utils.js';
import {
@@ -337,6 +338,23 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
}
});
/**
* Change a events duration to fit inside the group target
*/
router.post(
'/:rundownId/:id/fit-group-duration',
paramsWithId,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Deletes a list of entries by their ID
*/
@@ -47,6 +47,7 @@ import {
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
eventDurationMatchGroupTarget,
} from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
@@ -447,6 +448,69 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
return rundownResult;
}
/**
* Change a events duration to fit inside the group target
*/
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
const entry = rundown.entries[entryId];
if (!entry) {
throw new Error('Entry not found');
}
if (!isOntimeEvent(entry)) {
throw new Error('Entry must be an event');
}
const { parent } = entry;
if (!parent) {
throw new Error('Entry must be in a group');
}
const group = rundown.entries[parent];
if (!group) {
throw new Error('Group not found');
}
if (!isOntimeGroup(group)) {
throw new Error('Group is not a group');
}
const newDuration = eventDurationMatchGroupTarget({
targetDuration: group.targetDuration,
groupDuration: group.duration,
eventDuration: entry.duration,
});
if (newDuration === null) {
throw new Error('Unable to fit a duration');
}
const newEnd = entry.timeStart + newDuration;
rundownMutation.edit(rundown, {
id: entryId,
duration: newDuration,
timeEnd: newEnd,
timeStrategy: entry.timeStrategy,
});
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
// schedule the side effects
setImmediate(() => {
// notify runtime that rundown has changed
updateRuntimeOnChange(rundownMetadata);
// we need to notify the timer since we might be changing a running event
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
});
return rundownResult;
}
/**
* Groups a list of entries into a new group
*/
@@ -3,6 +3,7 @@ import {
EntryCustomFields,
EntryId,
ImportedFields,
Maybe,
OntimeBaseEvent,
OntimeDelay,
OntimeEntry,
@@ -30,6 +31,7 @@ import {
generateId,
getCueCandidate,
makeString,
maxDuration,
validateEndAction,
validateTimerType,
validateTimes,
@@ -351,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID
'cue',
'title',
'note',
'endAction',
'timerType',
'countToEnd',
'colour',
'timeWarning',
'timeDanger',
'custom',
'triggers',
id, // adding it for completeness, users cannot change ID
type, // adding it for completeness, users cannot change ID
cue,
title,
note,
endAction,
timerType,
countToEnd,
colour,
timeWarning,
timeDanger,
custom,
triggers,
}
/**
@@ -601,3 +603,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
precision,
};
}
/**
* Adjusts an event's duration to fit inside the group target
* @param targetDuration - The desired total duration for the group, or null
* @param groupDuration - The current total duration of all events in the group
* @param eventDuration - The current duration of the event being adjusted
* @returns The adjusted event duration, or null if targetDuration is null or
* the result would be negative
*/
export function eventDurationMatchGroupTarget({
targetDuration,
groupDuration,
eventDuration,
}: {
targetDuration: Maybe<number>;
groupDuration: number;
eventDuration: number;
}): Maybe<number> {
if (targetDuration === null) return null;
if (targetDuration === groupDuration) return null;
const durationDiff = targetDuration - groupDuration;
const newDuration = eventDuration + durationDiff;
return newDuration < 0 || newDuration > maxDuration ? null : newDuration;
}
+3 -6
View File
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
);
server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}));
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params;
+19
View File
@@ -6,6 +6,25 @@ test('cuesheet displays events', async ({ page }) => {
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
});
test('cuesheet persists column visibility', async ({ page }) => {
await page.goto('/cuesheet');
const noteHeader = page.getByRole('columnheader', { name: 'Note' });
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
await expect(noteHeader).toBeVisible();
await expect(noteCell).toBeVisible();
await page.getByRole('button', { name: 'Columns' }).click();
await page.getByRole('checkbox', { name: 'Note' }).click();
await expect(noteHeader).toBeHidden();
await expect(noteCell).toBeHidden();
await page.reload();
await expect(noteHeader).toBeHidden();
await expect(noteCell).toBeHidden();
});
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
await page.goto('/cuesheet');
@@ -71,11 +71,15 @@ test('Move', async ({ page }) => {
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);
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
// copy move down
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
@@ -86,15 +90,16 @@ test('Move', async ({ page }) => {
.press('Alt+Control+ArrowDown');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
// copy move up
// move entry three up twice, waiting for each reorder before targeting its new row
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
await page
.getByTestId('entry-3')
.getByTestId('rundown-event')
.filter({ hasText: '3' })
.press('Alt+ControlOrMeta+ArrowUp');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
await page
.getByTestId('entry-3')
.getByTestId('entry-2')
.getByTestId('rundown-event')
.filter({ hasText: '3' })
.press('Alt+ControlOrMeta+ArrowUp');
+23 -10
View File
@@ -13,23 +13,27 @@ test('time until absolute', async ({ context }) => {
await editor.getByRole('button', { name: 'Rundown menu' }).click();
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
await editor.getByRole('button', { name: 'Delete all' }).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
await editor.getByRole('button', { name: 'Create Event' }).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
const ids = new Array<string>();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
const entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
// Create reusable locator references for different elements
const entry2 = {
@@ -57,8 +61,10 @@ test('time until absolute', async ({ context }) => {
await editor.getByRole('button', { name: 'Absolute' }).click();
await editor.getByTestId('entry-1').getByLabel('Start event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
await editor.getByLabel('Pause event').click();
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
// 1. initial check
await expect(entry2.editorEvent).toContainText('9m');
@@ -129,22 +135,29 @@ test('time until absolute', async ({ context }) => {
test('time until relative', async ({ context }) => {
const editor = await context.newPage();
editor.goto('/editor');
await editor.goto('/editor');
await editor.getByRole('button', { name: 'Edit' }).click();
await editor.getByRole('button', { name: 'Rundown menu' }).click();
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
await editor.getByRole('button', { name: 'Delete all' }).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
await editor.getByRole('button', { name: 'Create Event' }).click();
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
await editor.getByRole('button', { name: 'Relative' }).click();
await editor.getByTestId('entry-1').getByLabel('Start event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
await editor.getByLabel('Pause event').click();
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.11.0",
"version": "4.12.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check"
},
"devDependencies": {
"@playwright/test": "1.60.0",
"@playwright/test": "1.62.1",
"@types/node": "catalog:",
"cross-env": "^7.0.3",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.17.4",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxlint-tsgolint": "^7.0.2001",
"rimraf": "catalog:",
"turbo": "2.8.20",
"turbo": "2.10.10",
"typescript": "catalog:"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.11.0",
"version": "4.12.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -1,35 +1,8 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from './OntimeEntry.js';
export type OntimeEventReport = {
startedAt: MaybeNumber;
endedAt: MaybeNumber;
/**
* 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;
scheduledDuration: number;
/** how many times the event was started, >1 means it was re-run */
playCount: number;
};
export type OntimeReport = Record<EntryId, OntimeEventReport>;
/** Headline numbers for everything in the current report */
export type RunSummary = {
/** events which produced a report entry */
eventsRun: number;
/** playable events in the rundown */
eventsPlanned: number;
scheduledDuration: number;
actualDuration: number;
/** actualDuration - scheduledDuration, signed */
drift: number;
eventsOver: number;
eventsUnder: number;
eventsOnTime: number;
/** largest single overrun, answers "what blew the schedule" */
worstOverrun: { id: EntryId; delta: number } | null;
};
export type OntimeReport = Record<string, OntimeEventReport>;
+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';
// ---> Report
export type { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js';
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
// ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
-9
View File
@@ -99,15 +99,6 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
// feature business logic - reports
export {
countPlannedEvents,
getEventVariance,
getRunSummary,
type EventVariance,
type VarianceStatus,
} from './src/report-utils/reportUtils.js';
//Colour
export {
colourToHex,
+1
View File
@@ -14,6 +14,7 @@
"nanoid": "^6.0.0"
},
"devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
@@ -1,112 +0,0 @@
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
import { getEventVariance, getRunSummary } from './reportUtils.js';
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
return {
startedAt: 0,
endedAt: 10000,
scheduledStart: 0,
scheduledDuration: 10000,
playCount: 1,
...patch,
};
}
describe('getEventVariance()', () => {
it('reports an event which never ran', () => {
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null, delta: 0 });
});
it('reports an event which started but never finished', () => {
const entry = makeEntry({ startedAt: 1000, endedAt: null });
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run', actualDuration: null });
});
it('reports an event which never started', () => {
const entry = makeEntry({ startedAt: null, endedAt: 1000 });
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run' });
});
it('reports an event which matched its schedule', () => {
const entry = makeEntry({ startedAt: 0, endedAt: 10000, scheduledDuration: 10000 });
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10000, delta: 0 });
});
it('treats sub-second differences as on time', () => {
const entry = makeEntry({ startedAt: 0, endedAt: 10500, scheduledDuration: 10000 });
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', delta: 500 });
});
it('reports an overrun', () => {
const entry = makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 });
expect(getEventVariance(entry)).toMatchObject({ status: 'over', actualDuration: 15000, delta: 5000 });
});
it('reports an underrun', () => {
const entry = makeEntry({ startedAt: 0, endedAt: 6000, scheduledDuration: 10000 });
expect(getEventVariance(entry)).toMatchObject({ status: 'under', actualDuration: 6000, delta: -4000 });
});
it('measures against the snapshot, not the current rundown', () => {
// the rundown may have been edited after the run, the snapshot is what counts
const entry = makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 });
expect(getEventVariance(entry).delta).toBe(2000);
});
});
describe('getRunSummary()', () => {
it('returns an empty summary for an empty report', () => {
expect(getRunSummary({}, 0)).toMatchObject({
eventsRun: 0,
eventsPlanned: 0,
scheduledDuration: 0,
actualDuration: 0,
drift: 0,
worstOverrun: null,
});
});
it('aggregates durations and drift across a run', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
b: makeEntry({ startedAt: 15000, endedAt: 21000, scheduledDuration: 10000 }), // -4000
c: makeEntry({ startedAt: 21000, endedAt: 31000, scheduledDuration: 10000 }), // 0
};
expect(getRunSummary(report, 4)).toMatchObject({
eventsRun: 3,
eventsPlanned: 4,
scheduledDuration: 30000,
actualDuration: 31000,
drift: 1000,
eventsOver: 1,
eventsUnder: 1,
eventsOnTime: 1,
});
});
it('identifies the worst overrun', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
b: makeEntry({ startedAt: 0, endedAt: 30000, scheduledDuration: 10000 }), // +20000
c: makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }), // +2000
};
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
});
it('ignores events which did not complete', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }),
b: makeEntry({ startedAt: 15000, endedAt: null, scheduledDuration: 10000 }),
};
expect(getRunSummary(report, 2)).toMatchObject({
eventsRun: 1,
scheduledDuration: 10000,
actualDuration: 15000,
drift: 5000,
});
});
});
@@ -1,101 +0,0 @@
import type { EntryId, OntimeEventReport, OntimeReport, RundownEntries, RunSummary } from 'ontime-types';
import { isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from '../date-utils/conversionUtils.js';
export type VarianceStatus = 'ontime' | 'over' | 'under' | 'not-run';
export type EventVariance = {
/** how long the event actually took, null if it never completed */
actualDuration: number | null;
/** actualDuration - scheduledDuration, signed. 0 when the event did not complete */
delta: number;
status: VarianceStatus;
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
/**
* Calculates how an event performed against its schedule.
* An event is considered on time if it is within a second of its scheduled duration.
*/
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) {
return notRun;
}
const { startedAt, endedAt, scheduledDuration } = entry;
if (startedAt === null || endedAt === null) {
return notRun;
}
const actualDuration = endedAt - startedAt;
const delta = actualDuration - scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) {
return { actualDuration, delta, status: 'ontime' };
}
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
/**
* Aggregates a run's per event data into the headline numbers for a show.
* @param report the run's per event data
* @param eventsPlanned how many playable events the rundown held when the run was made
*/
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
const summary: RunSummary = {
eventsRun: 0,
eventsPlanned,
scheduledDuration: 0,
actualDuration: 0,
drift: 0,
eventsOver: 0,
eventsUnder: 0,
eventsOnTime: 0,
worstOverrun: null,
};
for (const [id, entry] of Object.entries(report)) {
const variance = getEventVariance(entry);
if (variance.status === 'not-run') {
continue;
}
summary.eventsRun += 1;
summary.scheduledDuration += entry.scheduledDuration;
summary.actualDuration += variance.actualDuration as number;
if (variance.status === 'over') {
summary.eventsOver += 1;
if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) {
summary.worstOverrun = { id, delta: variance.delta };
}
} else if (variance.status === 'under') {
summary.eventsUnder += 1;
} else {
summary.eventsOnTime += 1;
}
}
summary.drift = summary.actualDuration - summary.scheduledDuration;
return summary;
}
/**
* Counts the events a run could have played.
* Skipped events are excluded: they were never meant to run and would
* make the completion figures read as if the show fell short.
*/
export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number {
let count = 0;
for (const id of order) {
const entry = entries[id];
if (entry && isOntimeEvent(entry) && !entry.skip) {
count += 1;
}
}
return count;
}
+1152 -2116
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog:
'@types/node': 22.19.11
rimraf: 6.0.1
ts-essentials: 10.1.1
rimraf: 6.1.3
ts-essentials: 10.2.1
typescript: 7.0.2
vitest: 4.0.17
vitest: 4.1.10
allowBuilds:
'@parcel/watcher': true