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