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