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 CSS_OVERRIDE = ['cssOverride'];
|
||||||
export const CLIENT_LIST = ['clientList'];
|
export const CLIENT_LIST = ['clientList'];
|
||||||
export const REPORT = ['report'];
|
export const REPORT = ['report'];
|
||||||
|
export const REPORT_SHOW = ['report', 'show'];
|
||||||
export const TRANSLATION = ['translation'];
|
export const TRANSLATION = ['translation'];
|
||||||
|
|
||||||
// API URLs
|
// API URLs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { OntimeReport } from 'ontime-types';
|
import { OntimeReport, ShowReport } from 'ontime-types';
|
||||||
|
|
||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
import { REPORT, apiEntryUrl } from './constants';
|
import { REPORT, apiEntryUrl } from './constants';
|
||||||
@@ -15,6 +15,14 @@ export async function fetchReport(options?: RequestOptions): Promise<OntimeRepor
|
|||||||
return res.data;
|
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) {
|
export async function deleteReport(id: string) {
|
||||||
await axios.delete(`${reportUrl}/${id}`);
|
await axios.delete(`${reportUrl}/${id}`);
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
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 { useMemo } from 'react';
|
||||||
import { IoTrashBin } from 'react-icons/io5';
|
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
|
||||||
|
|
||||||
import { deleteAllReport } from '../../../../common/api/report';
|
import { deleteAllReport } from '../../../../common/api/report';
|
||||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import useReport from '../../../../common/hooks-query/useReport';
|
import useReport from '../../../../common/hooks-query/useReport';
|
||||||
import useRundown from '../../../../common/hooks-query/useRundown';
|
import useRundown from '../../../../common/hooks-query/useRundown';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import useShowReport from '../../../../common/hooks-query/useShowReport';
|
||||||
import { formatTime } from '../../../../common/utils/time';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
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 { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||||
|
|
||||||
import style from './ReportSettings.module.scss';
|
|
||||||
|
|
||||||
export default function ReportSettings() {
|
export default function ReportSettings() {
|
||||||
const { data: reportData } = useReport();
|
const { data: reportData } = useReport();
|
||||||
|
const { data: showReport } = useShowReport();
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
|
|
||||||
const clearReport = async () => await deleteAllReport();
|
const clearReport = async () => await deleteAllReport();
|
||||||
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||||
if (!combinedReport) {
|
if (combinedReport.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const csv = makeReportCSV(combinedReport);
|
const csv = makeReportCSV(combinedReport);
|
||||||
@@ -31,73 +32,49 @@ export default function ReportSettings() {
|
|||||||
return getCombinedReport(reportData, data.entries, data.flatOrder);
|
return getCombinedReport(reportData, data.entries, data.flatOrder);
|
||||||
}, [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 (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<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.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) => {
|
{!hasReport ? (
|
||||||
const start = (() => {
|
<Panel.Section>
|
||||||
if (entry.actualStart === null) return null;
|
<Panel.EmptyState
|
||||||
if (entry.actualStart <= entry.scheduledStart) return 'under';
|
title='No report yet'
|
||||||
return 'over';
|
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.'
|
||||||
})();
|
/>
|
||||||
const end = (() => {
|
</Panel.Section>
|
||||||
if (entry.actualEnd === null) return null;
|
) : (
|
||||||
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
|
<>
|
||||||
return 'over';
|
<ReportShowSummary show={showReport} summary={summary} />
|
||||||
})();
|
<Panel.Section>
|
||||||
return (
|
<ReportTable rows={combinedReport} groups={groups} />
|
||||||
<tr key={entry.id}>
|
</Panel.Section>
|
||||||
<th>{entry.index}</th>
|
</>
|
||||||
<th>{entry.cue}</th>
|
)}
|
||||||
<th>{entry.title}</th>
|
|
||||||
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
|
||||||
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
|
||||||
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
|
|
||||||
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</Panel.Table>
|
|
||||||
</Panel.Section>
|
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</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 { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
||||||
import { formatTime } from '../../../../common/utils/time';
|
import { enDash } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
|
|
||||||
export type CombinedReport = {
|
export type CombinedReport = {
|
||||||
id: EntryId;
|
id: EntryId;
|
||||||
index: number;
|
index: number;
|
||||||
title: string;
|
title: string;
|
||||||
cue: string;
|
cue: string;
|
||||||
|
/** the group this event belongs to, so the report can mirror the rundown */
|
||||||
|
parent: EntryId | null;
|
||||||
|
groupTitle: string;
|
||||||
scheduledStart: number;
|
scheduledStart: number;
|
||||||
actualStart: MaybeNumber;
|
actualStart: MaybeNumber;
|
||||||
scheduledEnd: number;
|
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(
|
export function getCombinedReport(
|
||||||
report: OntimeReport,
|
report: OntimeReport,
|
||||||
@@ -31,43 +41,71 @@ export function getCombinedReport(
|
|||||||
for (let i = 0; i < flatOrder.length; i++) {
|
for (let i = 0; i < flatOrder.length; i++) {
|
||||||
const id = flatOrder[i];
|
const id = flatOrder[i];
|
||||||
const entry = rundown[id];
|
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)) {
|
const parent = entry.parent;
|
||||||
combinedReport.push({
|
const group = parent ? rundown[parent] : undefined;
|
||||||
id: id,
|
const reported = report[id];
|
||||||
index: index,
|
|
||||||
title: entry.title,
|
|
||||||
cue: entry.cue,
|
|
||||||
scheduledStart: entry.timeStart,
|
|
||||||
actualEnd: null,
|
|
||||||
scheduledEnd: entry.timeEnd,
|
|
||||||
actualStart: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id in report) {
|
combinedReport.push({
|
||||||
combinedReport.push({
|
id,
|
||||||
id: id,
|
index,
|
||||||
index: index,
|
title: entry.title,
|
||||||
title: entry.title,
|
cue: entry.cue,
|
||||||
cue: entry.cue,
|
parent,
|
||||||
scheduledStart: entry.timeStart,
|
groupTitle: group && 'title' in group ? group.title : '',
|
||||||
actualEnd: report[id].endedAt,
|
// an event that ran is measured against the plan it ran on, one that
|
||||||
scheduledEnd: entry.timeEnd,
|
// did not has no snapshot and falls back to the rundown
|
||||||
actualStart: report[id].startedAt,
|
scheduledStart: reported?.scheduledStart ?? entry.timeStart,
|
||||||
});
|
scheduledEnd: reported ? reported.scheduledStart + reported.scheduledDuration : entry.timeEnd,
|
||||||
}
|
actualStart: reported?.startedAt ?? null,
|
||||||
|
actualEnd: reported?.endedAt ?? null,
|
||||||
|
});
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return combinedReport;
|
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[]) {
|
export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||||
const csv: string[][] = [];
|
const csv: string[][] = [];
|
||||||
@@ -76,12 +114,15 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
|
|||||||
for (const entry of combinedReport) {
|
for (const entry of combinedReport) {
|
||||||
csv.push([
|
csv.push([
|
||||||
String(entry.index),
|
String(entry.index),
|
||||||
entry.title,
|
entry.groupTitle,
|
||||||
entry.cue,
|
entry.cue,
|
||||||
|
entry.title,
|
||||||
formatTime(entry.scheduledStart),
|
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.scheduledEnd),
|
||||||
formatTime(entry.actualEnd),
|
csvTime(entry.actualEnd),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ function RundownEventInner({
|
|||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
isLoaded={loaded}
|
isLoaded={loaded}
|
||||||
totalGap={totalGap}
|
totalGap={totalGap}
|
||||||
duration={duration}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
|
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Day } from 'ontime-types';
|
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 { useMemo } from 'react';
|
||||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
|
|||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
duration: number;
|
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ export default function RundownEventChip({
|
|||||||
className,
|
className,
|
||||||
totalGap,
|
totalGap,
|
||||||
id,
|
id,
|
||||||
duration,
|
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
}: RundownEventChipProps) {
|
}: RundownEventChipProps) {
|
||||||
const playback = usePlayback();
|
const playback = usePlayback();
|
||||||
@@ -45,7 +43,7 @@ export default function RundownEventChip({
|
|||||||
const playbackActive = isPlaybackActive(playback);
|
const playbackActive = isPlaybackActive(playback);
|
||||||
|
|
||||||
if (!playbackActive || isPast) {
|
if (!playbackActive || isPast) {
|
||||||
return <EventReport className={className} id={id} duration={duration} />;
|
return <EventReport className={className} id={id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playbackActive) {
|
if (playbackActive) {
|
||||||
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
|||||||
interface EventReportProps {
|
interface EventReportProps {
|
||||||
className: string;
|
className: string;
|
||||||
id: string;
|
id: string;
|
||||||
duration: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventReport(props: EventReportProps) {
|
function EventReport(props: EventReportProps) {
|
||||||
const { className, id, duration } = props;
|
const { className, id } = props;
|
||||||
const { data } = useReport();
|
const { data } = useReport();
|
||||||
const currentReport = data[id];
|
const currentReport = data[id];
|
||||||
|
|
||||||
const [value, overUnderStyle, tooltip] = useMemo(() => {
|
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', ''];
|
return [null, 'none', ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { startedAt, endedAt } = currentReport;
|
if (variance.status === 'ontime') {
|
||||||
if (!startedAt || !endedAt) {
|
|
||||||
return [null, 'none', ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualDuration = endedAt - startedAt;
|
|
||||||
const difference = actualDuration - duration;
|
|
||||||
const absDifference = Math.abs(difference);
|
|
||||||
|
|
||||||
if (absDifference < MILLIS_PER_SECOND) {
|
|
||||||
return ['ontime', 'under', 'Event finished on time'];
|
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 fullTimeValue = millisToString(absDifference);
|
||||||
|
|
||||||
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
||||||
|
|
||||||
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
||||||
return [value, isOver ? 'over' : 'under', tooltip];
|
return [value, variance.status, tooltip];
|
||||||
}, [currentReport, duration]);
|
}, [currentReport]);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
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());
|
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) => {
|
router.delete('/all', (_req: Request, res: Response) => {
|
||||||
report.clear();
|
report.clear();
|
||||||
res.status(204).send();
|
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 { DeepReadonly } from 'ts-essentials';
|
||||||
|
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
@@ -8,6 +9,20 @@ const report = new Map<string, OntimeEventReport>();
|
|||||||
|
|
||||||
let formattedReport: OntimeReport | null = null;
|
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
|
* generates a full report
|
||||||
* @returns full report
|
* @returns full report
|
||||||
@@ -27,9 +42,14 @@ export function clear(id?: string) {
|
|||||||
formattedReport = null;
|
formattedReport = null;
|
||||||
if (id) {
|
if (id) {
|
||||||
report.delete(id);
|
report.delete(id);
|
||||||
} else {
|
return;
|
||||||
report.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
const eventId = state.eventNow.id;
|
||||||
|
|
||||||
if (cycle === TimerLifeCycle.onStart) {
|
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;
|
formattedReport = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cycle === TimerLifeCycle.onStop) {
|
if (cycle === TimerLifeCycle.onStop) {
|
||||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
const previous = report.get(eventId);
|
||||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
report.set(eventId, {
|
||||||
|
startedAt: previous?.startedAt ?? null,
|
||||||
|
endedAt: state.clock,
|
||||||
|
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||||
|
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
|
||||||
|
});
|
||||||
formattedReport = null;
|
formattedReport = null;
|
||||||
sendRefetch(RefetchKey.Report);
|
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 { MaybeNumber } from '../../utils/utils.type.js';
|
||||||
|
import type { EntryId } from './OntimeEntry.js';
|
||||||
|
|
||||||
export type OntimeEventReport = {
|
export type OntimeEventReport = {
|
||||||
startedAt: MaybeNumber;
|
startedAt: MaybeNumber;
|
||||||
endedAt: MaybeNumber;
|
endedAt: MaybeNumber;
|
||||||
|
/**
|
||||||
|
* Snapshot of the schedule taken when the event ran.
|
||||||
|
* Keeping a copy is what makes a report a record: editing the rundown
|
||||||
|
* afterwards no longer 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';
|
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
|
||||||
|
|
||||||
// ---> Report
|
// ---> Report
|
||||||
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
export type {
|
||||||
|
GroupReport,
|
||||||
|
OntimeReport,
|
||||||
|
OntimeEventReport,
|
||||||
|
RunSummary,
|
||||||
|
ShowOffsets,
|
||||||
|
ShowReport,
|
||||||
|
} from './definitions/core/Report.type.js';
|
||||||
|
|
||||||
// ---> Automations
|
// ---> Automations
|
||||||
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ export {
|
|||||||
|
|
||||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
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
|
//Colour
|
||||||
export {
|
export {
|
||||||
colourToHex,
|
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