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
This commit is contained in:
Claude
2026-08-08 11:08:28 +00:00
parent fb559ed9da
commit 3c2875a956
32 changed files with 2144 additions and 172 deletions
+2
View File
@@ -20,6 +20,8 @@ export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const REPORT_RUNS = ['report', 'runs'];
export const getLatestRunQueryKey = (rundownId?: string) => ['report', 'runs', 'latest', rundownId ?? null];
export const TRANSLATION = ['translation'];
// API URLs
+50 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { OntimeReport, ShowRun, ShowRunSummary } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -24,3 +24,52 @@ export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
/**
* HTTP request to fetch the run history, optionally scoped to a rundown
*/
export async function fetchRuns(rundownId?: string, options?: RequestOptions): Promise<ShowRunSummary[]> {
const res = await axios.get(`${reportUrl}/runs`, {
signal: options?.signal,
params: rundownId ? { rundownId } : undefined,
});
return res.data;
}
/**
* HTTP request to fetch a single run, including its per event data
*/
export async function fetchRun(id: string, options?: RequestOptions): Promise<ShowRun> {
const res = await axios.get(`${reportUrl}/runs/${id}`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch the most recently closed run, optionally scoped to a rundown
* @returns null if there is no closed run yet
*/
export async function fetchLatestRun(rundownId?: string, options?: RequestOptions): Promise<ShowRun | null> {
try {
const res = await axios.get(`${reportUrl}/runs/latest`, {
signal: options?.signal,
params: rundownId ? { rundownId } : undefined,
});
return res.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
export async function renameRun(id: string, label: string): Promise<ShowRun> {
const res = await axios.patch(`${reportUrl}/runs/${id}`, { label });
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
return res.data;
}
export async function deleteRun(id: string) {
await axios.delete(`${reportUrl}/runs/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
@@ -26,6 +26,16 @@ export function useProjectRundowns() {
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
/**
* The id of the currently loaded rundown, or null if none is loaded yet.
* Reads from the same lightweight summary query as `useProjectRundowns`, so
* consumers that only need the id are not subscribed to full rundown entries.
*/
export function useLoadedRundownId(): string | null {
const { data } = useProjectRundowns();
return data.loaded || null;
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { ShowRun, ShowRunSummary } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getLatestRunQueryKey, REPORT_RUNS } from '../api/constants';
import { fetchLatestRun, fetchRun, fetchRuns } from '../api/report';
/**
* Run history for the current project, optionally scoped to a rundown
*/
export default function useRuns(rundownId?: string) {
const { data, status, refetch } = useQuery<ShowRunSummary[]>({
queryKey: rundownId ? [...REPORT_RUNS, rundownId] : REPORT_RUNS,
queryFn: ({ signal }) => fetchRuns(rundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? [], status, refetch };
}
/**
* A single run with its per event data, fetched on demand when a run is selected
*/
export function useRun(id: string | null) {
const { data, status } = useQuery<ShowRun>({
queryKey: [...REPORT_RUNS, id],
queryFn: ({ signal }) => fetchRun(id as string, { signal }),
enabled: id !== null,
staleTime: MILLIS_PER_HOUR,
});
return { data, status };
}
/**
* Most recently closed run, used to compare a rundown against its last outing.
* Returns null while there is no run to compare against.
*/
export function useLatestRun(rundownId?: string) {
const { data, status } = useQuery<ShowRun | null>({
queryKey: getLatestRunQueryKey(rundownId),
queryFn: ({ signal }) => fetchLatestRun(rundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? null, status };
}
@@ -1,7 +0,0 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,104 +1,57 @@
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { useEffect, useState } from 'react';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import Select from '../../../../common/components/select/Select';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import useRuns from '../../../../common/hooks-query/useRuns';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import RunDetail from './composite/RunDetail';
import RunsList from './composite/RunsList';
import style from './ReportSettings.module.scss';
const allRundowns = 'all';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const { data: rundownsList } = useProjectRundowns();
const [rundownFilter, setRundownFilter] = useState<string>(allRundowns);
const { data: runs } = useRuns(rundownFilter === allRundowns ? undefined : rundownFilter);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
// keep a valid selection: default to the most recent run, and fall off the
// current one when it drops out of the filtered list
useEffect(() => {
if (selectedRunId && runs.some((run) => run.id === selectedRunId)) return;
setSelectedRunId(runs[0]?.id ?? null);
}, [runs, selectedRunId]);
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
const rundownOptions = [
{ value: allRundowns, label: 'All rundowns' },
...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })),
];
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.SubHeader>
Show reports
{rundownsList.rundowns.length > 1 && (
<Select
value={rundownFilter}
onValueChange={(value: string | null) => value && setRundownFilter(value)}
options={rundownOptions}
/>
)}
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
<Panel.Paragraph>
A run is created the first time an event is started, and is added to history once playback stops. Every
run keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
</Panel.Paragraph>
<RunsList runs={runs} rundownId={rundownFilter === allRundowns ? undefined : rundownFilter} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
</Panel.Card>
{selectedRunId && (
<Panel.Card>
<RunDetail runId={selectedRunId} />
</Panel.Card>
)}
</Panel.Section>
);
}
@@ -0,0 +1,143 @@
import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { 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 there is nothing to show', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('includes an event which has not run, using the current schedule', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const rundownEntries: RundownEntries = { a: entry };
const result = getCombinedReport({}, rundownEntries, ['a']);
expect(result).toEqual([
{
id: 'a',
index: 1,
title: 'event title',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
playCount: 0,
},
]);
});
it('uses the snapshot taken when the event ran, not the current rundown', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 }); // edited after the run
const rundownEntries: RundownEntries = { a: entry };
const report: OntimeReport = {
a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999
actualStart: 100,
actualEnd: 10100,
playCount: 1,
});
});
it('keeps an event which ran but has since been removed from the rundown', () => {
const report: OntimeReport = {
deleted: { startedAt: 0, endedAt: 5000, scheduledStart: 0, scheduledDuration: 5000, playCount: 1 },
};
const result = getCombinedReport(report, {}, []);
expect(result).toEqual([
{
id: 'deleted',
index: 1,
title: '(deleted event)',
cue: '',
scheduledStart: 0,
scheduledEnd: 5000,
actualStart: 0,
actualEnd: 5000,
playCount: 1,
},
]);
});
it('orders rundown events first, deleted events after', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const report: OntimeReport = {
a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
deleted: { startedAt: 10000, endedAt: 15000, scheduledStart: 10000, scheduledDuration: 5000, playCount: 1 },
};
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result.map((entry) => entry.id)).toEqual(['a', 'deleted']);
});
it('skips entries which are not events', () => {
const rundownEntries: RundownEntries = {
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
};
expect(getCombinedReport({}, rundownEntries, ['delay'])).toEqual([]);
});
});
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,
playCount: 1,
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(rows[0]).toContain('Play count');
});
});
@@ -0,0 +1,32 @@
th.over {
color: $playback-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;
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { IoDownload } from 'react-icons/io5';
import { createBlob, downloadBlob } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import { useRundownById } from '../../../../../common/hooks-query/useRundown';
import { useRun } from '../../../../../common/hooks-query/useRuns';
import { cx } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
import style from './RunDetail.module.scss';
interface RunDetailProps {
runId: string;
}
export default function RunDetail({ runId }: RunDetailProps) {
const { data: run, status } = useRun(runId);
// resolves the rundown the run belongs to, independent of what is currently loaded,
// so titles and cues are correct even when browsing an older run
const { data: rundown } = useRundownById(run?.rundownId);
const combinedReport = useMemo(() => {
if (!run) return [];
return getCombinedReport(run.report, rundown.entries, rundown.flatOrder);
}, [run, rundown]);
const downloadCSV = (report: CombinedReport[]) => {
if (!run || report.length === 0) return;
const csv = makeReportCSV(report);
const filename = `ontime-report-${run.label.replace(/\s+/g, '-').toLowerCase()}.csv`;
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, filename);
};
if (status === 'pending' || !run) {
return null;
}
return (
<Panel.Section>
<Panel.SubHeader>
{run.label}
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownload />
Export CSV
</Button>
</Panel.SubHeader>
<Panel.Divider />
<div className={style.summary}>
<Stat label='Events run' value={`${run.summary.eventsRun} / ${run.summary.eventsPlanned}`} />
<Stat label='Scheduled' value={formatDuration(run.summary.scheduledDuration, false)} />
<Stat label='Actual' value={formatDuration(run.summary.actualDuration, false)} />
<Stat label='Drift' value={formatDrift(run.summary.drift, run.summary.eventsRun)} />
<Stat label='On time' value={`${run.summary.eventsOnTime}`} />
<Stat label='Over' value={`${run.summary.eventsOver}`} />
<Stat label='Under' value={`${run.summary.eventsUnder}`} />
</div>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty title='No events in this run' description='This run has no recorded events yet.' />
)}
{combinedReport.map((entry) => {
const start = punctuality(entry.actualStart, entry.scheduledStart);
const end = punctuality(entry.actualEnd, entry.scheduledEnd);
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.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>
);
}
/** Whether an actual time landed before (under) or after (over) its schedule */
function punctuality(actual: number | null, scheduled: number): 'under' | 'over' | null {
if (actual === null) return null;
return actual <= scheduled ? 'under' : 'over';
}
@@ -0,0 +1,12 @@
.row {
cursor: pointer;
&:hover {
background-color: $white-3;
}
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
@@ -0,0 +1,156 @@
import { ShowRunSummary } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import { IoCheckmark, IoClose, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteRun, renameRun } from '../../../../../common/api/report';
import { maybeAxiosError } from '../../../../../common/api/utils';
import IconButton from '../../../../../common/components/buttons/IconButton';
import Input from '../../../../../common/components/input/input/Input';
import Tag from '../../../../../common/components/tag/Tag';
import useRuns from '../../../../../common/hooks-query/useRuns';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import { formatDrift } from '../reportSettings.utils';
import style from './RunsList.module.scss';
interface RunsListProps {
runs: ShowRunSummary[];
rundownId?: string;
selectedRunId: string | null;
onSelect: (id: string) => void;
}
export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: RunsListProps) {
const { refetch } = useRuns(rundownId);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [error, setError] = useState<string | null>(null);
const startRename = (run: ShowRunSummary) => {
setRenamingId(run.id);
setRenameValue(run.label);
};
const submitRename = async (id: string) => {
const label = renameValue.trim();
setRenamingId(null);
if (label.length === 0) {
return;
}
try {
setError(null);
await renameRun(id, label);
} catch (renameError) {
setError(maybeAxiosError(renameError));
} finally {
refetch();
}
};
const handleDelete = async (id: string) => {
try {
setError(null);
await deleteRun(id);
if (id === selectedRunId) {
const next = runs.find((run) => run.id !== id);
if (next) onSelect(next.id);
}
} catch (deleteError) {
setError(maybeAxiosError(deleteError));
} finally {
refetch();
}
};
const handleRenameKeyDown = (event: KeyboardEvent<HTMLInputElement>, id: string) => {
preventEscape(event, () => setRenamingId(null));
if (event.key === 'Enter') {
event.preventDefault();
submitRename(id);
}
};
return (
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>Run</th>
<th>Rundown</th>
<th>Started</th>
<th>Drift</th>
<th />
<th />
</tr>
</thead>
<tbody>
{runs.length === 0 && (
<Panel.TableEmpty
title='No runs yet'
description='A run is created the first time an event is started, and is added to history once playback stops.'
/>
)}
{runs.map((run) => {
const isSelected = run.id === selectedRunId;
const isRenaming = renamingId === run.id;
return (
<tr
key={run.id}
className={cx([style.row, isSelected && style.current])}
onClick={() => onSelect(run.id)}
>
<td>
{isRenaming ? (
<Panel.InlineElements relation='inner' onClick={(event) => event.stopPropagation()}>
<Input
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={(event) => handleRenameKeyDown(event, run.id)}
/>
<IconButton aria-label='Save name' variant='ghosted-white' onClick={() => submitRename(run.id)}>
<IoCheckmark />
</IconButton>
<IconButton
aria-label='Cancel rename'
variant='ghosted-white'
onClick={() => setRenamingId(null)}
>
<IoClose />
</IconButton>
</Panel.InlineElements>
) : (
run.label
)}
</td>
<td>{run.rundownTitle}</td>
<td>{new Date(run.startedAt).toLocaleString()}</td>
<td>{formatDrift(run.summary.drift, run.summary.eventsRun)}</td>
<td>{run.endedAt === null && <Tag variant='active'>Ongoing</Tag>}</td>
<Panel.InlineElements align='end' relation='inner' as='td' onClick={(event) => event.stopPropagation()}>
{!isRenaming && (
<>
<IconButton aria-label='Rename run' variant='ghosted-white' onClick={() => startRename(run)}>
<IoPencil />
</IconButton>
<IconButton
aria-label='Delete run'
variant='ghosted-destructive'
onClick={() => handleDelete(run.id)}
>
<IoTrash />
</IconButton>
</>
)}
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
{error && <Panel.Error>{error}</Panel.Error>}
</Panel.Section>
);
}
@@ -1,7 +1,18 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { EntryId, MaybeNumber, OntimeEventReport, 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 { formatDuration, formatTime } from '../../../../common/utils/time';
/**
* Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed
* events has no meaningful drift to report.
*/
export function formatDrift(drift: number, eventsRun: number): string {
if (eventsRun === 0) return '';
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
export type CombinedReport = {
id: EntryId;
@@ -12,59 +23,96 @@ export type CombinedReport = {
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
playCount: number;
};
/**
* Creates a combined report with the rundown data
* Creates a combined report joining a run's per event data with the rundown.
*
* A run's report keeps its own snapshot of the schedule (`scheduledStart` /
* `scheduledDuration`), so an event that ran is shown against the schedule as
* it was at the time, not against whatever the rundown has been edited to since.
*
* Events that ran but no longer exist in the rundown (deleted since the run)
* are still included, from the snapshot alone, so a historical run stays a
* complete record even after the rundown changes.
*/
export function getCombinedReport(
report: OntimeReport,
rundown: RundownEntries,
rundownEntries: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
if (Object.keys(report).length === 0 && flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
const seen = new Set<EntryId>();
let index = 1;
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id];
for (const id of flatOrder) {
const entry = rundownEntries[id];
if (!entry || !isOntimeEvent(entry)) continue;
if (!(id in report)) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
}
seen.add(id);
combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd));
index++;
}
if (id in report) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: report[id].endedAt,
scheduledEnd: entry.timeEnd,
actualStart: report[id].startedAt,
});
}
for (const [id, reportEntry] of Object.entries(report)) {
if (seen.has(id)) continue;
combinedReport.push(
makeCombinedEntry(
id,
index,
'(deleted event)',
'',
reportEntry,
reportEntry.scheduledStart,
reportEntry.scheduledStart + reportEntry.scheduledDuration,
),
);
index++;
}
return combinedReport;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
function makeCombinedEntry(
id: EntryId,
index: number,
title: string,
cue: string,
reportEntry: OntimeEventReport | undefined,
fallbackStart: number,
fallbackEnd: number,
): CombinedReport {
if (!reportEntry) {
return {
id,
index,
title,
cue,
scheduledStart: fallbackStart,
scheduledEnd: fallbackEnd,
actualStart: null,
actualEnd: null,
playCount: 0,
};
}
return {
id,
index,
title,
cue,
scheduledStart: reportEntry.scheduledStart,
scheduledEnd: reportEntry.scheduledStart + reportEntry.scheduledDuration,
actualStart: reportEntry.startedAt,
actualEnd: reportEntry.endedAt,
playCount: reportEntry.playCount,
};
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End', 'Play count'];
/**
* Transforms a CombinedReport into a CSV string
@@ -82,6 +130,7 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
String(entry.playCount),
]);
}
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -16,4 +16,10 @@
&.due {
color: $warning-orange;
}
// preview of how this event went last time it ran, shown before it plays again
&.muted {
color: $label-gray;
opacity: 0.7;
}
}
@@ -1,10 +1,19 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import {
EventVariance,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
getEventVariance,
isPlaybackActive,
millisToString,
} from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useLoadedRundownId } from '../../../../common/hooks-query/useProjectRundowns';
import useReport from '../../../../common/hooks-query/useReport';
import { useLatestRun } from '../../../../common/hooks-query/useRuns';
import { usePlayback } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -20,7 +29,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +41,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +52,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,49 +93,60 @@ 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) {
return [null, 'none', ''];
// an event with nothing in the current run's report yet can still show how
// it went last time, so a repeat show previews its schedule before it plays
const loadedRundownId = useLoadedRundownId();
const { data: lastRun } = useLatestRun(loadedRundownId ?? undefined);
const [value, chipStyle, tooltip] = useMemo(() => {
// compares against the schedule as it was when the event ran, not the
// rundown's current values, so an edit made afterwards cannot change this
const variance = getEventVariance(currentReport);
if (variance.status !== 'not-run') {
return describeVariance(variance, false);
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
const lastRunVariance = getEventVariance(lastRun?.report[id]);
if (lastRunVariance.status !== 'not-run') {
return describeVariance(lastRunVariance, true);
}
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 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, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [null, 'none', ''];
}, [currentReport, id, lastRun]);
if (!value) {
return null;
}
return (
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[overUnderStyle], className])}>
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[chipStyle], className])}>
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</Tooltip>
);
}
/**
* Formats a variance into the chip's value, style and tooltip.
* `muted` marks a preview of a past run rather than a live status.
*/
function describeVariance(variance: EventVariance, muted: boolean): [string, string, string] {
const prefix = muted ? 'Last run: ' : '';
if (variance.status === 'ontime') {
return ['ontime', muted ? 'muted' : 'under', `${prefix}Event finished on time`];
}
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const fullTimeValue = millisToString(absDifference);
const tooltip = `${prefix}Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, muted ? 'muted' : isOver ? 'over' : 'under', tooltip];
}
@@ -0,0 +1,285 @@
import { TimerLifeCycle } from 'ontime-types';
import type { PlayableEvent, ShowRun } from 'ontime-types';
import { vi } from 'vitest';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts
let runs: ShowRun[] = [];
vi.mock('../../../services/report-service/report.store.js', () => ({
// isolation between tests comes from the top-level beforeEach resetting `runs`,
// this mirrors the real store returning whatever is already on "disk"
loadReports: vi.fn(async () => ({ runs })),
getRuns: vi.fn(() => runs),
getRun: vi.fn((id: string) => runs.find((run) => run.id === id)),
upsertRun: vi.fn(async (run: ShowRun) => {
const index = runs.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
runs.unshift(run);
} else {
runs[index] = run;
}
}),
deleteRun: vi.fn(async (id: string) => {
const index = runs.findIndex((run) => run.id === id);
if (index === -1) return false;
runs.splice(index, 1);
return true;
}),
deleteRunsForRundown: vi.fn(async (rundownId: string) => {
const before = runs.length;
runs = runs.filter((run) => run.rundownId !== rundownId);
return before - runs.length;
}),
deleteAllRuns: vi.fn(async () => {
runs = [];
}),
}));
let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' });
vi.mock('../../rundown/rundown.dao.js', () => ({
getCurrentRundown: vi.fn(() => currentRundown),
}));
const {
generate,
clear,
triggerReportEntry,
closeRun,
initReports,
listRuns,
getRun,
getLatestRun,
renameRun,
deleteRun,
deleteAllRuns,
} = await import('../report.service.js');
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent;
beforeEach(async () => {
runs = [];
currentRundown = makeRundown({
id: 'rundown-1',
title: 'Test rundown',
entries: { [eventA.id]: eventA, [eventB.id]: eventB },
order: [eventA.id, eventB.id],
flatOrder: [eventA.id, eventB.id],
});
await initReports('project-a');
});
describe('triggerReportEntry()', () => {
it('captures a snapshot of the schedule when an event starts', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toMatchObject({
[eventA.id]: {
startedAt: 500,
endedAt: null,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
playCount: 1,
},
});
});
it('records the end time on stop, keeping the snapshot taken at start', () => {
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,
scheduledDuration: eventA.duration,
});
});
it('increments playCount when an event is re-run within the same show', () => {
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('ignores events without an id', () => {
const state = makeRuntimeStateData({ eventNow: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toEqual({});
});
it('persists a run to history on the first event stop', async () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
// persistence happens off the event loop
await new Promise((resolve) => setImmediate(resolve));
expect(listRuns()).toHaveLength(1);
expect(listRuns()[0].rundownId).toBe('rundown-1');
});
});
describe('closeRun()', () => {
it('closes the open run and starts a fresh one on the next event', async () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
closeRun();
await new Promise((resolve) => setImmediate(resolve));
expect(listRuns()).toHaveLength(1);
expect(listRuns()[0].endedAt).toBe(10000);
// a new start after closing opens a second run rather than reusing the first
const secondStart = makeRuntimeStateData({ eventNow: eventB, timer: { startedAt: 20000 }, clock: 20000 });
triggerReportEntry(TimerLifeCycle.onStart, secondStart);
triggerReportEntry(TimerLifeCycle.onStop, { ...secondStart, clock: 30000 } as typeof secondStart);
await new Promise((resolve) => setImmediate(resolve));
expect(listRuns()).toHaveLength(2);
});
it('does nothing when no run is open', () => {
expect(() => closeRun()).not.toThrow();
expect(listRuns()).toHaveLength(0);
});
});
describe('initReports()', () => {
it('closes a dangling run left open by a crash or shutdown', async () => {
runs = [
{
id: 'dangling',
rundownId: 'rundown-1',
rundownTitle: 'Test rundown',
label: 'unfinished',
startedAt: 0,
endedAt: null,
report: {
[eventA.id]: {
startedAt: 0,
endedAt: 9000,
scheduledStart: 0,
scheduledDuration: 10000,
playCount: 1,
},
},
summary: {
eventsRun: 1,
eventsPlanned: 2,
scheduledDuration: 10000,
actualDuration: 9000,
drift: -1000,
eventsOver: 0,
eventsUnder: 1,
eventsOnTime: 0,
worstOverrun: null,
},
},
];
await initReports('project-a');
const recovered = getRun('dangling');
expect(recovered?.endedAt).toBe(9000);
});
});
describe('run history queries and edits', () => {
async function makeClosedRun(id: string, rundownId = 'rundown-1') {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
currentRundown = { ...currentRundown, id: rundownId };
triggerReportEntry(TimerLifeCycle.onStart, start);
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
closeRun();
await new Promise((resolve) => setImmediate(resolve));
// stamp a predictable id so tests can address the run directly
const created = listRuns()[0];
runs[0] = { ...runs[0], id };
return created;
}
it('filters listRuns by rundown', async () => {
await makeClosedRun('run-a', 'rundown-1');
await makeClosedRun('run-b', 'rundown-2');
expect(listRuns('rundown-1').map((run) => run.id)).toEqual(['run-a']);
expect(listRuns('rundown-2').map((run) => run.id)).toEqual(['run-b']);
expect(listRuns()).toHaveLength(2);
});
it('returns the most recently started closed run', async () => {
await makeClosedRun('older');
await makeClosedRun('newer');
runs.find((run) => run.id === 'older')!.startedAt = 0;
runs.find((run) => run.id === 'newer')!.startedAt = 100000;
expect(getLatestRun()?.id).toBe('newer');
});
it('renames a run', async () => {
await makeClosedRun('run-a');
const renamed = await renameRun('run-a', 'Dress rehearsal');
expect(renamed?.label).toBe('Dress rehearsal');
expect(getRun('run-a')?.label).toBe('Dress rehearsal');
});
it('returns undefined when renaming a run that does not exist', async () => {
expect(await renameRun('missing', 'x')).toBeUndefined();
});
it('deletes a single run', async () => {
await makeClosedRun('run-a');
expect(await deleteRun('run-a')).toBe(true);
expect(getRun('run-a')).toBeUndefined();
});
it('clears the in-progress report when the open run is deleted', async () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
await new Promise((resolve) => setImmediate(resolve));
const openRunId = listRuns()[0].id;
await deleteRun(openRunId);
expect(generate()).toEqual({});
});
it('deletes all run history', async () => {
await makeClosedRun('run-a');
await makeClosedRun('run-b');
await deleteAllRuns();
expect(listRuns()).toHaveLength(0);
});
});
describe('clear()', () => {
it('clears a single event from the in-progress report', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
clear(eventA.id);
expect(generate()).toEqual({});
});
it('clears the entire in-progress report', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
clear();
expect(generate()).toEqual({});
});
});
@@ -3,15 +3,80 @@ import type { Request, Response, Router } from 'express';
import { paramsWithId } from '../validation-utils/validationFunction.js';
import * as report from './report.service.js';
import { validateRundownIdQuery, validateRunLabel } from './report.validation.js';
export const router: Router = express.Router();
/**
* Current run's report, kept unchanged so existing HTTP automations and the
* Companion module are unaffected.
*/
router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate());
});
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
/**
* Run history, most recent first. `?rundownId=` scopes the list to one rundown.
*/
router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => {
const { rundownId } = req.query as { rundownId?: string };
res.status(200).json(report.listRuns(rundownId));
});
/**
* Most recently closed run, used to compare a rundown against its last outing.
* Registered ahead of /runs/:id so "latest" is not read as an id.
*/
router.get('/runs/latest', validateRundownIdQuery, (req: Request, res: Response) => {
const { rundownId } = req.query as { rundownId?: string };
const run = report.getLatestRun(rundownId);
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => {
const { id } = req.params;
const run = report.getRun(id);
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
/**
* Renames a run, the only field a user can edit after the fact.
*/
router.patch('/runs/:id', validateRunLabel, async (req: Request, res: Response) => {
const { id } = req.params;
const { label } = req.body as { label: string };
const run = await report.renameRun(id, label);
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
/**
* Deletes a single run, eg: a test run that should not pollute the history.
*/
router.delete('/runs/:id', paramsWithId, async (req: Request, res: Response) => {
const { id } = req.params;
const didDelete = await report.deleteRun(id);
if (!didDelete) {
res.status(404).send();
return;
}
res.status(204).send();
});
router.delete('/all', async (_req: Request, res: Response) => {
// clears both the run history and the report of the run in progress
await report.deleteAllRuns();
res.status(204).send();
});
@@ -1,13 +1,28 @@
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
import {
EntryId,
OntimeEventReport,
OntimeReport,
RefetchKey,
ShowRun,
ShowRunSummary,
TimerLifeCycle,
} from 'ontime-types';
import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import * as reportStore from '../../services/report-service/report.store.js';
import { RuntimeState } from '../../stores/runtimeState.js';
import { getCurrentRundown } from '../rundown/rundown.dao.js';
const report = new Map<string, OntimeEventReport>();
/** per event data for the run currently in progress */
const report = new Map<EntryId, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/** metadata for the run in progress, null when no run is open */
let openRun: Omit<ShowRun, 'report' | 'summary'> | null = null;
/**
* generates a full report
* @returns full report
@@ -30,6 +45,7 @@ export function clear(id?: string) {
} else {
report.clear();
}
void persistOpenRun();
}
/**
@@ -49,15 +65,197 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
openRunIfNeeded(state);
// an event started twice in the same run 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 rewrite this run
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;
void persistOpenRun();
sendRefetch(RefetchKey.Report);
}
}
/**
* Closes the run in progress.
* Called when playback stops and events are unloaded, which is the operator
* saying the show is over. Pausing or loading another event does not end a run.
*/
export function closeRun() {
if (openRun === null) {
return;
}
// detach the run before the async write so a start arriving in between
// opens a new run instead of appending to the one we are closing
const closing = { ...openRun, endedAt: lastEndedAt() };
openRun = null;
void persistRun(closing, generate());
sendRefetch(RefetchKey.Report);
}
/**
* Opens a run on the first event start after the previous run closed.
* The in progress report is reset here rather than on close, so the rundown
* chips keep showing the run that just finished.
* @private
*/
function openRunIfNeeded(state: DeepReadonly<RuntimeState>) {
if (openRun !== null) {
return;
}
report.clear();
formattedReport = null;
const rundown = getCurrentRundown();
const startedAt = state.rundown.actualStart ?? state.clock;
openRun = {
id: generateId(),
rundownId: rundown.id,
rundownTitle: rundown.title,
label: new Date().toISOString(),
startedAt,
endedAt: null,
};
}
/**
* Writes the run in progress to the sidecar.
* Persisting on every event stop means an interrupted show still leaves a record.
* @private
*/
async function persistOpenRun(): Promise<void> {
if (openRun === null) {
return;
}
await persistRun(openRun, generate());
}
/**
* Writes a run and its derived summary to the sidecar
* @private
*/
async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, currentReport: OntimeReport): Promise<void> {
const rundown = getCurrentRundown();
const eventsPlanned = countPlannedEvents(rundown.entries, rundown.flatOrder);
await reportStore.upsertRun({
...run,
report: structuredClone(currentReport),
summary: getRunSummary(currentReport, eventsPlanned),
});
}
/**
* Timestamp of the last event to finish in this run
* @private
*/
function lastEndedAt(): number | null {
let latest: number | null = null;
for (const entry of report.values()) {
if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) {
latest = entry.endedAt;
}
}
return latest;
}
/**
* Prepares reporting for a newly loaded project.
* Any run left open by a crash or shutdown is closed against its own data
* so it cannot absorb events from the next show.
*/
export async function initReports(projectFilename: string): Promise<void> {
report.clear();
formattedReport = null;
openRun = null;
await reportStore.loadReports(projectFilename);
const dangling = reportStore.getRuns().find((run) => run.endedAt === null);
if (dangling) {
const endedAt = Object.values(dangling.report).reduce<number | null>((latest, entry) => {
if (entry.endedAt === null) return latest;
return latest === null || entry.endedAt > latest ? entry.endedAt : latest;
}, null);
await reportStore.upsertRun({ ...dangling, endedAt });
}
}
/** Run history for the current project, without per event data */
export function listRuns(rundownId?: string): ShowRunSummary[] {
return reportStore
.getRuns()
.filter((run) => rundownId === undefined || run.rundownId === rundownId)
.map(({ report: _report, ...rest }) => rest);
}
export function getRun(id: string): ShowRun | undefined {
return reportStore.getRun(id);
}
/** Most recent closed run, used to compare a rundown against its last outing */
export function getLatestRun(rundownId?: string): ShowRun | undefined {
return reportStore
.getRuns()
.filter((run) => run.endedAt !== null && (rundownId === undefined || run.rundownId === rundownId))
.sort((a, b) => b.startedAt - a.startedAt)
.at(0);
}
export async function renameRun(id: string, label: string): Promise<ShowRun | undefined> {
const run = reportStore.getRun(id);
if (!run) {
return undefined;
}
const renamed = { ...run, label };
await reportStore.upsertRun(renamed);
sendRefetch(RefetchKey.Report);
return renamed;
}
export async function deleteRun(id: string): Promise<boolean> {
const didDelete = await reportStore.deleteRun(id);
if (didDelete) {
if (openRun?.id === id) {
openRun = null;
report.clear();
formattedReport = null;
}
sendRefetch(RefetchKey.Report);
}
return didDelete;
}
export async function deleteAllRuns(): Promise<void> {
await reportStore.deleteAllRuns();
openRun = null;
report.clear();
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
@@ -0,0 +1,14 @@
import { body, param, query } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validateRundownIdQuery = [
query('rundownId').optional().isString().trim().notEmpty(),
requestValidationFunction,
];
export const validateRunLabel = [
param('id').isString().trim().notEmpty(),
body('label').isString().trim().notEmpty(),
requestValidationFunction,
];
@@ -25,6 +25,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { logger } from '../../classes/Logger.js';
import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { deleteRunsForRundown } from '../../services/report-service/report.store.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
@@ -892,6 +893,9 @@ export async function deleteRundown(id: string) {
const projectRundowns = await dataProvider.deleteRundown(id);
// a rundown's run history has no meaning once the rundown is gone
await deleteRunsForRundown(id);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
@@ -6,6 +6,8 @@ import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { initReports } from '../../api-data/report/report.service.js';
import { deleteReportsForProject, renameReportsForProject } from '../report-service/report.store.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
@@ -63,6 +65,7 @@ function init() {
ensureDirectory(publicDir.corruptDir);
ensureDirectory(publicDir.logoDir);
ensureDirectory(publicDir.migrateDir);
ensureDirectory(publicDir.reportsDir);
}
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
@@ -88,6 +91,9 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
// stop the runtime service
runtimeService.stop();
// point reporting at this project's sidecar, reports do not cross projects
await initReports(fileName);
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
@@ -263,6 +269,8 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate);
// deliberately not copying report history: a duplicate is a new show and
// inheriting another project's run history would be misleading
return;
}
@@ -284,6 +292,9 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const pathToRenamed = getPathToProject(newFilename);
await dockerSafeRename(projectFilePath, pathToRenamed);
// run history follows the project it belongs to
await renameReportsForProject(originalFile, newFilename);
// Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
@@ -332,6 +343,8 @@ export async function deleteProjectFile(filename: string) {
}
await deleteFile(projectFilePath);
// reports are owned by their project and do not outlive it
await deleteReportsForProject(filename);
}
/**
@@ -0,0 +1,213 @@
import type { ShowRun } from 'ontime-types';
import { vi } from 'vitest';
// in-memory stand-in for the JSON file on disk, keyed by path
const files = new Map<string, unknown>();
vi.mock('lowdb/node', () => {
class JSONFile {
private path: string;
constructor(path: string) {
this.path = path;
}
async read() {
return files.has(this.path) ? files.get(this.path) : null;
}
async write(data: unknown) {
files.set(this.path, data);
}
}
return { JSONFile };
});
vi.mock('../../../utils/fileManagement.js', async () => {
const actual = await vi.importActual<typeof import('../../../utils/fileManagement.js')>(
'../../../utils/fileManagement.js',
);
return {
...actual,
deleteFile: vi.fn(async (path: string) => {
files.delete(path);
}),
dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => {
if (files.has(oldPath)) {
files.set(newPath, files.get(oldPath));
files.delete(oldPath);
}
}),
statIfExists: vi.fn(async (path: string) => (files.has(path) ? {} : null)),
};
});
const {
loadReports,
getRuns,
getRun,
upsertRun,
deleteRun,
deleteRunsForRundown,
deleteAllRuns,
deleteReportsForProject,
renameReportsForProject,
resetStore,
getPathToReports,
} = await import('../report.store.js');
function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
return {
id: 'run-1',
rundownId: 'rundown-1',
rundownTitle: 'My rundown',
label: '2026-08-08',
startedAt: 1000,
endedAt: 2000,
report: {},
summary: {
eventsRun: 0,
eventsPlanned: 0,
scheduledDuration: 0,
actualDuration: 0,
drift: 0,
eventsOver: 0,
eventsUnder: 0,
eventsOnTime: 0,
worstOverrun: null,
},
...patch,
};
}
beforeEach(() => {
files.clear();
resetStore();
});
describe('loadReports()', () => {
it('starts empty for a project with no sidecar', async () => {
const result = await loadReports('project-a');
expect(result.runs).toEqual([]);
expect(getRuns()).toEqual([]);
});
it('loads runs already on disk', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun()] });
const result = await loadReports('project-a');
expect(result.runs).toHaveLength(1);
expect(getRuns()[0].id).toBe('run-1');
});
it('discards a corrupt sidecar rather than throwing', async () => {
files.set(getPathToReports('project-a'), { runs: 'not-an-array' });
const result = await loadReports('project-a');
expect(result.runs).toEqual([]);
});
it('scopes runs to the loaded project', async () => {
files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] });
files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] });
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['a']);
await loadReports('project-b');
expect(getRuns().map((run) => run.id)).toEqual(['b']);
});
});
describe('upsertRun() / getRun() / getRuns()', () => {
beforeEach(async () => {
await loadReports('project-a');
});
it('inserts a new run at the front of the list', async () => {
await upsertRun(makeRun({ id: 'first' }));
await upsertRun(makeRun({ id: 'second' }));
expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']);
});
it('replaces an existing run in place rather than duplicating it', async () => {
await upsertRun(makeRun({ id: 'run-1', label: 'first pass' }));
await upsertRun(makeRun({ id: 'run-1', label: 'renamed' }));
expect(getRuns()).toHaveLength(1);
expect(getRun('run-1')?.label).toBe('renamed');
});
it('persists to disk', async () => {
await upsertRun(makeRun());
const reloaded = await loadReports('project-a');
expect(reloaded.runs).toHaveLength(1);
});
});
describe('deleteRun()', () => {
beforeEach(async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'keep' }));
await upsertRun(makeRun({ id: 'discard' }));
});
it('removes only the targeted run', async () => {
const didDelete = await deleteRun('discard');
expect(didDelete).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['keep']);
});
it('reports false for a run that does not exist', async () => {
expect(await deleteRun('missing')).toBe(false);
expect(getRuns()).toHaveLength(2);
});
});
describe('deleteRunsForRundown()', () => {
it('removes only runs belonging to the given rundown', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a', rundownId: 'rundown-x' }));
await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' }));
await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' }));
const removed = await deleteRunsForRundown('rundown-x');
expect(removed).toBe(2);
expect(getRuns().map((run) => run.id)).toEqual(['b']);
});
});
describe('deleteAllRuns()', () => {
it('empties the run history', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
await deleteAllRuns();
expect(getRuns()).toEqual([]);
});
});
describe('project lifecycle', () => {
it('deletes the sidecar for a project', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
expect(files.has(getPathToReports('project-a'))).toBe(true);
await deleteReportsForProject('project-a');
expect(files.has(getPathToReports('project-a'))).toBe(false);
});
it('does nothing when the project never had a sidecar', async () => {
await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined();
});
it('moves the sidecar to follow a project rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun());
await renameReportsForProject('project-a', 'project-b');
expect(files.has(getPathToReports('project-a'))).toBe(false);
const moved = files.get(getPathToReports('project-b')) as { runs: ShowRun[] };
expect(moved.runs).toHaveLength(1);
});
it('does nothing when renaming a project that never had a sidecar', async () => {
await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,130 @@
import type { OntimeEventReport, ProjectReports, RunSummary, ShowRun } from 'ontime-types';
import { is } from '../../utils/is.js';
/**
* Validates the contents of a report sidecar file.
* A file which fails validation is discarded rather than repaired: reports are
* a record, and a partially understood record is worse than an empty one.
*/
export function isProjectReports(value: unknown): value is ProjectReports {
if (!is.object(value)) {
return false;
}
if (!is.objectWithKeys(value, ['runs'])) {
return false;
}
if (!is.array(value.runs)) {
return false;
}
return value.runs.every(isShowRun);
}
function isShowRun(value: unknown): value is ShowRun {
if (!is.object(value)) {
return false;
}
if (
!is.objectWithKeys(value, [
'id',
'rundownId',
'rundownTitle',
'label',
'startedAt',
'endedAt',
'report',
'summary',
])
) {
return false;
}
if (!is.string(value.id) || !is.string(value.rundownId) || !is.string(value.rundownTitle)) {
return false;
}
if (!is.string(value.label)) {
return false;
}
if (!is.number(value.startedAt)) {
return false;
}
if (!is.number(value.endedAt) && value.endedAt !== null) {
return false;
}
if (!isOntimeReport(value.report)) {
return false;
}
return isRunSummary(value.summary);
}
function isOntimeReport(value: unknown): value is Record<string, OntimeEventReport> {
if (!is.object(value)) {
return false;
}
return Object.values(value).every(isEventReport);
}
function isEventReport(value: unknown): value is OntimeEventReport {
if (!is.object(value)) {
return false;
}
if (!is.objectWithKeys(value, ['startedAt', 'endedAt', 'scheduledStart', 'scheduledDuration', 'playCount'])) {
return false;
}
if (!is.number(value.startedAt) && value.startedAt !== null) {
return false;
}
if (!is.number(value.endedAt) && value.endedAt !== null) {
return false;
}
return is.number(value.scheduledStart) && is.number(value.scheduledDuration) && is.number(value.playCount);
}
function isRunSummary(value: unknown): value is RunSummary {
if (!is.object(value)) {
return false;
}
const numericKeys = [
'eventsRun',
'eventsPlanned',
'scheduledDuration',
'actualDuration',
'drift',
'eventsOver',
'eventsUnder',
'eventsOnTime',
] as const;
if (!is.objectWithKeys(value, [...numericKeys, 'worstOverrun'])) {
return false;
}
if (!numericKeys.every((key) => is.number(value[key]))) {
return false;
}
if (value.worstOverrun === null) {
return true;
}
if (!is.object(value.worstOverrun) || !is.objectWithKeys(value.worstOverrun, ['id', 'delta'])) {
return false;
}
return is.string(value.worstOverrun.id) && is.number(value.worstOverrun.delta);
}
@@ -0,0 +1,182 @@
import { join } from 'path';
import { JSONFile } from 'lowdb/node';
import type { ProjectReports, ShowRun } from 'ontime-types';
import { publicDir } from '../../setup/index.js';
import { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js';
import { isProjectReports } from './report.parser.js';
/**
* Reports are kept in a sidecar file per project rather than in the project
* itself. This keeps the project file free of mid-show writes and lets the
* run history grow without bloating what the user exports.
*
* Persistence is best effort by design: a failing disk degrades reporting
* but must never interrupt a running show.
*/
/**
* Returns a fresh empty store.
* Must not be a shared constant: `cache.runs` is mutated in place elsewhere
* in this module, and a shared array would leak state between projects.
*/
function emptyStore(): ProjectReports {
return { runs: [] };
}
let fileRef: JSONFile<ProjectReports> | null = null;
let cache: ProjectReports = emptyStore();
let failedWriteAttempts = 0;
/**
* Resolves the sidecar path for a given project file name
*/
export function getPathToReports(projectFilename: string): string {
return join(publicDir.reportsDir, ensureJsonExtension(projectFilename));
}
/**
* Points the store at a project's sidecar and loads whatever is on disk.
* Called on every project load, which is the single choke point for
* project changes.
*/
export async function loadReports(projectFilename: string): Promise<ProjectReports> {
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename));
failedWriteAttempts = 0;
try {
const maybeReports = await fileRef.read();
cache = isProjectReports(maybeReports) ? maybeReports : emptyStore();
} catch (_error) {
// a missing or corrupt sidecar is not worth interrupting a project load over
cache = emptyStore();
}
return cache;
}
/**
* Returns the runs held for the current project, newest first
*/
export function getRuns(): ShowRun[] {
return cache.runs;
}
export function getRun(id: string): ShowRun | undefined {
return cache.runs.find((run) => run.id === id);
}
/**
* Inserts or replaces a run, keeping the list ordered newest first
*/
export async function upsertRun(run: ShowRun): Promise<void> {
const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
cache.runs.unshift(run);
} else {
cache.runs[index] = run;
}
await persist();
}
/**
* Deletes a single run, used to discard a test run from the history
* @returns whether a run was found and removed
*/
export async function deleteRun(id: string): Promise<boolean> {
const index = cache.runs.findIndex((run) => run.id === id);
if (index === -1) {
return false;
}
cache.runs.splice(index, 1);
await persist();
return true;
}
/**
* Deletes every run belonging to a rundown, cascaded from rundown deletion
* @returns how many runs were removed
*/
export async function deleteRunsForRundown(rundownId: string): Promise<number> {
const before = cache.runs.length;
cache.runs = cache.runs.filter((run) => run.rundownId !== rundownId);
const removed = before - cache.runs.length;
if (removed > 0) {
await persist();
}
return removed;
}
/**
* Clears the run history of the current project
*/
export async function deleteAllRuns(): Promise<void> {
cache.runs = [];
await persist();
}
/**
* Removes a project's sidecar from disk.
* Reports are owned by their project and do not outlive it.
*/
export async function deleteReportsForProject(projectFilename: string): Promise<void> {
const path = getPathToReports(projectFilename);
try {
if ((await statIfExists(path)) !== null) {
await deleteFile(path);
}
} catch (_error) {
// a leftover sidecar is harmless, deleting the project must still succeed
}
}
/**
* Moves a project's sidecar so run history follows a project rename
*/
export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise<void> {
const originalPath = getPathToReports(originalFilename);
const newPath = getPathToReports(newFilename);
try {
if ((await statIfExists(originalPath)) === null) {
return;
}
await dockerSafeRename(originalPath, newPath);
// keep the reference pointing at the file we just moved
if (fileRef) {
fileRef = new JSONFile<ProjectReports>(newPath);
}
} catch (_error) {
// losing history on rename is bad but not fatal, the project rename stands
}
}
/**
* Writes the cache to disk.
* Gives up after repeated failures so a broken disk cannot stall the runtime.
* @private
*/
async function persist(): Promise<void> {
if (fileRef === null || failedWriteAttempts > 3) {
return;
}
try {
await fileRef.write(cache);
failedWriteAttempts = 0;
} catch (_error) {
failedWriteAttempts += 1;
}
}
/**
* Resets in-memory state, used when no project is loaded and in tests
*/
export function resetStore(): void {
fileRef = null;
cache = emptyStore();
failedWriteAttempts = 0;
}
@@ -17,7 +17,7 @@ import {
import { millisToString, validatePlayback } from 'ontime-utils';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { logger } from '../../classes/Logger.js';
@@ -523,6 +523,8 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
// a full stop unloads the events, which is the operator ending the show
closeRun();
triggerAutomations(TimerLifeCycle.onStop);
});
+1
View File
@@ -23,6 +23,7 @@ export const config = {
external: 'external',
demo: 'demo',
projects: 'projects',
reports: 'reports',
sheets: {
directory: 'sheets',
},
+2
View File
@@ -124,6 +124,8 @@ export const publicDir = {
crashDir: join(resolvePublicDirectory, config.crash),
/** path to projects folder */
projectsDir: join(resolvePublicDirectory, config.projects),
/** path to show reports folder, one sidecar file per project */
reportsDir: join(resolvePublicDirectory, config.reports),
/** path to corrupt folder */
corruptDir: join(resolvePublicDirectory, config.corrupt),
/** path to migrated folder */
@@ -1,8 +1,58 @@
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 rewrites history.
*/
scheduledStart: number;
scheduledDuration: number;
/** how many times the event was started within this run, >1 means it was re-run */
playCount: number;
};
export type OntimeReport = Record<string, OntimeEventReport>;
export type OntimeReport = Record<EntryId, OntimeEventReport>;
export type RunSummary = {
/** events which produced a report entry */
eventsRun: number;
/** playable events in the rundown at the time the summary was made */
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 ShowRun = {
id: string;
rundownId: string;
/**
* Denormalised so a run stays readable after its rundown
* is renamed or deleted.
*/
rundownTitle: string;
/** user editable, defaults to a formatted timestamp */
label: string;
startedAt: number;
endedAt: MaybeNumber;
report: OntimeReport;
summary: RunSummary;
};
/** A run without its per event data, for list views */
export type ShowRunSummary = Omit<ShowRun, 'report'>;
/** Contents of a project's report sidecar file */
export type ProjectReports = {
runs: ShowRun[];
};
+8 -1
View File
@@ -24,7 +24,14 @@ 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,
ProjectReports,
RunSummary,
ShowRun,
ShowRunSummary,
} 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;
}