fix(report): measure events against the schedule they ran on

The runtime report joined live report data against the current rundown,
so editing an event after the show silently rewrote the report of a show
that had already happened. Durations, and the over/under chip in the
rundown, would change to match the edit.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-09 19:19:02 +00:00
parent a851414d13
commit 05cf7c7c56
29 changed files with 223 additions and 1761 deletions
-2
View File
@@ -20,8 +20,6 @@ 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 REPORT_OPEN_RUN = ['report', 'runs', 'open'];
export const TRANSLATION = ['translation'];
// API URLs
+1 -47
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport, OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -24,49 +24,3 @@ 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 run being recorded
* @returns null when no run is in progress
*/
export async function fetchOpenRun(options?: RequestOptions): Promise<OpenRun | null> {
try {
const res = await axios.get(`${reportUrl}/runs/open`, { signal: options?.signal });
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 });
}
@@ -1,50 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT_OPEN_RUN, REPORT_RUNS } from '../api/constants';
import { fetchOpenRun, 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 };
}
/**
* The run currently being recorded, if any.
* A run in progress lives in memory on the server until it is finished, so it
* cannot be found in the run history and needs its own query.
*/
export function useOpenRun(): OpenRun | null {
const { data } = useQuery<OpenRun | null>({
queryKey: REPORT_OPEN_RUN,
queryFn: ({ signal }) => fetchOpenRun({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return data ?? null;
}
@@ -1,57 +1,131 @@
import { useEffect, useState } from 'react';
import { countPlannedEvents, getRunSummary } from 'ontime-utils';
import { useMemo } from 'react';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import Select from '../../../../common/components/select/Select';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import useRuns from '../../../../common/hooks-query/useRuns';
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 { formatDuration, formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import RunDetail from './composite/RunDetail';
import RunsList from './composite/RunsList';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
const allRundowns = 'all';
import style from './ReportSettings.module.scss';
export default function ReportSettings() {
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 { data: reportData } = useReport();
const { data } = useRundown();
// 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 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');
};
const rundownOptions = [
{ value: allRundowns, label: 'All rundowns' },
...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })),
];
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
const summary = useMemo(() => {
return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder));
}, [reportData, data.entries, data.flatOrder]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Show reports
{rundownsList.rundowns.length > 1 && (
<Select
value={rundownFilter}
onValueChange={(value: string | null) => value && setRundownFilter(value)}
options={rundownOptions}
/>
)}
</Panel.SubHeader>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Recording starts with the first event of a show and is saved here when you finish the run. Every report
keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
</Panel.Paragraph>
<RunsList runs={runs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
{summary.eventsRun > 0 && (
<Panel.Section>
<div className={style.summary}>
<Stat label='Events run' value={`${summary.eventsRun} / ${summary.eventsPlanned}`} />
<Stat label='Scheduled' value={formatDuration(summary.scheduledDuration, false)} />
<Stat label='Actual' value={formatDuration(summary.actualDuration, false)} />
<Stat label='Drift' value={formatDrift(summary.drift, summary.eventsRun)} />
<Stat label='On time' value={String(summary.eventsOnTime)} />
<Stat label='Over' value={String(summary.eventsOver)} />
<Stat label='Under' value={String(summary.eventsUnder)} />
</div>
</Panel.Section>
)}
<Panel.Section>
<Panel.Table>
<thead>
<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.Card>
{selectedRunId && (
<Panel.Card>
<RunDetail runId={selectedRunId} />
</Panel.Card>
)}
</Panel.Section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className={style.stat}>
<span className={style.statLabel}>{label}</span>
<span className={style.statValue}>{value}</span>
</div>
);
}
@@ -1,4 +1,12 @@
import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import {
EndAction,
OntimeEvent,
OntimeReport,
RundownEntries,
SupportedEntry,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
@@ -34,40 +42,18 @@ function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
}
describe('getCombinedReport()', () => {
it('returns an empty list when there is nothing to show', () => {
it('returns an empty list when nothing has run', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('includes an event which has not run, using the current schedule', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const rundownEntries: RundownEntries = { a: notRun, b: didRun };
const report: OntimeReport = {
b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a', 'b']);
expect(result[0]).toEqual({
id: 'a',
index: 1,
title: 'event title',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
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 };
it('measures a run event against the schedule recorded at the time', () => {
// the rundown was edited after the show, the report must not follow it
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 });
const report: OntimeReport = {
a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a']);
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
@@ -77,8 +63,26 @@ describe('getCombinedReport()', () => {
});
});
it('falls back to the rundown for an event which has not run', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const report: OntimeReport = {
b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']);
expect(result[0]).toMatchObject({
id: 'a',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
it('skips entries which are not events', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const entry = makeEvent({ id: 'a' });
const rundownEntries: RundownEntries = {
a: entry,
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
@@ -121,7 +125,6 @@ describe('makeReportCSV()', () => {
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(csv.trim().split('\n')).toHaveLength(2);
});
});
@@ -1,113 +0,0 @@
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';
}
@@ -1,12 +0,0 @@
.row {
cursor: pointer;
&:hover {
background-color: $white-3;
}
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
@@ -1,148 +0,0 @@
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 { 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[];
selectedRunId: string | null;
onSelect: (id: string) => void;
}
export default function RunsList({ runs, selectedRunId, onSelect }: RunsListProps) {
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);
};
// the api layer invalidates the report query key, which the run list
// hangs off, so these do not need to refetch by hand
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));
}
};
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));
}
};
const handleRenameKeyDown = (event: KeyboardEvent<HTMLInputElement>, id: string) => {
preventEscape(event, () => setRenamingId(null));
if (event.key === 'Enter') {
event.preventDefault();
void submitRename(id);
}
};
return (
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>Run</th>
<th>Rundown</th>
<th>Started</th>
<th>Drift</th>
<th />
</tr>
</thead>
<tbody>
{runs.length === 0 && (
<Panel.TableEmpty
title='No reports yet'
description='Recording starts with the first event of a show. Use Finish run in the editor to save the report here.'
/>
)}
{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>
<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>
);
}
@@ -2,6 +2,7 @@ import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } fro
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
@@ -20,8 +21,8 @@ export type CombinedReport = {
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not rewrite a past run. Events that never ran have no snapshot and fall
* back to the rundown.
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/
export function getCombinedReport(
report: OntimeReport,
@@ -71,11 +72,11 @@ export function getCombinedReport(
}
/**
* Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed
* events has no meaningful drift to report.
* Signed drift, eg "+4m12s" / "-1m". With nothing completed there is no
* meaningful drift to report.
*/
export function formatDrift(drift: number, eventsRun: number): string {
if (eventsRun === 0) return '';
if (eventsRun === 0) return enDash;
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
@@ -10,7 +10,6 @@ import {
StartTimesPlanning,
StartTimesRuntime,
} from './composite/TimeElements';
import RunIndicator from './composite/RunIndicator';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
@@ -26,8 +25,6 @@ function EditorOverview({ children }: PropsWithChildren) {
{layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
{layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
{layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
{/* renders nothing unless a run is being recorded */}
<RunIndicator />
</OverviewWrapper>
);
}
@@ -1,31 +0,0 @@
.indicator {
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: calc(1rem - 3px);
color: $label-gray;
&:hover {
color: $ui-white;
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-red;
flex-shrink: 0;
}
.since {
color: $ui-white;
font-weight: 600;
}
@@ -1,39 +0,0 @@
import { useNavigate } from 'react-router';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useOpenRun } from '../../../common/hooks-query/useRuns';
import style from './RunIndicator.module.scss';
/**
* Shows that a report is being recorded for the show in progress.
*
* This is how the feature is discovered: it appears with the first event and
* points at the reports panel. Ending the run is the existing Finish action
* on the last event, so there is no separate control here.
*/
export default function RunIndicator() {
const openRun = useOpenRun();
const navigate = useNavigate();
if (!openRun) {
return null;
}
// startedAt is a wall clock instant, not a time of day, so formatTime does not apply
const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' });
return (
<Tooltip text='A report is being recorded. It is saved when you finish the show.' render={<span />}>
<button
type='button'
className={style.indicator}
onClick={() => navigate('/editor?settings=sharing__report')}
aria-label='Show reports'
>
<span className={style.dot} />
Recording since <span className={style.since}>{since}</span>
</button>
</Tooltip>
);
}
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -1,5 +1,5 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +32,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +43,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
return <EventReport className={className} id={id} />;
}
if (playbackActive) {
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { className, id } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
// measured against the schedule recorded when the event ran, so this
// agrees with the report panel and survives later rundown edits
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
if (variance.status === 'ontime') {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [value, variance.status, tooltip];
}, [currentReport]);
if (!value) {
return null;
@@ -1,115 +1,36 @@
import { TimerLifeCycle } from 'ontime-types';
import type { PlayableEvent, ShowRun } from 'ontime-types';
import type { PlayableEvent } from 'ontime-types';
import { vi } from 'vitest';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { clear, generate, triggerReportEntry } from '../report.service.js';
// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts
let runs: ShowRun[] = [];
/** how many times the store was asked to write, to prove the cue path stays clean */
let writes = 0;
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) => {
writes += 1;
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 = [];
}),
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
sendRefetch: vi.fn(),
}));
let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' });
/** rundowns reachable by id, standing in for what is on disk */
let storedRundowns: Record<string, ReturnType<typeof makeRundown>> = {};
vi.mock('../../rundown/rundown.dao.js', () => ({
getCurrentRundown: vi.fn(() => currentRundown),
getCurrentRundownId: vi.fn(() => currentRundown.id),
}));
vi.mock('../../../classes/data-provider/DataProvider.js', () => ({
getDataProvider: vi.fn(() => ({
getRundown: vi.fn((id: string) => {
if (!(id in storedRundowns)) throw new Error(`Rundown with id: ${id} not found`);
return storedRundowns[id];
}),
})),
}));
const {
generate,
clear,
triggerReportEntry,
closeRun,
initReports,
listRuns,
getRun,
getOpenRun,
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 = [];
writes = 0;
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],
});
storedRundowns = { 'rundown-1': currentRundown };
await initReports('project-a');
beforeEach(() => {
clear();
});
/** an epoch instant, as the runtime would supply on the first event start */
const showEpoch = Date.UTC(2026, 7, 8, 9, 30);
describe('triggerReportEntry()', () => {
it('captures a snapshot of the schedule when an event starts', () => {
it('snapshots 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,
},
expect(generate()[eventA.id]).toEqual({
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', () => {
it('keeps the snapshot taken at start when the event stops', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
@@ -119,11 +40,24 @@ describe('triggerReportEntry()', () => {
expect(generate()[eventA.id]).toMatchObject({
startedAt: 0,
endedAt: 12000,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
it('increments playCount when an event is re-run within the same show', () => {
it('records the schedule as it was, not as it later becomes', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
// the event is edited to a different duration, then stopped
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
});
it('counts a re-run rather than losing the previous one', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state);
@@ -132,262 +66,30 @@ describe('triggerReportEntry()', () => {
expect(generate()[eventA.id].playCount).toBe(2);
});
it('falls back to the current event when a stop arrives with no start', () => {
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: null,
endedAt: 10000,
scheduledDuration: eventA.duration,
playCount: 1,
});
});
it('ignores events without an id', () => {
const state = makeRuntimeStateData({ eventNow: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toEqual({});
});
it('ignores a stop arriving when no run is open', () => {
// a project load stops playback and reinitialises reporting, the trailing
// stop must not attribute the old project's event to the new one
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, state);
expect(generate()).toEqual({});
});
});
describe('nothing is written on the cue path', () => {
function runEvent(clock: number) {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock, _startEpoch: showEpoch });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: clock + 1000 } as typeof state);
}
it('keeps a run in progress out of history until it is finished', async () => {
runEvent(0);
runEvent(2000);
runEvent(4000);
await new Promise((resolve) => setImmediate(resolve));
// the run exists and is accumulating, but is not a report yet
expect(getOpenRun()).not.toBeNull();
expect(Object.keys(generate())).toHaveLength(1);
expect(listRuns()).toHaveLength(0);
expect(writes).toBe(0);
});
it('writes exactly once, when the run is finished', async () => {
runEvent(0);
runEvent(2000);
expect(writes).toBe(0);
await closeRun();
expect(writes).toBe(1);
expect(listRuns()).toHaveLength(1);
expect(getOpenRun()).toBeNull();
});
it('stamps the finished run with an end', async () => {
const before = Date.now();
runEvent(0);
await closeRun();
const run = listRuns()[0];
expect(run.endedAt).toBeGreaterThanOrEqual(before);
expect(run.endedAt).toBeGreaterThanOrEqual(run.startedAt);
});
it('discards an unfinished run when the project changes', async () => {
runEvent(0);
expect(getOpenRun()).not.toBeNull();
await initReports('project-b');
expect(getOpenRun()).toBeNull();
expect(listRuns()).toHaveLength(0);
expect(writes).toBe(0);
});
});
describe('run timestamps', () => {
it('dates a run with the wall clock epoch, not the time of day', async () => {
// clock/actualStart are millis since midnight, which cannot date a run.
// The run must take _startEpoch so it is not stamped 1 Jan 1970.
const state = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 34200000, // 09:30 as a time of day
rundown: { actualStart: 34200000 },
_startEpoch: showEpoch,
});
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 44200000 } as typeof state);
await closeRun();
const run = listRuns()[0];
expect(run.startedAt).toBe(showEpoch);
expect(new Date(run.startedAt).getUTCFullYear()).toBe(2026);
});
it('falls back to the current instant when no start epoch is available', async () => {
const before = Date.now();
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
await closeRun();
const run = listRuns()[0];
expect(run.startedAt).toBeGreaterThanOrEqual(before);
expect(run.startedAt).toBeLessThanOrEqual(Date.now());
});
it('dates runs from different days apart', async () => {
// the times of day here disagree with chronological order: 20:00 yesterday
// is a larger time of day than 09:30 today
const yesterdayEvening = Date.UTC(2026, 7, 7, 20, 0);
await makeClosedRunAt(yesterdayEvening);
const older = listRuns()[0].startedAt;
await makeClosedRunAt(showEpoch);
const newer = listRuns()[0].startedAt;
expect(newer).toBeGreaterThan(older);
});
async function makeClosedRunAt(epoch: number) {
const timeOfDay = epoch % 86400000;
const state = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: timeOfDay,
rundown: { actualStart: timeOfDay },
_startEpoch: epoch,
});
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: timeOfDay + 10000 } as typeof state);
await closeRun();
}
});
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);
await closeRun();
expect(listRuns()).toHaveLength(1);
// a new start after finishing 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 closeRun();
expect(listRuns()).toHaveLength(2);
});
it('does nothing when no run is open', async () => {
await expect(closeRun()).resolves.toBeNull();
expect(listRuns()).toHaveLength(0);
});
});
describe('summary is measured against the run\'s own rundown', () => {
it('counts planned events from the rundown the run belongs to', async () => {
// a three event rundown that is not the loaded one
const otherEvent = makeOntimeEvent({ id: 'event-c', timeStart: 0, timeEnd: 1000, duration: 1000 });
storedRundowns['rundown-2'] = makeRundown({
id: 'rundown-2',
title: 'Other rundown',
entries: { [eventA.id]: eventA, [eventB.id]: eventB, [otherEvent.id]: otherEvent },
order: [eventA.id, eventB.id, otherEvent.id],
flatOrder: [eventA.id, eventB.id, otherEvent.id],
});
// open a run against rundown-2, then switch the loaded rundown away
currentRundown = storedRundowns['rundown-2'];
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch });
triggerReportEntry(TimerLifeCycle.onStart, state);
currentRundown = storedRundowns['rundown-1'];
await closeRun();
// three, from rundown-2, not two from the now loaded rundown-1
expect(listRuns()[0].summary.eventsPlanned).toBe(3);
});
it('falls back to what ran when the rundown has been deleted', async () => {
// the run belongs to a rundown that is neither loaded nor in storage
currentRundown = makeRundown({ id: 'gone', title: 'Deleted rundown', entries: {}, order: [], flatOrder: [] });
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
currentRundown = storedRundowns['rundown-1'];
await closeRun();
// one event ran, so planned reads as one rather than zero of one
const summary = listRuns()[0].summary;
expect(summary.eventsPlanned).toBe(1);
expect(summary.eventsRun).toBe(1);
});
});
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);
await 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('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('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', () => {
it('clears a single event', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
clear(eventA.id);
expect(generate()).toEqual({});
});
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,81 +3,15 @@ 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());
});
/**
* Finished reports, most recent first. `?rundownId=` scopes the list to one
* rundown. A run in progress is not a report and does not appear here.
*/
router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => {
const { rundownId } = req.query as { rundownId?: string };
res.status(200).json(report.listRuns(rundownId));
});
/**
* The run currently being recorded, which exists in memory only.
* Registered ahead of /runs/:id so "open" is not read as an id.
*/
router.get('/runs/open', (_req: Request, res: Response) => {
const run = report.getOpenRun();
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();
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
res.status(204).send();
});
@@ -1,32 +1,13 @@
import {
EntryId,
OntimeEventReport,
OntimeReport,
OpenRun,
RefetchKey,
Rundown,
ShowRun,
ShowRunSummary,
TimerLifeCycle,
} from 'ontime-types';
import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils';
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
import { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as timeCore from '../../lib/time-core/timeCore.js';
import * as reportStore from '../../services/report-service/report.store.js';
import { RuntimeState } from '../../stores/runtimeState.js';
import { getCurrentRundown, getCurrentRundownId } from '../rundown/rundown.dao.js';
/** per event data for the run currently in progress */
const report = new Map<EntryId, OntimeEventReport>();
const report = new Map<string, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/** the run being recorded, held in memory only until it is finished */
let openRun: OpenRun | null = null;
/**
* generates a full report
* @returns full report
@@ -68,15 +49,14 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
openRunIfNeeded(state);
// an event started twice in the same run is a re-run, not a new record
// an event started twice is a re-run, not a new record
const playCount = (report.get(eventId)?.playCount ?? 0) + 1;
report.set(eventId, {
startedAt: state.timer.startedAt,
endedAt: null,
// snapshot the schedule so later rundown edits cannot rewrite this run
// snapshot the schedule so later rundown edits cannot change how a show
// that already happened is reported
scheduledStart: state.eventNow.timeStart,
scheduledDuration: state.eventNow.duration,
playCount,
@@ -86,13 +66,6 @@ export function triggerReportEntry(
}
if (cycle === TimerLifeCycle.onStop) {
// With no run open there is nothing this event can belong to. A stop can
// still arrive here after a project load, and recording it would attribute
// the previous project's event to the newly loaded one.
if (openRun === null) {
return;
}
const previous = report.get(eventId);
report.set(eventId, {
startedAt: previous?.startedAt ?? null,
@@ -102,188 +75,6 @@ export function triggerReportEntry(
playCount: previous?.playCount ?? 1,
});
formattedReport = null;
// deliberately not written here: a run is kept in memory until it is
// finished, so nothing touches the disk on the cue path
sendRefetch(RefetchKey.Report);
}
}
/**
* Closes the run in progress and writes it to history immediately.
*
* Ending a run is an explicit act by the operator, not a side effect of
* playback. Stopping and starting again mid show is ordinary, and would
* otherwise split one show across several runs.
* @returns the closed run, or null if none was open
*/
export async function closeRun(): Promise<ShowRun | null> {
if (openRun === null) {
return null;
}
// detach the run before the write so a start arriving in between opens a
// new run instead of appending to the one we are closing
const closing = { ...openRun, endedAt: timeCore.now() };
openRun = null;
const closed = await persistRun(closing, generate());
sendRefetch(RefetchKey.Report);
return closed;
}
/** The run being recorded, if any. Held in memory until it is finished. */
export function getOpenRun(): OpenRun | null {
return openRun;
}
/**
* 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();
// `clock` and `rundown.actualStart` are times of day, which cannot date or
// order a run across days. `_startEpoch` is the wall clock instant the show
// began, which is what a run needs to be a dated record.
const startedAt = state._startEpoch ?? timeCore.now();
openRun = {
id: generateId(),
rundownId: rundown.id,
rundownTitle: rundown.title,
label: makeRunLabel(startedAt),
startedAt,
};
// let the editor show that a run is being recorded without waiting
// for the first event to finish
sendRefetch(RefetchKey.Report);
}
/**
* Default name for a run, a readable local date and time rather than the
* raw timestamp the user would otherwise have to decipher.
* @private
*/
function makeRunLabel(startedAt: number): string {
return new Date(startedAt).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
}
/**
* Writes the run in progress to the sidecar.
* Persisting on every event stop means an interrupted show still leaves a record.
* @private
*/
/**
* Writes a finished run and its derived summary to the sidecar
* @private
*/
async function persistRun(
run: Omit<ShowRun, 'report' | 'summary'>,
currentReport: OntimeReport,
): Promise<ShowRun> {
const rundown = getRundownForRun(run.rundownId);
// a rundown deleted mid-run leaves nothing to count against. Falling back to
// what actually ran keeps the report honest, where zero would read as if the
// whole show had been missed
const eventsPlanned = rundown
? countPlannedEvents(rundown.entries, rundown.flatOrder)
: Object.keys(currentReport).length;
const persisted: ShowRun = {
...run,
report: structuredClone(currentReport),
summary: getRunSummary(currentReport, eventsPlanned),
};
await reportStore.upsertRun(persisted);
return persisted;
}
/**
* Resolves the rundown a run belongs to.
* The loaded rundown can be switched while a run is open, so the run's own
* id is the only reliable way to count the events it was measured against.
* @private
*/
function getRundownForRun(rundownId: string): Readonly<Rundown> | null {
if (rundownId === getCurrentRundownId()) {
return getCurrentRundown();
}
try {
return getDataProvider().getRundown(rundownId);
} catch (_error) {
// getRundown throws when the rundown no longer exists
return null;
}
}
/**
* Prepares reporting for a newly loaded project.
*
* There is nothing to recover here: a run only reaches the sidecar once it
* has been finished, so the stored history never contains a partial run. An
* unfinished run is discarded along with the project it belonged to.
*/
export async function initReports(projectFilename: string): Promise<void> {
report.clear();
formattedReport = null;
openRun = null;
await reportStore.loadReports(projectFilename);
}
/** 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);
}
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> {
// only finished reports are in the store, so this can never target the
// run in progress
const didDelete = await reportStore.deleteRun(id);
if (didDelete) {
sendRefetch(RefetchKey.Report);
}
return didDelete;
}
export async function deleteAllRuns(): Promise<void> {
await reportStore.deleteAllRuns();
openRun = null;
report.clear();
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
@@ -1,14 +0,0 @@
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,7 +25,6 @@ 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';
@@ -893,9 +892,6 @@ 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,8 +6,6 @@ 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';
@@ -65,7 +63,6 @@ function init() {
ensureDirectory(publicDir.corruptDir);
ensureDirectory(publicDir.logoDir);
ensureDirectory(publicDir.migrateDir);
ensureDirectory(publicDir.reportsDir);
}
export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> {
@@ -91,9 +88,6 @@ 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
@@ -269,8 +263,6 @@ 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;
}
@@ -292,9 +284,6 @@ 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) {
@@ -343,8 +332,6 @@ export async function deleteProjectFile(filename: string) {
}
await deleteFile(projectFilePath);
// reports are owned by their project and do not outlive it
await deleteReportsForProject(filename);
}
/**
@@ -1,275 +0,0 @@
import { join } from 'path';
import type { ShowRun } from 'ontime-types';
import { vi } from 'vitest';
// in-memory stand-in for the filesystem, keyed by absolute 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',
);
const childrenOf = (dir: string) => [...files.keys()].filter((path) => path.startsWith(`${dir}/`));
return {
...actual,
ensureDirectory: vi.fn(),
readDirectoryEntries: vi.fn(async (dir: string) => {
const children = childrenOf(dir);
if (children.length === 0) throw new Error('ENOENT');
return children.map((path) => ({
name: path.slice(dir.length + 1),
isFile: () => true,
}));
}),
deleteFile: vi.fn(async (path: string) => {
files.delete(path);
}),
deleteDirectory: vi.fn(async (dir: string) => {
for (const path of childrenOf(dir)) files.delete(path);
}),
dockerSafeRename: vi.fn(async (oldDir: string, newDir: string) => {
for (const path of childrenOf(oldDir)) {
files.set(join(newDir, path.slice(oldDir.length + 1)), files.get(path));
files.delete(path);
}
}),
statIfExists: vi.fn(async (path: string) =>
files.has(path) || childrenOf(path).length > 0 ? {} : 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: '8 Aug 2026, 09:30',
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,
};
}
/** where a run's file lands for a given project */
const runPath = (project: string, id: string) => join(getPathToReports(project), `${id}.json`);
beforeEach(() => {
files.clear();
resetStore();
});
describe('loadReports()', () => {
it('starts empty for a project with no reports', async () => {
expect(await loadReports('project-a')).toEqual([]);
expect(getRuns()).toEqual([]);
});
it('resolves a directory per project, ignoring the file extension', async () => {
expect(getPathToReports('my show.json')).toBe(getPathToReports('my show'));
});
it('loads every run in the project directory', async () => {
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
files.set(runPath('project-a', 'b'), makeRun({ id: 'b' }));
const runs = await loadReports('project-a');
expect(runs).toHaveLength(2);
});
it('presents runs newest first regardless of read order', async () => {
files.set(runPath('project-a', 'old'), makeRun({ id: 'old', startedAt: 1000 }));
files.set(runPath('project-a', 'new'), makeRun({ id: 'new', startedAt: 9000 }));
files.set(runPath('project-a', 'mid'), makeRun({ id: 'mid', startedAt: 5000 }));
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['new', 'mid', 'old']);
});
it('skips an unreadable report without losing the rest', async () => {
files.set(runPath('project-a', 'good'), makeRun({ id: 'good' }));
files.set(runPath('project-a', 'broken'), { nonsense: true });
await loadReports('project-a');
expect(getRuns().map((run) => run.id)).toEqual(['good']);
});
it('scopes runs to the loaded project', async () => {
files.set(runPath('project-a', 'a'), makeRun({ id: 'a' }));
files.set(runPath('project-b', 'b'), 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()', () => {
beforeEach(async () => {
await loadReports('project-a');
});
it('writes only the run given, not the whole history', async () => {
await upsertRun(makeRun({ id: 'first' }));
await upsertRun(makeRun({ id: 'second' }));
// one file each, so the cost of finishing a show does not grow with history
expect(files.has(runPath('project-a', 'first'))).toBe(true);
expect(files.has(runPath('project-a', 'second'))).toBe(true);
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('survives a reload', async () => {
await upsertRun(makeRun());
expect(await loadReports('project-a')).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 and its file', async () => {
expect(await deleteRun('discard')).toBe(true);
expect(getRuns().map((run) => run.id)).toEqual(['keep']);
expect(files.has(runPath('project-a', 'discard'))).toBe(false);
expect(files.has(runPath('project-a', 'keep'))).toBe(true);
});
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' }));
expect(await deleteRunsForRundown('rundown-x')).toBe(2);
expect(getRuns().map((run) => run.id)).toEqual(['b']);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'c'))).toBe(false);
});
});
describe('deleteAllRuns()', () => {
it('empties the project directory', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await upsertRun(makeRun({ id: 'b' }));
await deleteAllRuns();
expect(getRuns()).toEqual([]);
expect(files.has(runPath('project-a', 'a'))).toBe(false);
});
});
describe('project lifecycle', () => {
it('removes the whole directory with the project', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await upsertRun(makeRun({ id: 'b' }));
await deleteReportsForProject('project-a');
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-a', 'b'))).toBe(false);
});
it('does nothing when the project never had reports', async () => {
await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined();
});
it('moves the directory to follow a project rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b');
expect(files.has(runPath('project-a', 'a'))).toBe(false);
expect(files.has(runPath('project-b', 'a'))).toBe(true);
});
it('keeps writing to the new location after a rename', async () => {
await loadReports('project-a');
await upsertRun(makeRun({ id: 'a' }));
await renameReportsForProject('project-a', 'project-b');
await upsertRun(makeRun({ id: 'later' }));
expect(files.has(runPath('project-b', 'later'))).toBe(true);
expect(files.has(runPath('project-a', 'later'))).toBe(false);
});
it('does nothing when renaming a project that never had reports', async () => {
await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined();
});
});
@@ -1,24 +0,0 @@
import type { ShowRun } from 'ontime-types';
import { is } from '../../utils/is.js';
/**
* Shallow check on the contents of a report file.
*
* We are the only writer of these files, so this guards against one being
* empty, truncated or hand edited rather than against arbitrary payloads.
* A file that fails is skipped, leaving the rest of the history readable.
*/
export function isShowRun(value: unknown): value is ShowRun {
if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'endedAt', 'report', 'summary'])) {
return false;
}
return (
is.string(value.id) &&
is.number(value.startedAt) &&
is.number(value.endedAt) &&
is.object(value.report) &&
is.object(value.summary)
);
}
@@ -1,209 +0,0 @@
import { join } from 'path';
import { JSONFile } from 'lowdb/node';
import type { ShowRun } from 'ontime-types';
import { publicDir } from '../../setup/index.js';
import {
deleteDirectory,
deleteFile,
dockerSafeRename,
ensureDirectory,
readDirectoryEntries,
removeFileExtension,
statIfExists,
} from '../../utils/fileManagement.js';
import { isShowRun } from './report.parser.js';
/**
* Reports live in a directory per project, one file per run.
*
* Keeping them out of the project file leaves it free of show time writes and
* lets history grow without bloating what the user exports. Keeping each run
* in its own file means finishing a show writes only that show, rather than
* rewriting everything the project has ever recorded.
*
* Persistence is best effort by design: a failing disk degrades reporting
* but must never interrupt a running show.
*/
/** runs for the loaded project, newest first */
let cache: ShowRun[] = [];
let projectDir: string | null = null;
let failedWriteAttempts = 0;
/** Directory holding a project's reports */
export function getPathToReports(projectFilename: string): string {
// the project name without its extension, so "show.json" does not read as a file
return join(publicDir.reportsDir, removeFileExtension(projectFilename));
}
function getPathToRun(id: string): string | null {
return projectDir === null ? null : join(projectDir, `${id}.json`);
}
/**
* Points the store at a project's report directory and loads what is there.
* Called on every project load, which is the single choke point for
* project changes.
*/
export async function loadReports(projectFilename: string): Promise<ShowRun[]> {
const dir = getPathToReports(projectFilename);
projectDir = dir;
failedWriteAttempts = 0;
cache = [];
try {
const entries = await readDirectoryEntries(dir);
const reports = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
const contents = await Promise.all(
reports.map(async (entry) => {
try {
return await new JSONFile<unknown>(join(dir, entry.name)).read();
} catch (_error) {
// a single unreadable report is skipped rather than losing the rest
return null;
}
}),
);
// files come off disk in arbitrary order, the list is presented newest first
cache = contents.filter(isShowRun).sort((a, b) => b.startedAt - a.startedAt);
} catch (_error) {
// a missing directory is the normal case for a project with no history
}
return cache;
}
/** Runs held for the current project, newest first */
export function getRuns(): ShowRun[] {
return cache;
}
export function getRun(id: string): ShowRun | undefined {
return cache.find((run) => run.id === id);
}
/**
* Writes a single finished report.
* Only this run's file is touched, so the cost does not grow with history.
*/
export async function upsertRun(run: ShowRun): Promise<void> {
const index = cache.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
cache.unshift(run);
} else {
cache[index] = run;
}
const dir = projectDir;
if (dir === null || failedWriteAttempts > 3) {
return;
}
try {
ensureDirectory(dir);
await new JSONFile<ShowRun>(join(dir, `${run.id}.json`)).write(run);
failedWriteAttempts = 0;
} catch (_error) {
failedWriteAttempts += 1;
}
}
/**
* 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.findIndex((run) => run.id === id);
if (index === -1) {
return false;
}
cache.splice(index, 1);
await removeRunFile(id);
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 doomed = cache.filter((run) => run.rundownId === rundownId);
cache = cache.filter((run) => run.rundownId !== rundownId);
// separate files, so these can go at once
await Promise.all(doomed.map((run) => removeRunFile(run.id)));
return doomed.length;
}
/** Clears the run history of the current project */
export async function deleteAllRuns(): Promise<void> {
cache = [];
if (projectDir === null) {
return;
}
try {
await deleteDirectory(projectDir);
} catch (_error) {
// leftovers are harmless, they are filtered on next load
}
}
/**
* Removes a project's reports.
* They are owned by the project and do not outlive it, so this is a single
* recursive delete of its directory.
*/
export async function deleteReportsForProject(projectFilename: string): Promise<void> {
try {
await deleteDirectory(getPathToReports(projectFilename));
} catch (_error) {
// a leftover directory is harmless, deleting the project must still succeed
}
}
/** Moves a project's reports so 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);
if (projectDir === originalPath) {
projectDir = newPath;
}
} catch (_error) {
// losing history on rename is bad but not fatal, the project rename stands
}
}
/** @private */
async function removeRunFile(id: string): Promise<void> {
const path = getPathToRun(id);
if (path === null) {
return;
}
try {
if ((await statIfExists(path)) !== null) {
await deleteFile(path);
}
} catch (_error) {
// the run is already out of the cache, a stray file is filtered on load
}
}
/** Resets in-memory state, used when no project is loaded and in tests */
export function resetStore(): void {
projectDir = null;
cache = [];
failedWriteAttempts = 0;
}
@@ -17,7 +17,7 @@ import {
import { millisToString, validatePlayback } from 'ontime-utils';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { logger } from '../../classes/Logger.js';
@@ -523,10 +523,6 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
// stop is what the Go button does on the last event, where it reads
// "Finish". Ending playback is the operator ending the show, and that
// is what turns the run in progress into a report.
void closeRun();
triggerAutomations(TimerLifeCycle.onStop);
});
-1
View File
@@ -23,7 +23,6 @@ export const config = {
external: 'external',
demo: 'demo',
projects: 'projects',
reports: 'reports',
sheets: {
directory: 'sheets',
},
-2
View File
@@ -124,8 +124,6 @@ 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 */
@@ -7,20 +7,21 @@ export type OntimeEventReport = {
/**
* 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.
* afterwards no longer changes how a show that already happened is reported.
*/
scheduledStart: number;
scheduledDuration: number;
/** how many times the event was started within this run, >1 means it was re-run */
/** how many times the event was started, >1 means it was re-run */
playCount: number;
};
export type OntimeReport = Record<EntryId, OntimeEventReport>;
/** Headline numbers for everything in the current report */
export type RunSummary = {
/** events which produced a report entry */
eventsRun: number;
/** playable events in the rundown at the time the summary was made */
/** playable events in the rundown */
eventsPlanned: number;
scheduledDuration: number;
actualDuration: number;
@@ -32,38 +33,3 @@ export type RunSummary = {
/** 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 local date and time */
label: string;
/**
* Wall clock instant (milliseconds from epoch) the run began.
* Not a time of day: runs must be datable and orderable across days.
*/
startedAt: number;
/**
* Wall clock instant the run was finished.
* Always set: a run only becomes a report once it has been finished, so
* there is no such thing as a stored run still in progress.
*/
endedAt: number;
report: OntimeReport;
summary: RunSummary;
};
/** A run without its per event data, for list views */
export type ShowRunSummary = Omit<ShowRun, 'report'>;
/**
* The run currently being recorded.
* Lives in memory only until it is finished, so it carries no report data
* and has no end.
*/
export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>;
+1 -8
View File
@@ -24,14 +24,7 @@ export { TimerType } from './definitions/TimerType.type.js';
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report
export type {
OntimeReport,
OntimeEventReport,
OpenRun,
RunSummary,
ShowRun,
ShowRunSummary,
} from './definitions/core/Report.type.js';
export type { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js';
// ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';