refactor(report): improve report summary

This commit is contained in:
Carlos Valente
2026-08-25 21:57:15 +02:00
parent b9683f00dc
commit fa55765912
21 changed files with 2066 additions and 162 deletions
+1
View File
@@ -20,6 +20,7 @@ export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const REPORT_SHOW = ['report', 'show'];
export const TRANSLATION = ['translation'];
// API URLs
+9 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { OntimeReport, ShowReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -15,6 +15,14 @@ export async function fetchReport(options?: RequestOptions): Promise<OntimeRepor
return res.data;
}
/**
* HTTP request to fetch the show level times for the report
*/
export async function fetchShowReport(options?: RequestOptions): Promise<ShowReport> {
const res = await axios.get(`${reportUrl}/show`, { signal: options?.signal });
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { ShowReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT_SHOW } from '../api/constants';
import { fetchShowReport } from '../api/report';
const emptyShowReport: ShowReport = {
plannedStart: null,
plannedEnd: null,
actualStart: null,
actualEnd: null,
};
/**
* Show level times for the report.
* Invalidated by the same websocket signal as the report itself, since the
* two are read together.
*/
export default function useShowReport() {
const { data } = useQuery<ShowReport>({
queryKey: REPORT_SHOW,
queryFn: ({ signal }) => fetchShowReport({ signal }),
placeholderData: (previousData) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? emptyShowReport };
}
@@ -1,7 +0,0 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,103 +1,97 @@
import { isOntimeEvent } from 'ontime-types';
import { countPlannedEvents, getGroupReports, getRunSummary } from 'ontime-utils';
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import useShowReport from '../../../../common/hooks-query/useShowReport';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import style from './ReportSettings.module.scss';
import ReportShowSummary from './composite/ReportShowSummary';
import ReportTable from './composite/ReportTable';
import { getCombinedReport, makeReportCSV } from './reportSettings.utils';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data: showReport } = useShowReport();
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
const clearReport = () => deleteAllReport();
const combinedReport = useMemo(
() => getCombinedReport(reportData, data.entries, data.flatOrder),
[reportData, data.entries, data.flatOrder],
);
const summary = useMemo(
() => getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder)),
[reportData, data.entries, data.flatOrder],
);
const groups = useMemo(
() => getGroupReports(reportData, data.entries, data.order),
[reportData, data.entries, data.order],
);
let worstOverrunTitle: string | null = null;
if (summary.worstOverrun !== null) {
const entry = data.entries[summary.worstOverrun.id];
if (entry && isOntimeEvent(entry)) {
worstOverrunTitle = entry.title;
}
}
const hasReport = combinedReport.length > 0;
const downloadCSV = () => {
if (!hasReport) return;
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.SubHeader>
Report
<Panel.InlineElements>
<Button onClick={downloadCSV} disabled={!hasReport}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={!hasReport}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
{!hasReport ? (
<Panel.Section>
<Panel.EmptyState
title='No report yet'
description='Ontime records what actually happened as you run the show, and compares it against the schedule as it stood at the time. Start an event and this fills in.'
/>
</Panel.Section>
) : (
<>
<Panel.Section>
<ReportShowSummary
rundownTitle={data.title}
show={showReport}
summary={summary}
worstOverrunTitle={worstOverrunTitle}
/>
</Panel.Section>
<Panel.Section>
<ReportTable rows={combinedReport} groups={groups} />
</Panel.Section>
</>
)}
</Panel.Card>
</Panel.Section>
);
@@ -0,0 +1,194 @@
import {
EndAction,
OntimeEvent,
OntimeReport,
RundownEntries,
SupportedEntry,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { formatOffset, 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 nothing has run', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('measures a run event against the schedule recorded at the time', () => {
// the rundown was edited after the show, the report must not follow it
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999, colour: '#ff0000' });
const report: OntimeReport = {
a: {
startedAt: 100,
endedAt: 10100,
scheduledStart: 0,
scheduledDuration: 10000,
},
};
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999
actualStart: 100,
actualEnd: 10100,
colour: '#ff0000',
});
});
it('falls back to the rundown for an event which has not run', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const report: OntimeReport = {
b: {
startedAt: 10000,
endedAt: 20000,
scheduledStart: 10000,
scheduledDuration: 10000,
},
};
const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']);
expect(result[0]).toMatchObject({
id: 'a',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
it('leaves out skipped events, matching what the summary counts', () => {
const ran = makeEvent({ id: 'a' });
const skipped = makeEvent({ id: 'b', skip: true });
const report: OntimeReport = {
a: {
startedAt: 0,
endedAt: 10000,
scheduledStart: 0,
scheduledDuration: 10000,
},
};
expect(getCombinedReport(report, { a: ran, b: skipped }, ['a', 'b']).map((row) => row.id)).toEqual(['a']);
});
it('skips entries which are not events', () => {
const entry = makeEvent({ id: 'a' });
const rundownEntries: RundownEntries = {
a: entry,
delay: {
type: SupportedEntry.Delay,
id: 'delay',
duration: 1000,
parent: null,
},
};
const report: OntimeReport = {
a: {
startedAt: 0,
endedAt: 10000,
scheduledStart: 0,
scheduledDuration: 10000,
},
};
expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']);
});
});
describe('formatOffset()', () => {
it('has nothing to show without a value', () => {
expect(formatOffset(null)).toBe('');
});
it('treats a sub-second offset as on time', () => {
expect(formatOffset(500)).toBe('On time');
});
it('signs the offset in both directions', () => {
expect(formatOffset(252000)).toBe('+4m12s');
expect(formatOffset(-60000)).toBe('-1m');
});
});
describe('makeReportCSV()', () => {
it('leaves the cell empty for an event which never ran', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
parent: null,
groupTitle: '',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
},
]);
// empty fields rather than a placeholder a spreadsheet would read as text
const fields = csv.trim().split('\n')[1].split(',');
expect(fields[5]).toBe('');
expect(fields[7]).toBe('');
expect(csv).not.toContain('...');
});
it('produces a header row and one row per entry', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
parent: 'act1',
groupTitle: 'Act 1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: 0,
actualEnd: 12000,
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(rows[0]).toContain('Group');
expect(rows[1]).toContain('Act 1');
});
});
@@ -0,0 +1,138 @@
// Panel.Table nests its own padding inside the section it sits in, so the
// summary takes the same inset to keep one left edge down the whole panel
.inset {
padding: 0 var(--panel-card-padding, 2rem);
}
.summary {
padding: 1.25rem;
background-color: $gray-1200;
border-radius: 3px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.title {
margin: 0;
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.body {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem 2.5rem;
}
.headline {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.headlineLabel,
.metricLabel {
color: $gray-300;
font-size: calc(1rem - 2px);
}
.headlineValue {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.unavailable {
max-width: 32rem;
color: $warning-orange;
font-size: calc(1rem - 2px);
line-height: 1.4;
}
.metrics {
display: grid;
grid-template-columns: auto auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
}
.metricValue,
.metricOffset {
margin: 0;
font-variant-numeric: tabular-nums;
}
.metricValue {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.planned,
.arrow {
color: $gray-300;
}
.actual {
color: $ui-white;
font-weight: 600;
}
.metricOffset {
justify-self: end;
font-weight: 600;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $gray-300;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding-top: 0.875rem;
border-top: 1px solid $white-10;
color: $gray-300;
font-size: calc(1rem - 2px);
}
.footerItem {
white-space: nowrap;
& + &::before {
content: '·';
margin-right: 0.75rem;
color: $gray-500;
}
b {
color: $ui-white;
font-weight: 600;
}
}
.overrun {
color: $playback-over;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,131 @@
import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types';
import { getShowOffsets } from 'ontime-utils';
import { useMemo } from 'react';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import { formatOffset, offsetTone } from '../reportSettings.utils';
import style from './ReportShowSummary.module.scss';
interface ReportShowSummaryProps {
rundownTitle: string;
show: ShowReport;
summary: RunSummary;
worstOverrunTitle: string | null;
}
/**
* Leads the report with whether the show ran to the length it was planned for.
*
* Running time is the headline rather than finishing time because it is the
* part the team controls and the part that carries into the next run of the
* same rundown. Finishing time is the other question a report is asked, and
* the two can point opposite ways, so it stays beside it as its own row
* rather than being folded into a single figure.
*/
export default function ReportShowSummary({ rundownTitle, show, summary, worstOverrunTitle }: ReportShowSummaryProps) {
const offsets = useMemo(() => getShowOffsets(show), [show]);
/**
* A show that stopped early has no meaningful end: its last event is simply
* where it got to. Measuring that against the plan would report a show which
* never finished as having come in comfortably short.
*/
const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned;
const hasPlan = offsets.startOffset !== null;
return (
<div className={style.inset}>
<section className={style.summary} aria-labelledby='report-summary-title'>
<h4 id='report-summary-title' className={style.title}>
{rundownTitle || 'Untitled rundown'}
</h4>
<div className={style.body}>
<div className={style.headline}>
<span className={style.headlineLabel}>{didReachEnd ? 'Against planned duration' : 'Show incomplete'}</span>
{didReachEnd && offsets.durationOffset !== null ? (
<span className={cx([style.headlineValue, style[offsetTone(offsets.durationOffset)]])}>
{formatOffset(offsets.durationOffset)}
</span>
) : (
<span className={style.unavailable}>
The show did not reach the end of the rundown, so there is nothing to measure it against.
</span>
)}
</div>
<dl className={style.metrics}>
{hasPlan && (
<Metric
label='Started'
planned={formatMaybeTime(show.plannedStart)}
actual={formatMaybeTime(show.actualStart)}
offset={offsets.startOffset}
/>
)}
{hasPlan && didReachEnd && (
<Metric
label='Ended'
planned={formatMaybeTime(show.plannedEnd)}
actual={formatMaybeTime(show.actualEnd)}
offset={offsets.endOffset}
/>
)}
{didReachEnd && (
<Metric
label='Duration'
planned={formatMaybeDuration(offsets.plannedDuration)}
actual={formatMaybeDuration(offsets.actualDuration)}
/>
)}
</dl>
</div>
{summary.worstOverrun !== null && (
<div className={style.footer}>
<span className={style.footerItem}>
longest overrun <b>{worstOverrunTitle || 'an event'}</b>{' '}
<span className={style.overrun}>+{formatDuration(summary.worstOverrun.delta, false)}</span>
</span>
</div>
)}
</section>
</div>
);
}
function Metric({
label,
planned,
actual,
offset,
}: {
label: string;
planned: string;
actual: string;
offset?: MaybeNumber;
}) {
return (
<>
<dt className={style.metricLabel}>{label}</dt>
<dd className={style.metricValue}>
<span className={style.planned}>{planned}</span>
<span className={style.arrow}></span>
<span className={style.actual}>{actual}</span>
</dd>
<dd className={cx([style.metricOffset, offset !== undefined && style[offsetTone(offset)]])}>
{offset === undefined ? '' : formatOffset(offset)}
</dd>
</>
);
}
function formatMaybeTime(value: MaybeNumber): string {
return value === null ? enDash : formatTime(value);
}
function formatMaybeDuration(value: MaybeNumber): string {
return value === null ? enDash : formatDuration(value, false);
}
@@ -0,0 +1,160 @@
$rail: var(--user-bg, #{$gray-500});
$event-wash: var(--event-bg, transparent);
// event rows carry their values in td, not th: the panel's th styling is meant
// for column headings and renders whatever it holds small, bold and upper case
td.over {
color: $playback-over;
}
td.under {
color: $playback-under;
}
.eventRow td {
background-color: color-mix(in srgb, #{$gray-1300} 96%, #{$event-wash} 4%);
}
.groupedRow td:first-child {
box-shadow: inset 2px 0 $rail;
// clear the rail rather than sitting against it
padding-left: 0.75rem;
}
.groupRow > * {
background-color: color-mix(in srgb, #{$gray-1300} 88%, #{$rail} 12%);
vertical-align: top;
}
.groupSpacer {
height: 0.75rem;
background: transparent !important;
td {
height: 0.75rem;
padding: 0;
background: transparent !important;
}
}
.groupSummary {
padding: 0.875rem 1rem;
box-shadow: inset 4px 0 $rail;
text-align: left;
text-transform: none;
letter-spacing: normal;
> * {
text-transform: none;
}
}
.groupTitle,
.groupLabel,
.groupValues,
.groupFooter {
display: block;
}
.groupTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
text-transform: none;
}
.groupBody {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 2.5rem;
margin-top: 0.75rem;
}
.groupHeadline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.groupLabel,
.groupMetrics dt,
.groupFooter {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.groupHeadlineValue {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.groupMetrics {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
gap: 0.375rem 1.5rem;
margin: 0;
font-variant-numeric: tabular-nums;
}
.groupMetrics dd {
margin: 0;
}
.groupValues {
display: flex;
gap: 0.5rem;
color: $gray-300;
b {
color: $ui-white;
font-weight: 600;
}
}
.groupFooter {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid $white-10;
b {
color: $ui-white;
font-weight: 600;
}
}
.unavailable {
color: $gray-400;
font-size: calc(1rem - 3px);
font-weight: 400;
}
.arrow {
color: $gray-500;
}
.over {
color: $playback-over;
}
.under {
color: $playback-under;
}
.none {
color: $ui-white;
}
.eventCue,
.eventIndex {
color: $gray-300;
}
@@ -0,0 +1,195 @@
import { EntryId, GroupReport } from 'ontime-types';
import { useMemo } from 'react';
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
import { cx, enDash } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import { CombinedReport, formatOffset, offsetTone } from '../reportSettings.utils';
import style from './ReportTable.module.scss';
interface ReportTableProps {
rows: CombinedReport[];
groups: GroupReport[];
}
/**
* The report laid out the way the show was planned: blocks, then the events
* inside them. Each block carries how it ran against the budget set for it.
*/
export default function ReportTable({ rows, groups }: ReportTableProps) {
// groups are rendered where their first event appears, so the table follows
// the rundown rather than a separate ordering
const sections = useMemo(() => makeSections(rows, groups), [rows, groups]);
return (
<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>
{sections.map((section, index) => (
<tbody key={section.key}>
{section.group && index > 0 && <GroupSpacer />}
{section.group && <GroupRow group={section.group} />}
{section.rows.map((entry) => (
<EventRow
key={entry.id}
entry={entry}
groupColour={section.group?.colour}
grouped={section.group !== null}
/>
))}
</tbody>
))}
</Panel.Table>
);
}
function GroupSpacer() {
return (
<tr aria-hidden='true' className={style.groupSpacer}>
<td colSpan={7} />
</tr>
);
}
/**
* A group read the same way as the show above it: what it was measured
* against, what it actually did, and how much of it ran.
*/
function GroupRow({ group }: { group: GroupReport }) {
const isComplete = group.eventsRun === group.eventsPlanned;
const hasTarget = group.targetDuration !== null;
const measuredAgainst = group.targetDuration ?? group.scheduledDuration;
const unavailableReason = isComplete ? 'Did not run' : 'Still running';
return (
<tr className={style.groupRow} style={groupColourStyle(group.colour)}>
<th scope='rowgroup' colSpan={7} className={style.groupSummary}>
<span className={style.groupTitle}>{group.title || 'Untitled group'}</span>
<div className={style.groupBody}>
<div className={style.groupHeadline}>
<span className={style.groupLabel}>{hasTarget ? 'Against target' : 'Against schedule'}</span>
{group.variance === null ? (
<span className={style.unavailable}>{unavailableReason}</span>
) : (
<span className={cx([style.groupHeadlineValue, style[offsetTone(group.variance)]])}>
{formatOffset(group.variance)}
</span>
)}
</div>
<dl className={style.groupMetrics}>
<dt>{hasTarget ? 'Target' : 'Scheduled'}</dt>
<dd className={style.groupValues}>
<span>{formatDuration(measuredAgainst, false)}</span>
<span className={style.arrow}></span>
<b>{group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}</b>
</dd>
{group.actualStart !== null && group.actualEnd !== null && (
<>
<dt>Ran</dt>
<dd className={style.groupValues}>
<span>{formatTime(group.actualStart)}</span>
<span className={style.arrow}></span>
<b>{formatTime(group.actualEnd)}</b>
</dd>
</>
)}
</dl>
</div>
{group.untimed !== null && group.untimed > 0 && (
<span className={style.groupFooter}>
<Tooltip
text='Time inside this group with no event running. It covers anything the timer did not: changeovers, a late start on an event, or an item run without a timer at all.'
render={<span />}
>
<b>{formatDuration(group.untimed, false)}</b> untimed
</Tooltip>
</span>
)}
</th>
</tr>
);
}
function EventRow({ entry, groupColour, grouped }: { entry: CombinedReport; groupColour?: string; grouped: boolean }) {
const start = punctuality(entry.actualStart, entry.scheduledStart);
const end = punctuality(entry.actualEnd, entry.scheduledEnd);
return (
<tr className={cx([style.eventRow, grouped && style.groupedRow])} style={eventColours(entry.colour, groupColour)}>
<td className={style.eventIndex}>{entry.index}</td>
<td className={style.eventCue}>{entry.cue}</td>
<td>{entry.title}</td>
<td>{formatTime(entry.scheduledStart)}</td>
<td className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</td>
<td>{formatTime(entry.scheduledEnd)}</td>
<td className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</td>
</tr>
);
}
/**
* Uses the cuesheet's custom property for group colour. Left unset when the
* group has none so the stylesheet can provide a neutral edge.
*/
function groupColourStyle(colour?: string): React.CSSProperties {
return { '--user-bg': colour || undefined } as React.CSSProperties;
}
/** Keeps the event wash distinct from its parent group's identifying rail. */
function eventColours(eventColour: string, groupColour?: string): React.CSSProperties {
return {
'--event-bg': eventColour || undefined,
'--user-bg': groupColour || undefined,
} as React.CSSProperties;
}
/** 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';
}
type Section = {
key: string;
group: GroupReport | null;
rows: CombinedReport[];
};
/**
* Splits the rows into the blocks they belong to, keeping rundown order and
* leaving ungrouped events in their own run of rows.
*/
function makeSections(rows: CombinedReport[], groups: GroupReport[]): Section[] {
const byId = new Map<EntryId, GroupReport>(groups.map((group) => [group.id, group]));
const sections: Section[] = [];
let current: Section | null = null;
let currentParent: EntryId | null | undefined;
for (const row of rows) {
if (current === null || row.parent !== currentParent) {
currentParent = row.parent;
// index keeps the key unique even if a group were to appear twice
current = {
key: `${row.parent ?? 'ungrouped'}-${sections.length}`,
group: row.parent ? (byId.get(row.parent) ?? null) : null,
rows: [],
};
sections.push(current);
}
current.rows.push(row);
}
return sections;
}
@@ -1,13 +1,19 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
/** the group this event belongs to, so the report can mirror the rundown */
parent: EntryId | null;
groupTitle: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
@@ -15,15 +21,19 @@ export type CombinedReport = {
};
/**
* Creates a combined report with the rundown data
* Creates a combined report with the rundown data.
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/
export function getCombinedReport(
report: OntimeReport,
rundown: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
if (Object.keys(report).length === 0 || flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
@@ -31,59 +41,77 @@ export function getCombinedReport(
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue;
// skipped events were never meant to run, listing them alongside events
// that did would also disagree with the summary, which excludes them
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
if (!(id in report)) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
}
const parent = entry.parent;
const group = parent ? rundown[parent] : undefined;
const reported = report[id];
if (id in report) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: report[id].endedAt,
scheduledEnd: entry.timeEnd,
actualStart: report[id].startedAt,
});
}
combinedReport.push({
id,
index,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent,
groupTitle: group && 'title' in group ? group.title : '',
// an event that ran is measured against the plan it ran on, one that
// did not has no snapshot and falls back to the rundown
scheduledStart: reported?.scheduledStart ?? entry.timeStart,
scheduledEnd: reported ? reported.scheduledStart + reported.scheduledDuration : entry.timeEnd,
actualStart: reported?.startedAt ?? null,
actualEnd: reported?.endedAt ?? null,
});
index++;
}
return combinedReport;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that
* positive means behind schedule.
*/
export function formatOffset(value: MaybeNumber): string {
if (value === null) return enDash;
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
}
/** Whether an offset is behind, ahead, or neither, for colouring */
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
return value > 0 ? 'over' : 'under';
}
/** @private */
function csvTime(value: MaybeNumber): string {
return value === null ? '' : formatTime(value);
}
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string
* Transforms a CombinedReport into a CSV string.
*
* Exported as one row per event with its group named, rather than with
* rollups baked in, so it stays the dataset a report is built from.
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv: string[][] = [];
csv.push(csvHeader);
const csv = combinedReport.map((entry) => [
String(entry.index),
entry.groupTitle,
entry.cue,
entry.title,
formatTime(entry.scheduledStart),
// an event that never ran leaves the cell empty rather than a
// placeholder, so a spreadsheet reads it as missing
csvTime(entry.actualStart),
formatTime(entry.scheduledEnd),
csvTime(entry.actualEnd),
]);
for (const entry of combinedReport) {
csv.push([
String(entry.index),
entry.title,
entry.cue,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
]);
}
return makeCSVFromArrayOfArrays(csv);
return makeCSVFromArrayOfArrays([csvHeader, ...csv]);
}
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -1,5 +1,5 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +32,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +43,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
return <EventReport className={className} id={id} />;
}
if (playbackActive) {
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { className, id } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
// measured against the schedule recorded when the event ran, so this
// agrees with the report panel and survives later rundown edits
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
if (variance.status === 'ontime') {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [value, variance.status, tooltip];
}, [currentReport]);
if (!value) {
return null;
@@ -0,0 +1,186 @@
import { RefetchKey, TimerLifeCycle } from 'ontime-types';
import type { PlayableEvent } from 'ontime-types';
import { vi } from 'vitest';
import { sendRefetch } from '../../../adapters/WebsocketAdapter.js';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { clear, generate, generateShowReport, triggerReportEntry } from '../report.service.js';
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
sendRefetch: vi.fn(),
}));
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(() => {
vi.clearAllMocks();
clear();
});
describe('triggerReportEntry()', () => {
it('snapshots the schedule when an event starts', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()[eventA.id]).toEqual({
startedAt: 500,
endedAt: null,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
it('notifies clients when an event starts', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(sendRefetch).toHaveBeenCalledWith(RefetchKey.Report);
});
it('keeps the snapshot taken at start when the event stops', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: 0,
endedAt: 12000,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
it('records the schedule as it was, not as it later becomes', () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
// the event is edited to a different duration, then stopped
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
});
it('falls back to the current event when a stop arrives with no start', () => {
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: null,
endedAt: 10000,
scheduledDuration: eventA.duration,
});
});
it('starts a fresh report when a new show begins', () => {
// a rehearsal ran earlier and left its numbers behind
const rehearsal = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
});
triggerReportEntry(TimerLifeCycle.onStart, rehearsal);
triggerReportEntry(TimerLifeCycle.onStop, { ...rehearsal, clock: 20000 } as typeof rehearsal);
expect(generate()[eventA.id].endedAt).toBe(20000);
// the performance is a different show and must not inherit them
const show = makeRuntimeStateData({
eventNow: eventB,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 9999,
});
triggerReportEntry(TimerLifeCycle.onStart, show);
expect(generate()[eventA.id]).toBeUndefined();
expect(generate()[eventB.id]).toBeDefined();
});
it('keeps accumulating within the same show', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: 1000 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
const next = makeRuntimeStateData({
eventNow: eventB,
timer: { startedAt: 10000 },
clock: 10000,
_startEpoch: 1000,
});
triggerReportEntry(TimerLifeCycle.onStart, next);
// same show, so the earlier event is still part of the report
expect(Object.keys(generate())).toHaveLength(2);
});
it('ignores events without an id', () => {
const state = makeRuntimeStateData({ eventNow: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toEqual({});
});
});
describe('generateShowReport()', () => {
it('captures the plan the show was measured against', () => {
const state = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
});
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generateShowReport()).toMatchObject({ plannedStart: 68400000, plannedEnd: 75600000 });
});
it('keeps the plan captured at start when the rundown is edited later', () => {
const start = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
});
triggerReportEntry(TimerLifeCycle.onStart, start);
// the rundown is reworked mid show, the plan it started against stands
const edited = { ...start, rundown: { ...start.rundown, plannedEnd: 99999999 } } as typeof start;
triggerReportEntry(TimerLifeCycle.onStop, { ...edited, clock: 10000 } as typeof start);
expect(generateShowReport().plannedEnd).toBe(75600000);
});
it('derives actual times from the events that ran', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500, _startEpoch: 1 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 12000 } as typeof state);
expect(generateShowReport()).toMatchObject({ actualStart: 500, actualEnd: 12000 });
});
it('has no times before anything runs', () => {
expect(generateShowReport()).toEqual({
plannedStart: null,
plannedEnd: null,
actualStart: null,
actualEnd: null,
});
});
});
describe('clear()', () => {
it('clears a single event', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, state);
clear(eventA.id);
expect(generate()).toEqual({});
});
});
@@ -10,6 +10,14 @@ router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate());
});
/**
* Show level times, kept separate so the report payload stays as it was
* for the integrations that already read it.
*/
router.get('/show', (_req: Request, res: Response) => {
res.status(200).json(report.generateShowReport());
});
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
res.status(204).send();
@@ -1,4 +1,5 @@
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
import { OntimeEventReport, OntimeReport, RefetchKey, ShowReport, TimerLifeCycle } from 'ontime-types';
import { getActualShowTimes } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
@@ -8,6 +9,20 @@ const report = new Map<string, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/**
* Identifies the show the current report belongs to.
* The report describes one run, so starting a new show begins a fresh one
* rather than mixing a rehearsal into the numbers for the performance.
*/
let currentShowStart: number | null = null;
/**
* The plan the show was measured against, taken when it starts.
* Snapshotted for the same reason the per event schedule is: editing the
* rundown afterwards must not move the target a past show was judged by.
*/
let plannedTimes: Pick<ShowReport, 'plannedStart' | 'plannedEnd'> = { plannedStart: null, plannedEnd: null };
/**
* generates a full report
* @returns full report
@@ -27,9 +42,14 @@ export function clear(id?: string) {
formattedReport = null;
if (id) {
report.delete(id);
} else {
report.clear();
return;
}
// clearing everything also forgets which show the report described, so the
// next event starts a report rather than resuming the one just discarded
report.clear();
currentShowStart = null;
plannedTimes = { plannedStart: null, plannedEnd: null };
}
/**
@@ -49,15 +69,63 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
startShowIfNew(state);
report.set(eventId, {
startedAt: state.timer.startedAt,
endedAt: null,
// snapshot the schedule so later rundown edits cannot change how a show
// that already happened is reported
scheduledStart: state.eventNow.timeStart,
scheduledDuration: state.eventNow.duration,
});
formattedReport = null;
sendRefetch(RefetchKey.Report);
return;
}
if (cycle === TimerLifeCycle.onStop) {
const startedAt = report.get(eventId)?.startedAt ?? null;
report.set(eventId, { startedAt, endedAt: state.clock });
const previous = report.get(eventId);
report.set(eventId, {
startedAt: previous?.startedAt ?? null,
endedAt: state.clock,
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
});
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
}
/**
* Clears the report when a new show begins.
*
* The runtime stamps a show when its first event starts, so a change of stamp
* means the previous report described a different run. Without this the report
* would accumulate across rehearsals and performances with no way to tell
* which numbers belonged to which.
* @private
*/
function startShowIfNew(state: DeepReadonly<RuntimeState>) {
const showStart = state._startEpoch ?? state.rundown.actualStart;
if (showStart === null || showStart === currentShowStart) {
return;
}
report.clear();
formattedReport = null;
currentShowStart = showStart;
plannedTimes = {
plannedStart: state.rundown.plannedStart,
plannedEnd: state.rundown.plannedEnd,
};
}
/**
* Show level times for the report.
* Planned times are the ones captured when the show started, actual times are
* derived from the events that ran.
*/
export function generateShowReport(): ShowReport {
return { ...plannedTimes, ...getActualShowTimes(generate()) };
}
@@ -1,8 +1,103 @@
import type { MaybeNumber } from '../../utils/utils.type.js';
import type { EntryId } from './OntimeEntry.js';
export type OntimeEventReport = {
startedAt: MaybeNumber;
endedAt: MaybeNumber;
/**
* Snapshot of the schedule taken when the event ran.
* Keeping a copy is what makes a report a record: editing the rundown
* afterwards no longer changes how a show that already happened is reported.
*/
scheduledStart: number;
scheduledDuration: number;
};
export type OntimeReport = Record<string, OntimeEventReport>;
export type OntimeReport = Record<EntryId, OntimeEventReport>;
/**
* Show level times for the report.
*
* Planned times are snapshotted when the show starts, for the same reason the
* per event schedule is. Actual times are derived from the events that ran.
*/
export type ShowReport = {
plannedStart: MaybeNumber;
plannedEnd: MaybeNumber;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
};
/**
* How the show sat against its plan, as the two separate questions it answers.
*
* Follows Ontime's offset convention: positive means behind schedule.
*
* `endOffset` asks whether the show came off air when it promised to, which is
* what an audience or a venue booking is measured against. `durationOffset`
* asks whether the show itself ran long, which is what the team controls and
* what carries over to the next run of the same rundown. They differ by
* exactly `startOffset`: a show can run over and still finish early if it
* started early, so reporting either one alone is misleading.
*/
export type ShowOffsets = {
/** actual start against planned start */
startOffset: MaybeNumber;
/** actual end against planned end */
endOffset: MaybeNumber;
/** how long the show was planned to take */
plannedDuration: MaybeNumber;
/** how long it actually took */
actualDuration: MaybeNumber;
/** actualDuration against plannedDuration, ie whether the show ran long */
durationOffset: MaybeNumber;
};
/**
* How a group ran against the budget set for it.
*
* Ontime already compares a group's scheduled duration against its
* targetDuration while planning. This closes that loop after the show.
*/
export type GroupReport = {
id: EntryId;
title: string;
colour: string;
/** the budget the user set for the block, null when they set none */
targetDuration: MaybeNumber;
/** what was scheduled into the block */
scheduledDuration: number;
actualStart: MaybeNumber;
actualEnd: MaybeNumber;
/** wall time from the first event starting to the last one ending */
elapsed: MaybeNumber;
/**
* Time inside the block with no event running: elapsed minus the sum of the
* events' own durations. Deliberately named for what was measured rather
* than for a cause, since the same gap can be a changeover, a timer started
* late, or an event run without the timer at all.
*/
untimed: MaybeNumber;
/**
* Measured against targetDuration where set, otherwise scheduledDuration.
* Null while the group is incomplete: the events still to run would make a
* partial group read as a large underrun.
*/
variance: MaybeNumber;
eventsRun: number;
eventsPlanned: number;
};
/**
* Counts across the events in the report.
*
* Deliberately not a tally of events over, under and on time: events rarely
* land on the exact second, so those buckets describe rounding more than they
* describe the show. The single worst overrun is the fact that acts on.
*/
export type RunSummary = {
eventsRun: number;
eventsPlanned: number;
/** largest single overrun, answers "what blew the schedule" */
worstOverrun: { id: EntryId; delta: number } | null;
};
+8 -1
View File
@@ -24,7 +24,14 @@ export { TimerType } from './definitions/TimerType.type.js';
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
export type {
GroupReport,
OntimeReport,
OntimeEventReport,
RunSummary,
ShowOffsets,
ShowReport,
} from './definitions/core/Report.type.js';
// ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
+13
View File
@@ -99,6 +99,19 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
// feature business logic - reports
export {
countPlannedEvents,
elapsedBetween,
getActualShowTimes,
getEventVariance,
getGroupReports,
getRunSummary,
getShowOffsets,
type EventVariance,
type VarianceStatus,
} from './src/report-utils/reportUtils.js';
//Colour
export {
colourToHex,
@@ -0,0 +1,362 @@
import type { OntimeEventReport, OntimeReport, RundownEntries } from 'ontime-types';
import { SupportedEntry } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js';
import {
countPlannedEvents,
elapsedBetween,
getActualShowTimes,
getEventVariance,
getGroupReports,
getRunSummary,
getShowOffsets,
} from './reportUtils.js';
const MIN = 60000;
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
return { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, ...patch };
}
function makeEvent(id: string, patch: Record<string, unknown> = {}) {
return { type: SupportedEntry.Event, id, duration: 10000, skip: false, parent: null, ...patch } as never;
}
function makeGroup(id: string, entries: string[], patch: Record<string, unknown> = {}) {
return {
type: SupportedEntry.Group,
id,
title: id,
colour: '',
targetDuration: null,
entries,
...patch,
} as never;
}
describe('elapsedBetween()', () => {
it('measures forward within a day', () => {
expect(elapsedBetween(1000, 5000)).toBe(4000);
});
it('reads a backwards result as having crossed midnight', () => {
// 23:50 to 00:10 is twenty minutes, not minus twenty three hours
const tenToMidnight = dayInMs - 10 * MIN;
expect(elapsedBetween(tenToMidnight, 10 * MIN)).toBe(20 * MIN);
});
});
describe('getEventVariance()', () => {
it('reports an event which never ran', () => {
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null });
});
it('reports an event which started but never finished', () => {
expect(getEventVariance(makeEntry({ endedAt: null }))).toMatchObject({ status: 'not-run' });
});
it('treats sub-second differences as on time', () => {
expect(getEventVariance(makeEntry({ endedAt: 10500 }))).toMatchObject({ status: 'ontime', delta: 500 });
});
it('reports an overrun', () => {
expect(getEventVariance(makeEntry({ endedAt: 15000 }))).toMatchObject({ status: 'over', delta: 5000 });
});
it('reports an underrun', () => {
expect(getEventVariance(makeEntry({ endedAt: 6000 }))).toMatchObject({ status: 'under', delta: -4000 });
});
it('measures against the snapshot, not a later edit', () => {
expect(getEventVariance(makeEntry({ endedAt: 12000, scheduledDuration: 10000 })).delta).toBe(2000);
});
it('handles an event running past midnight', () => {
const entry = makeEntry({ startedAt: dayInMs - 5 * MIN, endedAt: 5 * MIN, scheduledDuration: 10 * MIN });
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10 * MIN });
});
});
describe('getShowOffsets()', () => {
const HOUR = 60 * MIN;
it('reports a late start carried through to a late end', () => {
// started 8 late, ended 8 late: the show itself ran clean
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 8 * MIN,
actualEnd: 21 * HOUR + 8 * MIN,
});
expect(offsets).toEqual({
startOffset: 8 * MIN,
endOffset: 8 * MIN,
plannedDuration: 2 * HOUR,
actualDuration: 2 * HOUR,
durationOffset: 0,
});
});
it('separates how long the show ran from when it finished', () => {
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 8 * MIN,
actualEnd: 21 * HOUR + 12 * MIN,
});
expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, durationOffset: 4 * MIN });
});
it('reports a show which ran short as a negative', () => {
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 10 * MIN,
actualEnd: 21 * HOUR + 2 * MIN,
});
expect(offsets.durationOffset).toBe(-8 * MIN);
});
it('can report a show which ran over and still finished early', () => {
// the case which makes reporting either figure alone misleading:
// started 10 early, ran 4 over, so came off 6 early
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR - 10 * MIN,
actualEnd: 21 * HOUR - 6 * MIN,
});
expect(offsets.endOffset).toBe(-6 * MIN);
expect(offsets.durationOffset).toBe(4 * MIN);
});
it('keeps the two offsets exactly one start offset apart', () => {
const offsets = getShowOffsets({
plannedStart: 19 * HOUR,
plannedEnd: 21 * HOUR,
actualStart: 19 * HOUR + 3 * MIN,
actualEnd: 21 * HOUR + 11 * MIN,
});
expect(offsets.endOffset! - offsets.startOffset!).toBe(offsets.durationOffset);
});
it('does not read a show ending after midnight as a day early', () => {
const offsets = getShowOffsets({
plannedStart: 23 * HOUR,
plannedEnd: dayInMs - 10 * MIN,
actualStart: 23 * HOUR,
actualEnd: 5 * MIN, // ran past midnight
});
expect(offsets.endOffset).toBe(15 * MIN);
// 23:00 to 00:05 is an hour and five minutes, not a negative
expect(offsets.actualDuration).toBe(HOUR + 5 * MIN);
expect(offsets.durationOffset).toBe(15 * MIN);
});
it('still reports how long a show ran when it had no plan to run against', () => {
const offsets = getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 0, actualEnd: 5 * MIN });
// how long it took is knowable, whether that was long or short is not
expect(offsets).toEqual({
startOffset: null,
endOffset: null,
plannedDuration: null,
actualDuration: 5 * MIN,
durationOffset: null,
});
});
it('has nothing to report for a show which has not run', () => {
expect(getShowOffsets({ plannedStart: 0, plannedEnd: MIN, actualStart: null, actualEnd: null })).toMatchObject({
startOffset: null,
endOffset: null,
actualDuration: null,
durationOffset: null,
});
});
});
describe('getActualShowTimes()', () => {
it('opens on the first start and closes on the last end', () => {
const report: OntimeReport = {
b: makeEntry({ startedAt: 5000, endedAt: 9000 }),
a: makeEntry({ startedAt: 1000, endedAt: 4000 }),
c: makeEntry({ startedAt: 9000, endedAt: 20000 }),
};
expect(getActualShowTimes(report)).toEqual({ actualStart: 1000, actualEnd: 20000 });
});
it('is empty when nothing ran', () => {
expect(getActualShowTimes({})).toEqual({ actualStart: null, actualEnd: null });
});
it('keeps events after midnight later than events before midnight', () => {
const report: OntimeReport = {
beforeMidnight: makeEntry({
startedAt: dayInMs - 10 * MIN,
endedAt: dayInMs - 5 * MIN,
}),
afterMidnight: makeEntry({ startedAt: 0, endedAt: 10 * MIN }),
};
expect(getActualShowTimes(report)).toEqual({
actualStart: dayInMs - 10 * MIN,
actualEnd: 10 * MIN,
});
});
});
describe('getGroupReports()', () => {
const entries: RundownEntries = {
act1: makeGroup('act1', ['a', 'b'], { targetDuration: 30 * MIN }),
a: makeEvent('a', { duration: 10 * MIN, parent: 'act1' }),
b: makeEvent('b', { duration: 10 * MIN, parent: 'act1' }),
};
it('measures the block against the target the user set', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 12 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 12 * MIN, endedAt: 24 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group).toMatchObject({
targetDuration: 30 * MIN,
elapsed: 24 * MIN,
variance: -6 * MIN, // came in under its 30 minute budget
eventsRun: 2,
eventsPlanned: 2,
});
});
it('reports the time a block spent with no event running', () => {
// 5 minutes between one event ending and the next starting
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 15 * MIN, endedAt: 25 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group.elapsed).toBe(25 * MIN);
expect(group.untimed).toBe(5 * MIN);
});
it('measures a block across midnight in chronological order', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: dayInMs - 10 * MIN, endedAt: dayInMs - 5 * MIN, scheduledDuration: 5 * MIN }),
b: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group).toMatchObject({
actualStart: dayInMs - 10 * MIN,
actualEnd: 10 * MIN,
elapsed: 20 * MIN,
untimed: 5 * MIN,
});
});
it('falls back to what was scheduled when no target was set', () => {
const noTarget: RundownEntries = { ...entries, act1: makeGroup('act1', ['a', 'b']) };
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 12 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 12 * MIN, endedAt: 24 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, noTarget, ['act1']);
// 24 elapsed against 20 scheduled
expect(group.targetDuration).toBeNull();
expect(group.scheduledDuration).toBe(20 * MIN);
expect(group.variance).toBe(4 * MIN);
});
it('withholds variance while a block still has events to run', () => {
// one of two events run: the block has not spent its 30 minute budget yet,
// reporting it now would read as a 20 minute underrun
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group.eventsRun).toBe(1);
expect(group.eventsPlanned).toBe(2);
expect(group.variance).toBeNull();
// what did happen is still reported
expect(group.elapsed).toBe(10 * MIN);
});
it('has no actuals for a block which never ran', () => {
const [group] = getGroupReports({}, entries, ['act1']);
expect(group).toMatchObject({ elapsed: null, untimed: null, variance: null, eventsRun: 0 });
});
it('leaves skipped events out of the block', () => {
const withSkip: RundownEntries = { ...entries, b: makeEvent('b', { duration: 10 * MIN, skip: true }) };
const [group] = getGroupReports({}, withSkip, ['act1']);
expect(group.eventsPlanned).toBe(1);
expect(group.scheduledDuration).toBe(10 * MIN);
});
it('ignores entries which are not groups', () => {
expect(getGroupReports({}, entries, ['a'])).toEqual([]);
});
});
describe('getRunSummary()', () => {
it('counts how much of the show ran', () => {
const report: OntimeReport = {
a: makeEntry({ endedAt: 15000 }),
b: makeEntry({ endedAt: 6000 }),
c: makeEntry({ endedAt: 10000 }),
};
expect(getRunSummary(report, 4)).toMatchObject({ eventsRun: 3, eventsPlanned: 4 });
});
it('identifies the worst overrun', () => {
const report: OntimeReport = {
a: makeEntry({ endedAt: 15000 }),
b: makeEntry({ endedAt: 30000 }),
c: makeEntry({ endedAt: 12000 }),
};
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
});
it('has no worst overrun when nothing ran long', () => {
const report: OntimeReport = { a: makeEntry({ endedAt: 6000 }), b: makeEntry({ endedAt: 10000 }) };
expect(getRunSummary(report, 2).worstOverrun).toBeNull();
});
it('ignores events which did not complete', () => {
const report: OntimeReport = { a: makeEntry({ endedAt: 15000 }), b: makeEntry({ endedAt: null }) };
expect(getRunSummary(report, 2).eventsRun).toBe(1);
});
});
describe('countPlannedEvents()', () => {
it('counts playable events, excluding skipped ones', () => {
const entries: RundownEntries = {
a: makeEvent('a'),
b: makeEvent('b', { skip: true }),
g: makeGroup('g', []),
};
expect(countPlannedEvents(entries, ['a', 'b', 'g'])).toBe(1);
});
});
@@ -0,0 +1,306 @@
import type {
EntryId,
GroupReport,
OntimeEventReport,
OntimeReport,
RundownEntries,
RunSummary,
ShowOffsets,
ShowReport,
} from 'ontime-types';
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { dayInMs, 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' };
/**
* Time between two points in the day.
*
* Report times are times of day, so a show running past midnight would
* otherwise measure as a large negative. A backwards result is read as
* having crossed into the next day.
*/
export function elapsedBetween(from: number, to: number): number {
return to >= from ? to - from : to + dayInMs - from;
}
/**
* 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 = elapsedBetween(startedAt, endedAt);
const delta = actualDuration - scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) {
return { actualDuration, delta, status: 'ontime' };
}
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
/**
* How the show sat against its plan.
*
* A sum of event overruns cannot answer this: gaps absorb overrun, skipped
* events give time back, and a late start moves the whole show without any
* event running long.
*
* Finishing time and running time are reported separately because they answer
* different questions and can point opposite ways: a show which starts early
* and runs over still finishes early.
*/
export function getShowOffsets(show: ShowReport): ShowOffsets {
const startOffset = offsetBetween(show.plannedStart, show.actualStart);
const endOffset = offsetBetween(show.plannedEnd, show.actualEnd);
const plannedDuration = durationBetween(show.plannedStart, show.plannedEnd);
const actualDuration = durationBetween(show.actualStart, show.actualEnd);
return {
startOffset,
endOffset,
plannedDuration,
actualDuration,
durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration,
};
}
/**
* Time from one point in the day to another, or null if either is missing.
* @private
*/
function durationBetween(from: number | null, to: number | null): number | null {
if (from === null || to === null) {
return null;
}
return elapsedBetween(from, to);
}
/**
* Signed distance from a planned time to the time it happened.
* Positive means late, matching Ontime's offset convention.
* @private
*/
function offsetBetween(planned: number | null, actual: number | null): number | null {
if (planned === null || actual === null) {
return null;
}
const diff = actual - planned;
// a show is not half a day early: read a large negative as having crossed midnight
if (diff < -dayInMs / 2) {
return diff + dayInMs;
}
if (diff > dayInMs / 2) {
return diff - dayInMs;
}
return diff;
}
/**
* Derives the show's actual start and end from the events that ran.
* The first event to start opens the show, the last to finish closes it.
*/
export function getActualShowTimes(report: OntimeReport): Pick<ShowReport, 'actualStart' | 'actualEnd'> {
return getActualReportTimes(Object.values(report));
}
/**
* Finds the chronological bounds of report entries whose timestamps are times
* of day. Each observed timestamp is tried as the timeline origin; the origin
* producing the shortest span is the one that preserves a midnight rollover.
*/
function getActualReportTimes(entries: OntimeEventReport[]): Pick<ShowReport, 'actualStart' | 'actualEnd'> {
const origins = new Set<number>();
for (const entry of entries) {
if (entry.startedAt !== null) origins.add(entry.startedAt);
if (entry.endedAt !== null) origins.add(entry.endedAt);
}
let bestSpan = Number.POSITIVE_INFINITY;
let bestStart: number | null = null;
let bestEnd: number | null = null;
for (const origin of origins) {
let timelineStart = Number.POSITIVE_INFINITY;
let timelineEnd = Number.NEGATIVE_INFINITY;
let earliestStartPosition = Number.POSITIVE_INFINITY;
let latestEndPosition = Number.NEGATIVE_INFINITY;
let actualStart: number | null = null;
let actualEnd: number | null = null;
for (const entry of entries) {
if (entry.startedAt === null) {
if (entry.endedAt !== null) {
const endPosition = elapsedBetween(origin, entry.endedAt);
timelineStart = Math.min(timelineStart, endPosition);
timelineEnd = Math.max(timelineEnd, endPosition);
if (endPosition > latestEndPosition) {
latestEndPosition = endPosition;
actualEnd = entry.endedAt;
}
}
continue;
}
const startPosition = elapsedBetween(origin, entry.startedAt);
timelineStart = Math.min(timelineStart, startPosition);
if (startPosition < earliestStartPosition) {
earliestStartPosition = startPosition;
actualStart = entry.startedAt;
}
timelineEnd = Math.max(timelineEnd, startPosition);
if (entry.endedAt !== null) {
const endPosition = startPosition + elapsedBetween(entry.startedAt, entry.endedAt);
timelineEnd = Math.max(timelineEnd, endPosition);
if (endPosition > latestEndPosition) {
latestEndPosition = endPosition;
actualEnd = entry.endedAt;
}
}
}
const span = timelineEnd - timelineStart;
if (span < bestSpan) {
bestSpan = span;
bestStart = actualStart;
bestEnd = actualEnd;
}
}
return { actualStart: bestStart, actualEnd: bestEnd };
}
/**
* Rolls the report up to the blocks the show was planned in.
*
* A group carries a targetDuration the user committed to while planning, and
* Ontime already tells them whether the schedule fits it. This reports whether
* the show actually did.
*/
export function getGroupReports(report: OntimeReport, entries: RundownEntries, order: EntryId[]): GroupReport[] {
const groups: GroupReport[] = [];
for (const id of order) {
const group = entries[id];
if (!group || !isOntimeGroup(group)) continue;
let scheduledDuration = 0;
let eventsPlanned = 0;
let eventsRun = 0;
let ranDuration = 0;
const completedReports: OntimeEventReport[] = [];
for (const childId of group.entries) {
const child = entries[childId];
// skipped events were never meant to run and would read as missed
if (!child || !isOntimeEvent(child) || child.skip) continue;
eventsPlanned += 1;
const reported = report[childId];
// measure against what was scheduled at the time where we know it
scheduledDuration += reported?.scheduledDuration ?? child.duration;
const variance = getEventVariance(reported);
if (variance.actualDuration === null) continue;
eventsRun += 1;
ranDuration += variance.actualDuration;
completedReports.push(reported);
}
const { actualStart, actualEnd } = getActualReportTimes(completedReports);
const elapsed = actualStart === null || actualEnd === null ? null : elapsedBetween(actualStart, actualEnd);
// the budget if the user set one, otherwise what they scheduled into it
const measuredAgainst = group.targetDuration ?? scheduledDuration;
// a group with events still to run has not spent its budget yet: comparing
// what it has used so far against the whole would report a large underrun
const isComplete = eventsRun > 0 && eventsRun === eventsPlanned;
groups.push({
id: group.id,
title: group.title,
colour: group.colour,
targetDuration: group.targetDuration,
scheduledDuration,
actualStart,
actualEnd,
elapsed,
// whatever the block spent with no event running
untimed: elapsed === null ? null : Math.max(0, elapsed - ranDuration),
variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst,
eventsRun,
eventsPlanned,
});
}
return groups;
}
/**
* Counts across the events in the report.
*
* Only how much of the show ran, and which single event ran longest over its
* schedule. Bucketing every event as over, under or on time reads as precision
* the data does not have: almost nothing lands on the exact second.
*/
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
const summary: RunSummary = {
eventsRun: 0,
eventsPlanned,
worstOverrun: null,
};
for (const [id, entry] of Object.entries(report)) {
const variance = getEventVariance(entry);
if (variance.status === 'not-run') {
continue;
}
summary.eventsRun += 1;
if (variance.status === 'over' && (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta)) {
summary.worstOverrun = { id, delta: variance.delta };
}
}
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;
}