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 CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const REPORT_RUNS = ['report', 'runs'];
export const getLatestRunQueryKey = (rundownId?: string) => ['report', 'runs', 'latest', rundownId ?? null];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
// API URLs // API URLs
+50 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { OntimeReport } from 'ontime-types'; import { OntimeReport, ShowRun, ShowRunSummary } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -24,3 +24,52 @@ export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`); await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); 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 }; 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() { export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient(); 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 { useEffect, useState } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report'; import Select from '../../../../common/components/select/Select';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import Button from '../../../../common/components/buttons/Button'; import useRuns from '../../../../common/hooks-query/useRuns';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { 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() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: rundownsList } = useProjectRundowns();
const { data } = useRundown(); 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(); // keep a valid selection: default to the most recent run, and fall off the
const downloadCSV = (combinedReport: CombinedReport[]) => { // current one when it drops out of the filtered list
if (!combinedReport) { useEffect(() => {
return; if (selectedRunId && runs.some((run) => run.id === selectedRunId)) return;
} setSelectedRunId(runs[0]?.id ?? null);
const csv = makeReportCSV(combinedReport); }, [runs, selectedRunId]);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
const combinedReport = useMemo(() => { const rundownOptions = [
return getCombinedReport(reportData, data.entries, data.flatOrder); { value: allRundowns, label: 'All rundowns' },
}, [reportData, data.entries, data.flatOrder]); ...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })),
];
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader> <Panel.SubHeader>
<Panel.Divider /> Show reports
<Panel.Section> {rundownsList.rundowns.length > 1 && (
<Panel.Title> <Select
Manage report value={rundownFilter}
<Panel.InlineElements> onValueChange={(value: string | null) => value && setRundownFilter(value)}
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}> options={rundownOptions}
<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.'
/> />
)} )}
</Panel.SubHeader>
{combinedReport.map((entry) => { <Panel.Divider />
const start = (() => { <Panel.Paragraph>
if (entry.actualStart === null) return null; A run is created the first time an event is started, and is added to history once playback stops. Every
if (entry.actualStart <= entry.scheduledStart) return 'under'; run keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
return 'over'; </Panel.Paragraph>
})(); <RunsList runs={runs} rundownId={rundownFilter === allRundowns ? undefined : rundownFilter} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card> </Panel.Card>
{selectedRunId && (
<Panel.Card>
<RunDetail runId={selectedRunId} />
</Panel.Card>
)}
</Panel.Section> </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 { 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 = { export type CombinedReport = {
id: EntryId; id: EntryId;
@@ -12,59 +23,96 @@ export type CombinedReport = {
actualStart: MaybeNumber; actualStart: MaybeNumber;
scheduledEnd: number; scheduledEnd: number;
actualEnd: MaybeNumber; 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( export function getCombinedReport(
report: OntimeReport, report: OntimeReport,
rundown: RundownEntries, rundownEntries: RundownEntries,
flatOrder: EntryId[], flatOrder: EntryId[],
): CombinedReport[] { ): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0 && flatOrder.length === 0) return [];
if (flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = []; const combinedReport: CombinedReport[] = [];
const seen = new Set<EntryId>();
let index = 1; let index = 1;
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i]; for (const id of flatOrder) {
const entry = rundown[id]; const entry = rundownEntries[id];
if (!entry || !isOntimeEvent(entry)) continue; if (!entry || !isOntimeEvent(entry)) continue;
if (!(id in report)) { seen.add(id);
combinedReport.push({ combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd));
id: id, index++;
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
} }
if (id in report) { for (const [id, reportEntry] of Object.entries(report)) {
combinedReport.push({ if (seen.has(id)) continue;
id: id, combinedReport.push(
index: index, makeCombinedEntry(
title: entry.title, id,
cue: entry.cue, index,
scheduledStart: entry.timeStart, '(deleted event)',
actualEnd: report[id].endedAt, '',
scheduledEnd: entry.timeEnd, reportEntry,
actualStart: report[id].startedAt, reportEntry.scheduledStart,
}); reportEntry.scheduledStart + reportEntry.scheduledDuration,
} ),
);
index++; index++;
} }
return combinedReport; 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 * Transforms a CombinedReport into a CSV string
@@ -82,6 +130,7 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
formatTime(entry.actualStart), formatTime(entry.actualStart),
formatTime(entry.scheduledEnd), formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd), formatTime(entry.actualEnd),
String(entry.playCount),
]); ]);
} }
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast} isPast={isPast}
isLoaded={loaded} isLoaded={loaded}
totalGap={totalGap} totalGap={totalGap}
duration={duration}
/> />
)} )}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}> <div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -16,4 +16,10 @@
&.due { &.due {
color: $warning-orange; 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 { 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 { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5'; import { IoCheckmarkCircle } from 'react-icons/io5';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useLoadedRundownId } from '../../../../common/hooks-query/useProjectRundowns';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useLatestRun } from '../../../../common/hooks-query/useRuns';
import { usePlayback } from '../../../../common/hooks/useSocket'; import { usePlayback } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -20,7 +29,6 @@ interface RundownEventChipProps {
isLoaded: boolean; isLoaded: boolean;
className: string; className: string;
totalGap: number; totalGap: number;
duration: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
@@ -33,7 +41,6 @@ export default function RundownEventChip({
className, className,
totalGap, totalGap,
id, id,
duration,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEventChipProps) { }: RundownEventChipProps) {
const playback = usePlayback(); const playback = usePlayback();
@@ -45,7 +52,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback); const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) { if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />; return <EventReport className={className} id={id} />;
} }
if (playbackActive) { if (playbackActive) {
@@ -86,49 +93,60 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps { interface EventReportProps {
className: string; className: string;
id: string; id: string;
duration: number;
} }
function EventReport(props: EventReportProps) { function EventReport(props: EventReportProps) {
const { className, id, duration } = props; const { className, id } = props;
const { data } = useReport(); const { data } = useReport();
const currentReport = data[id]; const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => { // an event with nothing in the current run's report yet can still show how
if (!currentReport) { // 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 lastRunVariance = getEventVariance(lastRun?.report[id]);
if (lastRunVariance.status !== 'not-run') {
return describeVariance(lastRunVariance, true);
}
return [null, 'none', '']; return [null, 'none', ''];
} }, [currentReport, id, lastRun]);
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'under', 'Event finished on time'];
}
const 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]);
if (!value) { if (!value) {
return null; return null;
} }
return ( 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} {value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</Tooltip> </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 { paramsWithId } from '../validation-utils/validationFunction.js';
import * as report from './report.service.js'; import * as report from './report.service.js';
import { validateRundownIdQuery, validateRunLabel } from './report.validation.js';
export const router: Router = express.Router(); 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) => { router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate()); 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(); 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 { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import * as reportStore from '../../services/report-service/report.store.js';
import { RuntimeState } from '../../stores/runtimeState.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; 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 * generates a full report
* @returns full report * @returns full report
@@ -30,6 +45,7 @@ export function clear(id?: string) {
} else { } else {
report.clear(); report.clear();
} }
void persistOpenRun();
} }
/** /**
@@ -49,15 +65,197 @@ export function triggerReportEntry(
const eventId = state.eventNow.id; const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) { 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; formattedReport = null;
return; return;
} }
if (cycle === TimerLifeCycle.onStop) { if (cycle === TimerLifeCycle.onStop) {
const startedAt = report.get(eventId)?.startedAt ?? null; const previous = report.get(eventId);
report.set(eventId, { startedAt, endedAt: state.clock }); 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; formattedReport = null;
void persistOpenRun();
sendRefetch(RefetchKey.Report); 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 { logger } from '../../classes/Logger.js';
import { makeNewRundown } from '../../models/dataModel.js'; import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.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 { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js'; import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.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); const projectRundowns = await dataProvider.deleteRundown(id);
// a rundown's run history has no meaning once the rundown is gone
await deleteRunsForRundown(id);
setImmediate(() => { setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns); sendRefetch(RefetchKey.ProjectRundowns);
}); });
@@ -6,6 +6,8 @@ import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.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 { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js'; import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { initRundown } from '../../api-data/rundown/rundown.service.js';
@@ -63,6 +65,7 @@ function init() {
ensureDirectory(publicDir.corruptDir); ensureDirectory(publicDir.corruptDir);
ensureDirectory(publicDir.logoDir); ensureDirectory(publicDir.logoDir);
ensureDirectory(publicDir.migrateDir); ensureDirectory(publicDir.migrateDir);
ensureDirectory(publicDir.reportsDir);
} }
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { 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 // stop the runtime service
runtimeService.stop(); 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 // load the rundown given by key otherwise load the first in the project
const rundown = const rundown =
rundownId && rundownId in projectData.rundowns rundownId && rundownId in projectData.rundowns
@@ -263,6 +269,8 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st
const pathToDuplicate = getPathToProject(newFilename); const pathToDuplicate = getPathToProject(newFilename);
await copyFile(projectFilePath, pathToDuplicate); 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; return;
} }
@@ -284,6 +292,9 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
const pathToRenamed = getPathToProject(newFilename); const pathToRenamed = getPathToProject(newFilename);
await dockerSafeRename(projectFilePath, pathToRenamed); 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 // Update the last loaded project config if current loaded project is the one being renamed
const isLoaded = await isLastLoadedProject(originalFile); const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) { if (isLoaded) {
@@ -332,6 +343,8 @@ export async function deleteProjectFile(filename: string) {
} }
await deleteFile(projectFilePath); 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 { millisToString, validatePlayback } from 'ontime-utils';
import { triggerAutomations } from '../../api-data/automation/automation.service.js'; 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 { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
@@ -523,6 +523,8 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => { process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState); triggerReportEntry(TimerLifeCycle.onStop, previousState);
// a full stop unloads the events, which is the operator ending the show
closeRun();
triggerAutomations(TimerLifeCycle.onStop); triggerAutomations(TimerLifeCycle.onStop);
}); });
+1
View File
@@ -23,6 +23,7 @@ export const config = {
external: 'external', external: 'external',
demo: 'demo', demo: 'demo',
projects: 'projects', projects: 'projects',
reports: 'reports',
sheets: { sheets: {
directory: 'sheets', directory: 'sheets',
}, },
+2
View File
@@ -124,6 +124,8 @@ export const publicDir = {
crashDir: join(resolvePublicDirectory, config.crash), crashDir: join(resolvePublicDirectory, config.crash),
/** path to projects folder */ /** path to projects folder */
projectsDir: join(resolvePublicDirectory, config.projects), projectsDir: join(resolvePublicDirectory, config.projects),
/** path to show reports folder, one sidecar file per project */
reportsDir: join(resolvePublicDirectory, config.reports),
/** path to corrupt folder */ /** path to corrupt folder */
corruptDir: join(resolvePublicDirectory, config.corrupt), corruptDir: join(resolvePublicDirectory, config.corrupt),
/** path to migrated folder */ /** path to migrated folder */
@@ -1,8 +1,58 @@
import type { MaybeNumber } from '../../utils/utils.type.js'; import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from './OntimeEntry.js';
export type OntimeEventReport = { export type OntimeEventReport = {
startedAt: MaybeNumber; startedAt: MaybeNumber;
endedAt: 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'; export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report // ---> 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 // ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js'; export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
+9
View File
@@ -99,6 +99,15 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js'; 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 //Colour
export { export {
colourToHex, 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;
}