mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-29 02:49:13 +00:00
refactor(report): summary
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, _previousQuery) => previousData,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
return { data: data ?? emptyShowReport };
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
th.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
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 ReportShowSummary from './composite/ReportShowSummary';
|
||||
import ReportTable from './composite/ReportTable';
|
||||
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
|
||||
import style from './ReportSettings.module.scss';
|
||||
|
||||
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) {
|
||||
if (combinedReport.length === 0) {
|
||||
return;
|
||||
}
|
||||
const csv = makeReportCSV(combinedReport);
|
||||
@@ -31,73 +32,49 @@ export default function ReportSettings() {
|
||||
return getCombinedReport(reportData, data.entries, data.flatOrder);
|
||||
}, [reportData, data.entries, data.flatOrder]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder));
|
||||
}, [reportData, data.entries, data.flatOrder]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return getGroupReports(reportData, data.entries, data.order);
|
||||
}, [reportData, data.entries, data.order]);
|
||||
|
||||
const hasReport = combinedReport.length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Report</Panel.SubHeader>
|
||||
<Panel.SubHeader>
|
||||
Report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} 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>
|
||||
) : (
|
||||
<>
|
||||
<ReportShowSummary show={showReport} summary={summary} />
|
||||
<Panel.Section>
|
||||
<ReportTable rows={combinedReport} groups={groups} />
|
||||
</Panel.Section>
|
||||
</>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
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 });
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// a block within the panel card, matching how Info nests inside a card
|
||||
.summary {
|
||||
margin: 0 var(--panel-card-padding, 2rem);
|
||||
padding: 1.25rem;
|
||||
background-color: $gray-1200;
|
||||
border-radius: 3px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2.5rem;
|
||||
}
|
||||
|
||||
// the conclusion, given the most weight in the block
|
||||
.verdict {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.verdictLabel {
|
||||
font-size: calc(1rem - 3px);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.verdictValue {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
&.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
&.none {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.incompleteNote {
|
||||
max-width: 34rem;
|
||||
color: $warning-orange;
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// label | planned -> actual | offset, so every offset lands in one column
|
||||
.lines {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem 1.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $gray-300;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.planned {
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.actual {
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.offset {
|
||||
justify-self: end;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
&.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
&.none {
|
||||
color: $gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.counts {
|
||||
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);
|
||||
}
|
||||
|
||||
.count {
|
||||
white-space: nowrap;
|
||||
|
||||
b {
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $gray-500;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types';
|
||||
import { getShowOffsets, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
|
||||
import { cx, enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../../../common/utils/time';
|
||||
import { formatOffset, offsetTone } from '../reportSettings.utils';
|
||||
|
||||
import style from './ReportShowSummary.module.scss';
|
||||
|
||||
interface ReportShowSummaryProps {
|
||||
show: ShowReport;
|
||||
summary: RunSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leads the report with how the show sat against its plan.
|
||||
*
|
||||
* Start and end offsets are kept apart because they have different causes: a
|
||||
* late start is rarely the show's doing, while time lost between the two is.
|
||||
* That difference is the conclusion, so it carries the weight in the block.
|
||||
*/
|
||||
export default function ReportShowSummary({ show, summary }: 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. Comparing that against the planned end would report a
|
||||
* large recovery for a show that never finished.
|
||||
*/
|
||||
const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned;
|
||||
const hasPlan = offsets.startOffset !== null;
|
||||
|
||||
return (
|
||||
<div className={style.summary}>
|
||||
{hasPlan && (
|
||||
<div className={style.top}>
|
||||
<div className={style.verdict}>
|
||||
{didReachEnd ? (
|
||||
<>
|
||||
<Tooltip
|
||||
text='The end offset less the start offset: time the show itself lost or made up, setting aside how late it began.'
|
||||
render={<span />}
|
||||
className={style.verdictLabel}
|
||||
>
|
||||
{verdictLabel(offsets.duringShow)}
|
||||
</Tooltip>
|
||||
<span className={cx([style.verdictValue, style[offsetTone(offsets.duringShow)]])}>
|
||||
{formatOffset(offsets.duringShow)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={style.verdictLabel}>Show incomplete</span>
|
||||
<span className={style.incompleteNote}>
|
||||
The show did not reach the end of the rundown, so there is nothing to compare the finish against.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.lines}>
|
||||
<TimeLine
|
||||
label='Started'
|
||||
planned={show.plannedStart}
|
||||
actual={show.actualStart}
|
||||
offset={offsets.startOffset}
|
||||
/>
|
||||
{didReachEnd && (
|
||||
<TimeLine label='Ended' planned={show.plannedEnd} actual={show.actualEnd} offset={offsets.endOffset} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={style.counts}>
|
||||
<span className={style.count}>
|
||||
<b>
|
||||
{summary.eventsRun} of {summary.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</span>
|
||||
<Count label='over' value={summary.eventsOver} />
|
||||
<Count label='under' value={summary.eventsUnder} />
|
||||
<Count label='on time' value={summary.eventsOnTime} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function verdictLabel(duringShow: MaybeNumber): string {
|
||||
if (duringShow === null || Math.abs(duringShow) < MILLIS_PER_SECOND) {
|
||||
return 'Held schedule during the show';
|
||||
}
|
||||
return duringShow > 0 ? 'Lost during the show' : 'Recovered during the show';
|
||||
}
|
||||
|
||||
function TimeLine({
|
||||
label,
|
||||
planned,
|
||||
actual,
|
||||
offset,
|
||||
}: {
|
||||
label: string;
|
||||
planned: MaybeNumber;
|
||||
actual: MaybeNumber;
|
||||
offset: MaybeNumber;
|
||||
}) {
|
||||
return (
|
||||
<Fragment>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={style.times}>
|
||||
<span className={style.planned}>{planned === null ? enDash : formatTime(planned)}</span>
|
||||
<span className={style.arrow}>→</span>
|
||||
<span className={style.actual}>{actual === null ? enDash : formatTime(actual)}</span>
|
||||
</span>
|
||||
<span className={cx([style.offset, style[offsetTone(offset)]])}>{formatOffset(offset)}</span>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={style.count}>
|
||||
<b>{value}</b> {label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
th.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.groupRow {
|
||||
background-color: $white-3;
|
||||
|
||||
td {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.groupHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.groupColour {
|
||||
width: 0.25rem;
|
||||
height: 1rem;
|
||||
border-radius: 2px;
|
||||
background-color: var(--group-colour, #{$gray-500});
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 0.625rem;
|
||||
color: $gray-300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat b {
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.variance {
|
||||
font-weight: 600;
|
||||
|
||||
&.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
&.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
&.none {
|
||||
color: $gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.incomplete {
|
||||
color: $warning-orange;
|
||||
}
|
||||
|
||||
.eventCue,
|
||||
.eventIndex {
|
||||
color: $gray-300;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { EntryId, GroupReport } from 'ontime-types';
|
||||
import { Fragment, 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>
|
||||
<tbody>
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section.key}>
|
||||
{section.group && <GroupRow group={section.group} />}
|
||||
{section.rows.map((entry) => (
|
||||
<EventRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ group }: { group: GroupReport }) {
|
||||
const isComplete = group.eventsRun === group.eventsPlanned;
|
||||
const measure = group.targetDuration === null ? 'scheduled' : 'target';
|
||||
const measureValue = group.targetDuration ?? group.scheduledDuration;
|
||||
|
||||
return (
|
||||
<tr className={style.groupRow}>
|
||||
<td colSpan={7}>
|
||||
<div className={style.groupHeading}>
|
||||
<span className={style.groupTitle}>
|
||||
<span className={style.groupColour} style={{ '--group-colour': group.colour } as React.CSSProperties} />
|
||||
{group.title || 'Untitled group'}
|
||||
</span>
|
||||
|
||||
<span className={style.groupStats}>
|
||||
<Tooltip
|
||||
text={
|
||||
group.targetDuration === null
|
||||
? 'No target set for this group, measured against what was scheduled into it'
|
||||
: 'The target duration set for this group'
|
||||
}
|
||||
render={<span />}
|
||||
className={style.stat}
|
||||
>
|
||||
<b>{formatDuration(measureValue, false)}</b> {measure}
|
||||
</Tooltip>
|
||||
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={style.stat}>
|
||||
<b>{group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}</b> actual
|
||||
</span>
|
||||
|
||||
{group.variance !== null && (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={cx([style.variance, style[offsetTone(group.variance)]])}>
|
||||
{formatOffset(group.variance)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{group.changeover !== null && group.changeover > 0 && (
|
||||
<>
|
||||
<span className={style.separator}>·</span>
|
||||
<Tooltip
|
||||
text='Time inside this group that no event was running, ie changeovers'
|
||||
render={<span />}
|
||||
className={style.stat}
|
||||
>
|
||||
<b>{formatDuration(group.changeover, false)}</b> changeover
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={style.separator}>·</span>
|
||||
<span className={cx([style.stat, !isComplete && style.incomplete])}>
|
||||
<b>
|
||||
{group.eventsRun}/{group.eventsPlanned}
|
||||
</b>{' '}
|
||||
{isComplete ? 'events' : 'events run'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ entry }: { entry: CombinedReport }) {
|
||||
const start = punctuality(entry.actualStart, entry.scheduledStart);
|
||||
const end = punctuality(entry.actualEnd, entry.scheduledEnd);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<th className={style.eventIndex}>{entry.index}</th>
|
||||
<th className={style.eventCue}>{entry.cue}</th>
|
||||
<th>{entry.title}</th>
|
||||
<th>{formatTime(entry.scheduledStart)}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
||||
<th>{formatTime(entry.scheduledEnd)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,18 @@
|
||||
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;
|
||||
/** 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,7 +20,12 @@ 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,
|
||||
@@ -31,43 +41,71 @@ 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,
|
||||
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[][] = [];
|
||||
@@ -76,12 +114,15 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||
for (const entry of combinedReport) {
|
||||
csv.push([
|
||||
String(entry.index),
|
||||
entry.title,
|
||||
entry.groupTitle,
|
||||
entry.cue,
|
||||
entry.title,
|
||||
formatTime(entry.scheduledStart),
|
||||
formatTime(entry.actualStart),
|
||||
// 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),
|
||||
formatTime(entry.actualEnd),
|
||||
csvTime(entry.actualEnd),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,176 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import type { PlayableEvent } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
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(() => {
|
||||
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('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,62 @@ 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;
|
||||
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,85 @@
|
||||
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.
|
||||
*
|
||||
* Follows Ontime's offset convention: positive means behind schedule.
|
||||
* `duringShow` separates a late start from a badly run show, which have
|
||||
* different causes and different remedies.
|
||||
*/
|
||||
export type ShowOffsets = {
|
||||
/** actual start against planned start */
|
||||
startOffset: MaybeNumber;
|
||||
/** actual end against planned end */
|
||||
endOffset: MaybeNumber;
|
||||
/** time lost (positive) or recovered (negative) between start and end */
|
||||
duringShow: 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 not covered by a running event, ie changeovers.
|
||||
* elapsed minus the sum of the events' own durations.
|
||||
*/
|
||||
changeover: MaybeNumber;
|
||||
/** measured against targetDuration where set, otherwise scheduledDuration */
|
||||
variance: MaybeNumber;
|
||||
eventsRun: number;
|
||||
eventsPlanned: number;
|
||||
};
|
||||
|
||||
/** Counts across the events in the report */
|
||||
export type RunSummary = {
|
||||
eventsRun: number;
|
||||
eventsPlanned: number;
|
||||
eventsOver: number;
|
||||
eventsUnder: number;
|
||||
eventsOnTime: number;
|
||||
/** largest single overrun, answers "what blew the schedule" */
|
||||
worstOverrun: { id: EntryId; delta: number } | null;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,277 @@
|
||||
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()', () => {
|
||||
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 * 60 * MIN,
|
||||
plannedEnd: 21 * 60 * MIN,
|
||||
actualStart: 19 * 60 * MIN + 8 * MIN,
|
||||
actualEnd: 21 * 60 * MIN + 8 * MIN,
|
||||
});
|
||||
|
||||
expect(offsets).toEqual({ startOffset: 8 * MIN, endOffset: 8 * MIN, duringShow: 0 });
|
||||
});
|
||||
|
||||
it('separates time lost during the show from a late start', () => {
|
||||
const offsets = getShowOffsets({
|
||||
plannedStart: 19 * 60 * MIN,
|
||||
plannedEnd: 21 * 60 * MIN,
|
||||
actualStart: 19 * 60 * MIN + 8 * MIN,
|
||||
actualEnd: 21 * 60 * MIN + 12 * MIN,
|
||||
});
|
||||
|
||||
expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, duringShow: 4 * MIN });
|
||||
});
|
||||
|
||||
it('reports time recovered as a negative', () => {
|
||||
const offsets = getShowOffsets({
|
||||
plannedStart: 19 * 60 * MIN,
|
||||
plannedEnd: 21 * 60 * MIN,
|
||||
actualStart: 19 * 60 * MIN + 10 * MIN,
|
||||
actualEnd: 21 * 60 * MIN + 2 * MIN,
|
||||
});
|
||||
|
||||
expect(offsets.duringShow).toBe(-8 * MIN);
|
||||
});
|
||||
|
||||
it('does not read a show ending after midnight as a day early', () => {
|
||||
const offsets = getShowOffsets({
|
||||
plannedStart: 23 * 60 * MIN,
|
||||
plannedEnd: dayInMs - 10 * MIN,
|
||||
actualStart: 23 * 60 * MIN,
|
||||
actualEnd: 5 * MIN, // ran past midnight
|
||||
});
|
||||
|
||||
expect(offsets.endOffset).toBe(15 * MIN);
|
||||
});
|
||||
|
||||
it('has nothing to report without a plan', () => {
|
||||
expect(getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 1, actualEnd: 2 })).toEqual({
|
||||
startOffset: null,
|
||||
endOffset: null,
|
||||
duringShow: 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 });
|
||||
});
|
||||
});
|
||||
|
||||
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 not running an event', () => {
|
||||
// 5 minute changeover between the two events
|
||||
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.changeover).toBe(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('reads a partly run block as incomplete rather than as an overrun', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('has no actuals for a block which never ran', () => {
|
||||
const [group] = getGroupReports({}, entries, ['act1']);
|
||||
|
||||
expect(group).toMatchObject({ elapsed: null, changeover: 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 events landed', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ endedAt: 15000 }), // over
|
||||
b: makeEntry({ endedAt: 6000 }), // under
|
||||
c: makeEntry({ endedAt: 10000 }), // on time
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 4)).toMatchObject({
|
||||
eventsRun: 3,
|
||||
eventsPlanned: 4,
|
||||
eventsOver: 1,
|
||||
eventsUnder: 1,
|
||||
eventsOnTime: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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('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,236 @@
|
||||
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.
|
||||
*/
|
||||
export function getShowOffsets(show: ShowReport): ShowOffsets {
|
||||
const startOffset = offsetBetween(show.plannedStart, show.actualStart);
|
||||
const endOffset = offsetBetween(show.plannedEnd, show.actualEnd);
|
||||
|
||||
return {
|
||||
startOffset,
|
||||
endOffset,
|
||||
duringShow: startOffset === null || endOffset === null ? null : endOffset - startOffset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'> {
|
||||
let actualStart: number | null = null;
|
||||
let actualEnd: number | null = null;
|
||||
|
||||
for (const entry of Object.values(report)) {
|
||||
if (entry.startedAt !== null && (actualStart === null || entry.startedAt < actualStart)) {
|
||||
actualStart = entry.startedAt;
|
||||
}
|
||||
if (entry.endedAt !== null && (actualEnd === null || entry.endedAt > actualEnd)) {
|
||||
actualEnd = entry.endedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return { actualStart, actualEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
let actualStart: number | null = null;
|
||||
let actualEnd: number | null = null;
|
||||
|
||||
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.status === 'not-run') continue;
|
||||
|
||||
eventsRun += 1;
|
||||
ranDuration += variance.actualDuration as number;
|
||||
|
||||
const started = reported.startedAt as number;
|
||||
const ended = reported.endedAt as number;
|
||||
if (actualStart === null || started < actualStart) actualStart = started;
|
||||
if (actualEnd === null || ended > actualEnd) actualEnd = ended;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
groups.push({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
colour: group.colour,
|
||||
targetDuration: group.targetDuration,
|
||||
scheduledDuration,
|
||||
actualStart,
|
||||
actualEnd,
|
||||
elapsed,
|
||||
// whatever the block spent not running an event
|
||||
changeover: elapsed === null ? null : Math.max(0, elapsed - ranDuration),
|
||||
variance: elapsed === null ? null : elapsed - measuredAgainst,
|
||||
eventsRun,
|
||||
eventsPlanned,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Counts across the events in the report */
|
||||
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
|
||||
const summary: RunSummary = {
|
||||
eventsRun: 0,
|
||||
eventsPlanned,
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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