mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-29 02:49:13 +00:00
refactor(report): improve 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) => 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>
|
||||
);
|
||||
|
||||
+194
@@ -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');
|
||||
});
|
||||
});
|
||||
+138
@@ -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;
|
||||
}
|
||||
+131
@@ -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);
|
||||
}
|
||||
+160
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user