Compare commits

..

6 Commits

Author SHA1 Message Date
Claude 05cf7c7c56 fix(report): measure events against the schedule they ran on
The runtime report joined live report data against the current rundown,
so editing an event after the show silently rewrote the report of a show
that had already happened. Durations, and the over/under chip in the
rundown, would change to match the edit.

Each event now records the schedule it actually ran on, and the report
is read from that.

- OntimeEventReport carries scheduledStart and scheduledDuration,
  captured when the event starts, plus a playCount so an event started
  twice no longer overwrites its own record without trace.
- The over/under calculation moves to ontime-utils, where the rundown
  chip and the settings panel share one implementation instead of
  deriving it separately and disagreeing.
- The report panel gains a summary of the show: planned against actual,
  drift, and how many events landed over, under or on time.
- Export CSV was rendering a trash bin icon.

No change to how or where anything is stored: the report stays in memory
exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-09 19:19:02 +00:00
Claude a851414d13 refactor(report): finish via playback, one file per run
Uses the Finish the app already has, and stops rewriting the whole
history every time a show ends.

- The Go button already reads "Finish" on the last event and calls stop
  (playbackControl.utils). Closing the run hangs off that instead of a
  second Finish button of its own, so POST /report/runs/finish and the
  button in the overview are both gone. The run indicator stays as the
  way the feature is found, now a passive status that opens the reports
  panel.
- Reports move from one file per project to a directory per project with
  one file per run. Finishing a show wrote the entire history back to
  disk, which grew with every show ever recorded; it now writes only the
  run that just ended. Deleting a run unlinks one file and deleting a
  project removes one directory.
- A report that cannot be read is skipped instead of taking the rest of
  the history with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-09 14:59:57 +00:00
Claude b21385ec4a refactor(report): a run becomes a report only when finished
Progress is kept in memory while a show runs and written once, as a
complete report, when the operator finishes the run. The stored history
therefore never contains a partial run.

- Nothing writes on the cue path. triggerReportEntry only updates memory,
  so the sidecar is untouched between the first event and finish.
- Removes the write coalescing added for the previous model: with a
  single write per run there is nothing to debounce, so the timer, the
  dirty flag, the flush export and the shutdown hook all go, and app.ts
  returns to its previous state.
- ShowRun.endedAt is no longer nullable. A stored run is finished by
  definition, so recovery of runs left open by a crash is gone along with
  the concept, and the run list drops its ongoing state.
- Adds GET /report/runs/open for the editor indicator. /runs now means
  finished reports only, which is also the contract Cloud sees.
- A run whose rundown was deleted before it finished now reports the
  events that actually ran as its planned count, rather than zero.

Known gap: RestorePoint carries playback state only, so a crash mid show
loses the in progress report. The stored history is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-08 21:35:52 +00:00
Claude faca1a1c76 refactor(report): reduce footprint and take writes off the cue path
Cuts the risk surface of the run history feature while keeping what it
delivers.

Stability:
- Sidecar writes were happening on every event stop, and each write
  serialised the project's entire history. That put work proportional to
  everything that ever ran onto the show critical path, growing without
  bound. Writes during a run are now coalesced, with immediate writes for
  anything a user did and a flush on finish, project change and shutdown.
- The editor no longer carries any of this feature's code. The per event
  last run chip is gone, so RundownEventChip, RundownEventInner and
  useProjectRundowns are back to their previous state. Each rundown row
  had gained two extra query subscriptions, one of them polling.
- closeRun is no longer called from runtime.service, so the timer path is
  untouched. Ending a run is now explicit, which also means a mid show
  stop and restart no longer splits one show across two runs.

Discovery:
- A run indicator in the editor overview appears only while a run is
  being recorded. It shows when recording started and carries the Finish
  action, then links to the report. One component with one subscription,
  rather than anything per row.

Smaller:
- report.parser drops from exhaustive validation of a file we write
  ourselves to a shallow shape check; the failure mode is unchanged.
- getCombinedReport returns to its original shape, keeping only the
  snapshot read that report accuracy depends on.
- Removes getLatestRun and GET /runs/latest, which only existed for the
  chip, and hand rolled refetches the api layer already covers.

Production diff is down from ~1460 to ~1290 lines, and the four floating
promises the previous revision introduced are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-08 21:25:03 +00:00
Claude 8551c161f1 fix(report): correct run timestamps, scoping and recovery
Review of the run history feature turned up four defects, all in the
code that feature introduced.

- Runs were dated with a time of day rather than a wall clock instant.
  `clock` and `rundown.actualStart` are millis since midnight, so every
  run in the history list rendered as 1 January 1970, and getLatestRun
  ordered runs incorrectly across days: a run at 09:00 today sorted below
  one at 20:00 last week, so the "last run" chip and /runs/latest could
  return an older run. Runs now take `_startEpoch`, the instant the show
  began, and the type documents the invariant.
- A stop arriving with no run open is ignored. Loading a project stops
  playback and reinitialises reporting in the same tick, and the trailing
  stop was attributing the previous project's event to the new one.
- Run summaries counted planned events from the loaded rundown rather
  than the rundown the run belongs to, so switching rundowns mid-run gave
  the summary a denominator from a different rundown. A run whose rundown
  has since been deleted now keeps its last known count instead of
  dropping to zero.
- Recovery of runs left open by a crash only closed the first one. Any
  others stayed dangling forever: permanently shown as ongoing and never
  eligible as the latest run.

Also replaces the raw ISO default run label with a readable local date
and time, and adds a batching upsertRuns to the store so recovery writes
the sidecar once rather than racing one write per run.

Each fix has a regression test, verified to fail without the change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-08 16:21:00 +00:00
Claude 3c2875a956 feat(report): persistent show run history
Turns the reporter from a live-only, single-run curiosity into a
persistent, per-project, per-rundown history of runs.

- Extend OntimeEventReport with a schedule snapshot (scheduledStart,
  scheduledDuration) taken when an event starts, plus playCount. Reports
  are now a record that survives later rundown edits, rather than a live
  join against the current rundown.
- Add a run lifecycle: a run opens on the first event start and closes on
  a full stop, archiving the previous run to history so the next start
  begins fresh.
- Persist runs to a per-project sidecar file (services/report-service),
  patterned on the existing restore-service, so a crash or restart no
  longer loses the whole show's record. Runs are scoped by rundownId for
  multi-rundown projects, cascade-deleted with their rundown or project,
  renamed alongside a project rename, and deliberately not copied when a
  project is duplicated.
- Extract the over/under/on-time variance and run summary maths shared by
  the rundown chip, the report settings panel, and the server into
  ontime-utils (getEventVariance, getRunSummary, countPlannedEvents).
- Extend the report API with run history endpoints (list, get, latest,
  rename, delete) while keeping GET /report's existing shape so Companion
  and HTTP automations are unaffected.
- Replace the settings report table with a run browser: a list of runs
  with a rundown filter, inline rename, delete, and a detail view with
  per-run summary stats and CSV export.
- Add a third, muted state to the rundown event chip: an event with
  nothing in the current run yet previews how it went last time.

Fixes a real bug found while testing the new store: emptyStore() was a
shared object, so its runs array leaked mutations across project loads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-08 11:08:28 +00:00
37 changed files with 679 additions and 449 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.12.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.12.0",
"version": "4.11.0",
"private": true,
"type": "module",
"dependencies": {
-7
View File
@@ -176,13 +176,6 @@ 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 -24
View File
@@ -49,7 +49,6 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
@@ -467,27 +466,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd;
}
},
[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],
[getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
@@ -1030,7 +1009,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1048,7 +1026,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
@@ -200,7 +200,8 @@ $card-padding: 2rem;
.overlay {
position: absolute;
z-index: $zindex-backdrop;
inset: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,8 +3,6 @@ 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();
@@ -20,12 +18,7 @@ export default function AppVersion() {
return (
<Panel.ListItem>
<Panel.Field
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
title={`Ontime ${appVersion}`}
description={
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
@@ -33,7 +26,7 @@ export default function AppVersion() {
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
)}
</Panel.ListItem>
);
@@ -5,3 +5,28 @@ 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,5 +1,6 @@
import { countPlannedEvents, getRunSummary } from 'ontime-utils';
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
@@ -7,9 +8,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 { formatTime } from '../../../../common/utils/time';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
@@ -31,6 +32,10 @@ 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>
@@ -41,7 +46,7 @@ export default function ReportSettings() {
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
@@ -51,6 +56,19 @@ 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>
@@ -102,3 +120,12 @@ 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>
);
}
@@ -0,0 +1,130 @@
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,7 +1,9 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
id: EntryId;
@@ -15,7 +17,12 @@ export type CombinedReport = {
};
/**
* Creates a combined report with the rundown data
* Creates a combined report with the rundown data.
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/
export function getCombinedReport(
report: OntimeReport,
@@ -33,7 +40,9 @@ export function getCombinedReport(
const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue;
if (!(id in report)) {
const reported = report[id];
if (!reported) {
combinedReport.push({
id: id,
index: index,
@@ -44,18 +53,16 @@ export function getCombinedReport(
scheduledEnd: entry.timeEnd,
actualStart: null,
});
}
if (id in report) {
} else {
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,
scheduledStart: reported.scheduledStart,
actualEnd: reported.endedAt,
scheduledEnd: reported.scheduledStart + reported.scheduledDuration,
actualStart: reported.startedAt,
});
}
index++;
@@ -64,6 +71,16 @@ 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 === 'under' ? 'over' : planOffsetLabel}
offset={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, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { MouseEvent, useEffect, useRef } from 'react';
import {
@@ -13,10 +13,9 @@ import {
IoTrash,
IoUnlink,
} from 'react-icons/io5';
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { 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';
@@ -103,10 +102,7 @@ export default function RundownEvent({
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
useEntryActionsContext();
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect);
@@ -118,15 +114,6 @@ 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
? [
@@ -185,17 +172,6 @@ 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,7 +141,6 @@ 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, isPlaybackActive, millisToString } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +32,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +43,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
return <EventReport className={className} id={id} />;
}
if (playbackActive) {
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { className, id } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
// 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') {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
if (variance.status === 'ontime') {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
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, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [value, variance.status, tooltip];
}, [currentReport]);
if (!value) {
return null;
@@ -74,36 +74,42 @@
.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 {
color: $muted-gray;
opacity: 0.6;
}
.target {
display: contents;
.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;
}
}
.drag {
@@ -2,25 +2,24 @@ 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, useCallback, useRef } from 'react';
import { MouseEvent, 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';
@@ -41,31 +40,12 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
const { clone, ungroup, deleteEntry } = 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',
@@ -82,18 +62,6 @@ 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',
@@ -137,6 +105,22 @@ 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),
@@ -191,18 +175,20 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.metaLabel}>End</div>
<div>{formatTime(data.timeEnd)}</div>
</div>
<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 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} />}
</div>
</Tooltip>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.12.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -13
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [
{
label: 'New project...',
click: () => redirectWindow('/editor?settings=project__create'),
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
},
{
label: 'Load...',
@@ -202,18 +202,6 @@ 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.12.0",
"version": "4.11.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.12.0",
"version": "4.11.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -0,0 +1,95 @@
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,14 +49,31 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
// 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,
});
formattedReport = null;
return;
}
if (cycle === TimerLifeCycle.onStop) {
const startedAt = report.get(eventId)?.startedAt ?? null;
report.set(eventId, { startedAt, endedAt: state.clock });
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,
});
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
@@ -8,7 +8,7 @@ import {
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -22,7 +22,6 @@ import {
makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
eventDurationMatchGroupTarget,
} from '../rundown.utils.js';
describe('test event validator', () => {
@@ -611,107 +610,3 @@ 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,7 +35,6 @@ import {
reorderEntry,
swapEvents,
ungroupEntries,
entryFitGroupDuration,
} from './rundown.service.js';
import { normalisedToRundownArray } from './rundown.utils.js';
import {
@@ -338,23 +337,6 @@ 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,7 +47,6 @@ import {
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
eventDurationMatchGroupTarget,
} from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
@@ -448,69 +447,6 @@ 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,7 +3,6 @@ import {
EntryCustomFields,
EntryId,
ImportedFields,
Maybe,
OntimeBaseEvent,
OntimeDelay,
OntimeEntry,
@@ -31,7 +30,6 @@ import {
generateId,
getCueCandidate,
makeString,
maxDuration,
validateEndAction,
validateTimerType,
validateTimes,
@@ -603,27 +601,3 @@ 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;
}
@@ -71,15 +71,11 @@ 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();
@@ -90,16 +86,15 @@ test('Move', async ({ page }) => {
.press('Alt+Control+ArrowDown');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
// move entry three up twice, waiting for each reorder before targeting its new row
// copy move up
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-2')
.getByTestId('entry-3')
.getByTestId('rundown-event')
.filter({ hasText: '3' })
.press('Alt+ControlOrMeta+ArrowUp');
+10 -23
View File
@@ -13,27 +13,23 @@ 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 entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
const ids = new Array<string>();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
// Create reusable locator references for different elements
const entry2 = {
@@ -61,10 +57,8 @@ 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.getByTestId('entry-1').getByLabel('Pause event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
await editor.getByLabel('Pause event').click();
// 1. initial check
await expect(entry2.editorEvent).toContainText('9m');
@@ -135,29 +129,22 @@ test('time until absolute', async ({ context }) => {
test('time until relative', async ({ context }) => {
const editor = await context.newPage();
await editor.goto('/editor');
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 expect(editor.getByTestId('rundown-event')).toHaveCount(1);
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
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.getByTestId('entry-1').getByLabel('Pause event').click();
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
await editor.getByLabel('Pause event').click();
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.12.0",
"version": "4.11.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.12.0",
"version": "4.11.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -1,8 +1,35 @@
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<string, OntimeEventReport>;
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;
};
+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 } from './definitions/core/Report.type.js';
export type { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js';
// ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
+9
View File
@@ -99,6 +99,15 @@ 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,
@@ -0,0 +1,112 @@
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,
});
});
});
@@ -0,0 +1,101 @@
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;
}