mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-26 17:39:58 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2bbb10ad | |||
| 255695bc4a | |||
| ef472b513a | |||
| d2f2cc0bff | |||
| c91b6c020e |
Binary file not shown.
@@ -20,6 +20,7 @@ export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CSS_OVERRIDE = ['cssOverride'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
export const REPORT_SHOW = ['report', 'show'];
|
||||
export const TRANSLATION = ['translation'];
|
||||
|
||||
// API URLs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { OntimeReport } from 'ontime-types';
|
||||
import { OntimeReport, ShowReport } from 'ontime-types';
|
||||
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
import { REPORT, apiEntryUrl } from './constants';
|
||||
@@ -15,6 +15,14 @@ export async function fetchReport(options?: RequestOptions): Promise<OntimeRepor
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch the show level times for the report
|
||||
*/
|
||||
export async function fetchShowReport(options?: RequestOptions): Promise<ShowReport> {
|
||||
const res = await axios.get(`${reportUrl}/show`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteReport(id: string) {
|
||||
await axios.delete(`${reportUrl}/${id}`);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ShowReport } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { REPORT_SHOW } from '../api/constants';
|
||||
import { fetchShowReport } from '../api/report';
|
||||
|
||||
const emptyShowReport: ShowReport = {
|
||||
plannedStart: null,
|
||||
plannedEnd: null,
|
||||
actualStart: null,
|
||||
actualEnd: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Show level times for the report.
|
||||
* Invalidated by the same websocket signal as the report itself, since the
|
||||
* two are read together.
|
||||
*/
|
||||
export default function useShowReport() {
|
||||
const { data } = useQuery<ShowReport>({
|
||||
queryKey: REPORT_SHOW,
|
||||
queryFn: ({ signal }) => fetchShowReport({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
return { data: data ?? emptyShowReport };
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
th.over {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
th.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
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 ReportShowSummary from './composite/ReportShowSummary';
|
||||
import ReportTable from './composite/ReportTable';
|
||||
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
|
||||
import style from './ReportSettings.module.scss';
|
||||
|
||||
export default function ReportSettings() {
|
||||
const { data: reportData } = useReport();
|
||||
const { data: showReport } = useShowReport();
|
||||
const { data } = useRundown();
|
||||
|
||||
const clearReport = async () => await deleteAllReport();
|
||||
const downloadCSV = (combinedReport: CombinedReport[]) => {
|
||||
if (!combinedReport) {
|
||||
if (combinedReport.length === 0) {
|
||||
return;
|
||||
}
|
||||
const csv = makeReportCSV(combinedReport);
|
||||
@@ -31,73 +33,63 @@ export default function ReportSettings() {
|
||||
return getCombinedReport(reportData, data.entries, data.flatOrder);
|
||||
}, [reportData, data.entries, data.flatOrder]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder));
|
||||
}, [reportData, data.entries, data.flatOrder]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return getGroupReports(reportData, data.entries, data.order);
|
||||
}, [reportData, data.entries, data.order]);
|
||||
|
||||
// the summary knows which event ran longest over, the rundown knows its name
|
||||
const worstOverrunTitle = useMemo(() => {
|
||||
if (summary.worstOverrun === null) return null;
|
||||
const entry = data.entries[summary.worstOverrun.id];
|
||||
return entry && isOntimeEvent(entry) ? entry.title : null;
|
||||
}, [summary.worstOverrun, data.entries]);
|
||||
|
||||
const hasReport = combinedReport.length > 0;
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Report</Panel.SubHeader>
|
||||
<Panel.SubHeader>
|
||||
Report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} disabled={!hasReport}>
|
||||
<IoDownloadOutline />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={clearReport} disabled={!hasReport}>
|
||||
<IoTrashBin />
|
||||
Clear All
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Title>
|
||||
Manage report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
Clear All
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Cue</th>
|
||||
<th>Title</th>
|
||||
<th>Scheduled Start</th>
|
||||
<th>Actual Start</th>
|
||||
<th>Scheduled End</th>
|
||||
<th>Actual End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinedReport.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No report data yet'
|
||||
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{combinedReport.map((entry) => {
|
||||
const start = (() => {
|
||||
if (entry.actualStart === null) return null;
|
||||
if (entry.actualStart <= entry.scheduledStart) return 'under';
|
||||
return 'over';
|
||||
})();
|
||||
const end = (() => {
|
||||
if (entry.actualEnd === null) return null;
|
||||
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
|
||||
return 'over';
|
||||
})();
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<th>{entry.index}</th>
|
||||
<th>{entry.cue}</th>
|
||||
<th>{entry.title}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
|
||||
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
|
||||
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
{!hasReport ? (
|
||||
<Panel.Section>
|
||||
<Panel.EmptyState
|
||||
title='No report yet'
|
||||
description='Ontime records what actually happened as you run the show, and compares it against the schedule as it stood at the time. Start an event and this fills in.'
|
||||
/>
|
||||
</Panel.Section>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
.overrun {
|
||||
color: $playback-over;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types';
|
||||
import { getShowOffsets } from 'ontime-utils';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../../../common/utils/time';
|
||||
import ReportSummaryCard, { FooterItem, Metric } from './ReportSummaryCard';
|
||||
|
||||
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}>
|
||||
<ReportSummaryCard
|
||||
title={rundownTitle || 'Untitled rundown'}
|
||||
headlineLabel={didReachEnd ? 'Against planned duration' : 'Show incomplete'}
|
||||
headline={didReachEnd ? offsets.durationOffset : null}
|
||||
note={
|
||||
didReachEnd
|
||||
? undefined
|
||||
: 'The show did not reach the end of the rundown, so there is nothing to measure it against.'
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<FooterItem>
|
||||
<b>
|
||||
{summary.eventsRun} of {summary.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</FooterItem>
|
||||
{summary.worstOverrun !== null && (
|
||||
<FooterItem>
|
||||
longest overrun <b>{worstOverrunTitle || 'an event'}</b>{' '}
|
||||
<span className={style.overrun}>+{formatDuration(summary.worstOverrun.delta, false)}</span>
|
||||
</FooterItem>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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 && (
|
||||
// no offset: the headline is this row's offset, and repeating it here
|
||||
// would read as a second, different figure
|
||||
<Metric
|
||||
label='Duration'
|
||||
planned={formatMaybeDuration(offsets.plannedDuration)}
|
||||
actual={formatMaybeDuration(offsets.actualDuration)}
|
||||
/>
|
||||
)}
|
||||
</ReportSummaryCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMaybeTime(value: MaybeNumber): string {
|
||||
return value === null ? enDash : formatTime(value);
|
||||
}
|
||||
|
||||
function formatMaybeDuration(value: MaybeNumber): string {
|
||||
return value === null ? enDash : formatDuration(value, false);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// an inset surface within the panel card, matching how Info nests inside one.
|
||||
// The surface is a variable so a tier with a colour of its own can wash it
|
||||
// without having to out-specify this rule
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
background-color: var(--card-surface, #{$gray-1200});
|
||||
border-radius: 3px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// a subordinate tier carries the same shape at less weight, so a group heading
|
||||
// reads as belonging to the show summary rather than competing with it
|
||||
.compact {
|
||||
padding: 0.875rem 1rem;
|
||||
gap: 0.625rem;
|
||||
|
||||
// at this weight the conclusion fits beside its label rather than under it,
|
||||
// keeping a group heading to two lines however many of them the table has
|
||||
.headline {
|
||||
flex-flow: row wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
// the conclusion sits beside its label, but a reason why there is none
|
||||
// needs the full line rather than pushing the workings off to the right
|
||||
.note {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.headlineValue {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-top: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $gray-500;
|
||||
font-size: calc(1rem - 2px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.body {
|
||||
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 card
|
||||
.headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headlineLabel {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.headlineValue {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
max-width: 32rem;
|
||||
color: $gray-300;
|
||||
font-size: calc(1rem - 2px);
|
||||
line-height: 1.4;
|
||||
|
||||
// nothing to conclude yet is a caveat on the report, not a detail
|
||||
&.unavailable {
|
||||
color: $warning-orange;
|
||||
}
|
||||
}
|
||||
|
||||
// label | planned -> actual | offset, so every offset lands in one column
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem 1.5rem;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
color: $gray-300;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
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;
|
||||
}
|
||||
|
||||
.metricOffset {
|
||||
justify-self: end;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.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;
|
||||
|
||||
// a dot between neighbours, rather than a separator element per gap
|
||||
& + &::before {
|
||||
content: "·";
|
||||
margin-right: 0.75rem;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
b {
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { formatOffset, offsetTone } from '../reportSettings.utils';
|
||||
|
||||
import style from './ReportSummaryCard.module.scss';
|
||||
|
||||
interface ReportSummaryCardProps {
|
||||
/** what is being summarised: a rundown name, a group title */
|
||||
title: ReactNode;
|
||||
/** optional context for the title, eg the window the group ran in */
|
||||
subtitle?: ReactNode;
|
||||
/** what the headline figure measures, eg "Total plan deviation" */
|
||||
headlineLabel: string;
|
||||
/** the conclusion. Null renders the note instead, for a run with nothing to conclude */
|
||||
headline: MaybeNumber;
|
||||
/** shown under the headline: the workings when there is a figure, the reason when there is not */
|
||||
note?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** a subordinate tier, eg a group inside the show it belongs to */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One shape for every tier of the report.
|
||||
*
|
||||
* A group header is a summary of the events inside it exactly as the show
|
||||
* summary is a summary of the groups, so both are read the same way: the
|
||||
* conclusion on the left at full weight, the workings behind it on the right,
|
||||
* and how much of it ran along the bottom.
|
||||
*/
|
||||
export default function ReportSummaryCard({
|
||||
title,
|
||||
subtitle,
|
||||
headlineLabel,
|
||||
headline,
|
||||
note,
|
||||
footer,
|
||||
compact,
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<ReportSummaryCardProps>) {
|
||||
return (
|
||||
<div className={cx([style.card, compact && style.compact, className])}>
|
||||
<div className={style.identity}>
|
||||
<span className={style.title}>{title}</span>
|
||||
{subtitle && <span className={style.subtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className={style.body}>
|
||||
<div className={style.headline}>
|
||||
<span className={style.headlineLabel}>{headlineLabel}</span>
|
||||
{headline !== null && (
|
||||
<span className={cx([style.headlineValue, style[offsetTone(headline)]])}>{formatOffset(headline)}</span>
|
||||
)}
|
||||
{note && <span className={cx([style.note, headline === null && style.unavailable])}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div className={style.metrics}>{children}</div>
|
||||
</div>
|
||||
|
||||
{footer && <div className={style.footer}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
/** the value it was measured against, omitted for a figure which stands alone */
|
||||
planned?: string;
|
||||
/** what actually happened */
|
||||
actual: string;
|
||||
offset?: MaybeNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the workings: what was planned, what happened, and the distance
|
||||
* between them. Every offset lands in the same column so they read as a stack.
|
||||
*/
|
||||
export function Metric({ label, planned, actual, offset }: MetricProps) {
|
||||
return (
|
||||
<>
|
||||
<span className={style.metricLabel}>{label}</span>
|
||||
<span className={style.metricValue}>
|
||||
{planned !== undefined && (
|
||||
<>
|
||||
<span className={style.planned}>{planned}</span>
|
||||
<span className={style.arrow}>→</span>
|
||||
</>
|
||||
)}
|
||||
<span className={style.actual}>{actual}</span>
|
||||
</span>
|
||||
<span className={cx([style.metricOffset, offset !== undefined && style[offsetTone(offset)]])}>
|
||||
{offset === undefined ? '' : formatOffset(offset)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact in the card footer, separated from its neighbours by a dot */
|
||||
export function FooterItem({ children }: PropsWithChildren) {
|
||||
return <span className={style.footerItem}>{children}</span>;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Rows follow the treatment the cuesheet and the sheet import preview already
|
||||
// use for entries: a wash of the entry's own colour over the row and a rail of
|
||||
// it down the left, deepening for the group that owns them. --entry-colour is
|
||||
// the variable both of those read.
|
||||
//
|
||||
// The two fall back differently on purpose: a group the user gave no colour
|
||||
// still needs a visible edge to be read as a group, but nothing to wash with.
|
||||
$rail: var(--entry-colour, #{$gray-500});
|
||||
$wash: var(--entry-colour, 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;
|
||||
}
|
||||
|
||||
// the surface sits on the cells rather than the row so it paints over the
|
||||
// table's own striping without having to out-specify it
|
||||
.eventRow td {
|
||||
background-color: color-mix(in srgb, #{$gray-1300} 96%, #{$wash} 4%);
|
||||
}
|
||||
|
||||
.groupedRow td:first-child {
|
||||
box-shadow: inset 3px 0 $rail;
|
||||
// clear the rail rather than sitting against it
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
// the group heading is a card rather than a row treatment, so the cell only has
|
||||
// to give it room. The space above it is what separates one group from the last
|
||||
.groupRow {
|
||||
background-color: transparent;
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
border-top: 1.25rem solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.groupCard {
|
||||
--card-surface: color-mix(in srgb, #{$gray-1300} 88%, #{$rail} 12%);
|
||||
|
||||
box-shadow: inset 4px 0 $rail;
|
||||
// the card sits directly on top of its own event rows with no gap between
|
||||
// them, so the bottom corners stay square: rounding them curved the rail
|
||||
// away right where it needs to run straight into the row below
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.eventCue,
|
||||
.eventIndex {
|
||||
color: $gray-300;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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 } from '../reportSettings.utils';
|
||||
import ReportSummaryCard, { FooterItem, Metric } from './ReportSummaryCard';
|
||||
|
||||
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} colour={section.group?.colour} />
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
return (
|
||||
<tr className={style.groupRow} style={entryColour(group.colour)}>
|
||||
<td colSpan={7}>
|
||||
<ReportSummaryCard
|
||||
className={style.groupCard}
|
||||
title={group.title || 'Untitled group'}
|
||||
subtitle={
|
||||
group.actualStart !== null && group.actualEnd !== null
|
||||
? `ran ${formatTime(group.actualStart)} to ${formatTime(group.actualEnd)}`
|
||||
: undefined
|
||||
}
|
||||
compact
|
||||
headlineLabel={hasTarget ? 'Against target' : 'Against schedule'}
|
||||
headline={group.variance}
|
||||
// terse, because the footer already carries how much of the group ran
|
||||
note={group.variance !== null ? undefined : isComplete ? 'Did not run' : 'Still running'}
|
||||
footer={
|
||||
<>
|
||||
<FooterItem>
|
||||
<b>
|
||||
{group.eventsRun} of {group.eventsPlanned}
|
||||
</b>{' '}
|
||||
events run
|
||||
</FooterItem>
|
||||
{group.untimed !== null && group.untimed > 0 && (
|
||||
<FooterItem>
|
||||
<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>
|
||||
</FooterItem>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* the headline already carries the variance, so this is the workings behind it */}
|
||||
<Metric
|
||||
label={hasTarget ? 'Target' : 'Scheduled'}
|
||||
planned={formatDuration(measuredAgainst, false)}
|
||||
actual={group.elapsed === null ? enDash : formatDuration(group.elapsed, false)}
|
||||
/>
|
||||
</ReportSummaryCard>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ entry, colour }: { entry: CombinedReport; colour?: string }) {
|
||||
const start = punctuality(entry.actualStart, entry.scheduledStart);
|
||||
const end = punctuality(entry.actualEnd, entry.scheduledEnd);
|
||||
const grouped = colour !== undefined;
|
||||
|
||||
return (
|
||||
<tr className={cx([style.eventRow, grouped && style.groupedRow])} style={grouped ? entryColour(colour) : undefined}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry's own colour, read by the row styling the same way the cuesheet and
|
||||
* the import preview read it. Left unset when the user gave the group none, so
|
||||
* the stylesheet falls back to a neutral edge.
|
||||
*/
|
||||
function entryColour(colour?: string): React.CSSProperties {
|
||||
return { '--entry-colour': colour || 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,18 @@
|
||||
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
import { enDash } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
|
||||
export type CombinedReport = {
|
||||
id: EntryId;
|
||||
index: number;
|
||||
title: string;
|
||||
cue: string;
|
||||
/** the group this event belongs to, so the report can mirror the rundown */
|
||||
parent: EntryId | null;
|
||||
groupTitle: string;
|
||||
scheduledStart: number;
|
||||
actualStart: MaybeNumber;
|
||||
scheduledEnd: number;
|
||||
@@ -15,7 +20,12 @@ export type CombinedReport = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a combined report with the rundown data
|
||||
* Creates a combined report with the rundown data.
|
||||
*
|
||||
* Events that ran are measured against the schedule recorded at the time,
|
||||
* not the rundown's current values, so editing the rundown afterwards does
|
||||
* not change how a show that already happened is reported. Events that never
|
||||
* ran have no snapshot and fall back to the rundown.
|
||||
*/
|
||||
export function getCombinedReport(
|
||||
report: OntimeReport,
|
||||
@@ -31,43 +41,62 @@ export function getCombinedReport(
|
||||
for (let i = 0; i < flatOrder.length; i++) {
|
||||
const id = flatOrder[i];
|
||||
const entry = rundown[id];
|
||||
if (!entry || !isOntimeEvent(entry)) continue;
|
||||
// skipped events were never meant to run, listing them alongside events
|
||||
// that did would also disagree with the summary, which excludes them
|
||||
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
|
||||
|
||||
if (!(id in report)) {
|
||||
combinedReport.push({
|
||||
id: id,
|
||||
index: index,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
scheduledStart: entry.timeStart,
|
||||
actualEnd: null,
|
||||
scheduledEnd: entry.timeEnd,
|
||||
actualStart: null,
|
||||
});
|
||||
}
|
||||
const parent = entry.parent;
|
||||
const group = parent ? rundown[parent] : undefined;
|
||||
const reported = report[id];
|
||||
|
||||
if (id in report) {
|
||||
combinedReport.push({
|
||||
id: id,
|
||||
index: index,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
scheduledStart: entry.timeStart,
|
||||
actualEnd: report[id].endedAt,
|
||||
scheduledEnd: entry.timeEnd,
|
||||
actualStart: report[id].startedAt,
|
||||
});
|
||||
}
|
||||
combinedReport.push({
|
||||
id,
|
||||
index,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
parent,
|
||||
groupTitle: group && 'title' in group ? group.title : '',
|
||||
// an event that ran is measured against the plan it ran on, one that
|
||||
// did not has no snapshot and falls back to the rundown
|
||||
scheduledStart: reported?.scheduledStart ?? entry.timeStart,
|
||||
scheduledEnd: reported ? reported.scheduledStart + reported.scheduledDuration : entry.timeEnd,
|
||||
actualStart: reported?.startedAt ?? null,
|
||||
actualEnd: reported?.endedAt ?? null,
|
||||
});
|
||||
index++;
|
||||
}
|
||||
|
||||
return combinedReport;
|
||||
}
|
||||
|
||||
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||
/**
|
||||
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that
|
||||
* positive means behind schedule.
|
||||
*/
|
||||
export function formatOffset(value: MaybeNumber): string {
|
||||
if (value === null) return enDash;
|
||||
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
|
||||
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
|
||||
}
|
||||
|
||||
/** Whether an offset is behind, ahead, or neither, for colouring */
|
||||
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
|
||||
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
|
||||
return value > 0 ? 'over' : 'under';
|
||||
}
|
||||
|
||||
/** @private */
|
||||
function csvTime(value: MaybeNumber): string {
|
||||
return value === null ? '' : formatTime(value);
|
||||
}
|
||||
|
||||
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||
|
||||
/**
|
||||
* Transforms a CombinedReport into a CSV string
|
||||
* Transforms a CombinedReport into a CSV string.
|
||||
*
|
||||
* Exported as one row per event with its group named, rather than with
|
||||
* rollups baked in, so it stays the dataset a report is built from.
|
||||
*/
|
||||
export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||
const csv: string[][] = [];
|
||||
@@ -76,12 +105,15 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
|
||||
for (const entry of combinedReport) {
|
||||
csv.push([
|
||||
String(entry.index),
|
||||
entry.title,
|
||||
entry.groupTitle,
|
||||
entry.cue,
|
||||
entry.title,
|
||||
formatTime(entry.scheduledStart),
|
||||
formatTime(entry.actualStart),
|
||||
// an event that never ran leaves the cell empty rather than a
|
||||
// placeholder, so a spreadsheet reads it as missing
|
||||
csvTime(entry.actualStart),
|
||||
formatTime(entry.scheduledEnd),
|
||||
formatTime(entry.actualEnd),
|
||||
csvTime(entry.actualEnd),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ function RundownEventInner({
|
||||
isPast={isPast}
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Day } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
import { useMemo } from 'react';
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
|
||||
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
totalGap: number;
|
||||
duration: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@ export default function RundownEventChip({
|
||||
className,
|
||||
totalGap,
|
||||
id,
|
||||
duration,
|
||||
isLinkedToLoaded,
|
||||
}: RundownEventChipProps) {
|
||||
const playback = usePlayback();
|
||||
@@ -45,7 +43,7 @@ export default function RundownEventChip({
|
||||
const playbackActive = isPlaybackActive(playback);
|
||||
|
||||
if (!playbackActive || isPast) {
|
||||
return <EventReport className={className} id={id} duration={duration} />;
|
||||
return <EventReport className={className} id={id} />;
|
||||
}
|
||||
|
||||
if (playbackActive) {
|
||||
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
interface EventReportProps {
|
||||
className: string;
|
||||
id: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function EventReport(props: EventReportProps) {
|
||||
const { className, id, duration } = props;
|
||||
const { className, id } = props;
|
||||
const { data } = useReport();
|
||||
const currentReport = data[id];
|
||||
|
||||
const [value, overUnderStyle, tooltip] = useMemo(() => {
|
||||
if (!currentReport) {
|
||||
// measured against the schedule recorded when the event ran, so this
|
||||
// agrees with the report panel and survives later rundown edits
|
||||
const variance = getEventVariance(currentReport);
|
||||
if (variance.status === 'not-run') {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const { startedAt, endedAt } = currentReport;
|
||||
if (!startedAt || !endedAt) {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const actualDuration = endedAt - startedAt;
|
||||
const difference = actualDuration - duration;
|
||||
const absDifference = Math.abs(difference);
|
||||
|
||||
if (absDifference < MILLIS_PER_SECOND) {
|
||||
if (variance.status === 'ontime') {
|
||||
return ['ontime', 'under', 'Event finished on time'];
|
||||
}
|
||||
|
||||
const isOver = difference > 0;
|
||||
|
||||
const absDifference = Math.abs(variance.delta);
|
||||
const isOver = variance.status === 'over';
|
||||
const fullTimeValue = millisToString(absDifference);
|
||||
|
||||
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
||||
|
||||
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
||||
return [value, isOver ? 'over' : 'under', tooltip];
|
||||
}, [currentReport, duration]);
|
||||
return [value, variance.status, tooltip];
|
||||
}, [currentReport]);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
|
||||
@@ -195,25 +195,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =================== SOUND PROMPT ===================*/
|
||||
.sound-prompt {
|
||||
position: absolute;
|
||||
bottom: $view-block-padding;
|
||||
left: $view-inline-padding;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: $element-border-radius;
|
||||
background-color: $viewer-card-bg-color;
|
||||
color: $viewer-secondary-color;
|
||||
font-size: $timer-label-size;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
transition: opacity $viewer-transition-time;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== LOGO ===================*/
|
||||
.logo {
|
||||
position: absolute;
|
||||
|
||||
@@ -8,7 +8,6 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
import { useTimerSound } from './useTimerSound';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
@@ -68,7 +66,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
endSound,
|
||||
font,
|
||||
keyColour,
|
||||
timerColour,
|
||||
@@ -78,8 +75,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const { showPrompt } = useTimerSound(time.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -161,8 +156,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
||||
|
||||
{showPrompt && <EnableSoundPrompt />}
|
||||
|
||||
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||
|
||||
{!hideMessage && (
|
||||
@@ -234,19 +227,3 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges the user to interact with the screen so that the browser allows audio playback
|
||||
* Any interaction arms the sound, so this is a hint rather than a control
|
||||
* It is tied to mouse movement since that does not itself grant playback permission,
|
||||
* which keeps the hint off screen unless somebody is at the machine to act on it
|
||||
*/
|
||||
function EnableSoundPrompt() {
|
||||
const isUserActive = useFadeOutOnInactivity(true);
|
||||
|
||||
return (
|
||||
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
|
||||
Tap the screen to enable sound
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
|
||||
import { shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
'sounds when a running timer goes into overtime from %s',
|
||||
(previousPhase) => {
|
||||
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
|
||||
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent for a roll timer waiting to start', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('sounds once, not on every update while in overtime', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent on phases which are not the end of the timer', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -76,14 +76,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'endSound',
|
||||
title: 'Play sound on timer end',
|
||||
description:
|
||||
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -201,7 +193,6 @@ type TimerOptions = {
|
||||
freezeOvertime: boolean;
|
||||
freezeMessage: string;
|
||||
hidePhase: boolean;
|
||||
endSound: boolean;
|
||||
font?: string;
|
||||
keyColour?: string;
|
||||
timerColour?: string;
|
||||
@@ -236,7 +227,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
||||
freezeMessage: getValue('freezeMessage') ?? '',
|
||||
hidePhase: isStringBoolean(getValue('hidePhase')),
|
||||
endSound: isStringBoolean(getValue('endSound')),
|
||||
|
||||
font: getValue('font') ?? undefined,
|
||||
keyColour: makeColourString(getValue('keyColour')),
|
||||
|
||||
@@ -189,18 +189,3 @@ export function getCardData(
|
||||
nextSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the end of timer sound should play for a given phase transition
|
||||
* We only sound the transition into overtime from a phase that was already counting,
|
||||
* which keeps a client that connects or reloads mid-overtime silent
|
||||
*/
|
||||
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
|
||||
if (phase !== TimerPhase.Overtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import buzzer from '../../assets/sounds/buzzer.mp3';
|
||||
import { shouldPlayEndSound } from './timer.utils';
|
||||
|
||||
/**
|
||||
* Plays a sound when the timer reaches its end
|
||||
*
|
||||
* Browsers reject playback until the document has been interacted with, and that permission
|
||||
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
|
||||
* audio element on the first interaction and let the view prompt for one if it never comes.
|
||||
* Safari grants the permission per element, so priming has to call play() on this element from
|
||||
* inside the event handler, it is not enough to know that an interaction happened.
|
||||
*/
|
||||
export function useTimerSound(phase: TimerPhase, enabled: boolean): { showPrompt: boolean } {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previousPhaseRef = useRef<TimerPhase | null>(null);
|
||||
const [isArmed, setIsArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(buzzer);
|
||||
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
setIsArmed(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isArmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const prime = () => {
|
||||
audioRef.current
|
||||
?.play()
|
||||
.then(() => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setIsArmed(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// playback is still blocked, a later interaction will try again
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
|
||||
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, isArmed]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousPhase = previousPhaseRef.current;
|
||||
previousPhaseRef.current = phase;
|
||||
|
||||
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current?.play().catch(() => {
|
||||
// the screen has not been interacted with, the view shows a prompt for it
|
||||
});
|
||||
}, [enabled, phase]);
|
||||
|
||||
return { showPrompt: enabled && !isArmed };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import type { PlayableEvent } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { clear, generate, generateShowReport, triggerReportEntry } from '../report.service.js';
|
||||
|
||||
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
|
||||
sendRefetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
|
||||
const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
describe('triggerReportEntry()', () => {
|
||||
it('snapshots the schedule when an event starts', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generate()[eventA.id]).toEqual({
|
||||
startedAt: 500,
|
||||
endedAt: null,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the snapshot taken at start when the event stops', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: 0,
|
||||
endedAt: 12000,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the schedule as it was, not as it later becomes', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
// the event is edited to a different duration, then stopped
|
||||
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
|
||||
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
|
||||
});
|
||||
|
||||
it('falls back to the current event when a stop arrives with no start', () => {
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: null,
|
||||
endedAt: 10000,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a fresh report when a new show begins', () => {
|
||||
// a rehearsal ran earlier and left its numbers behind
|
||||
const rehearsal = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, rehearsal);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...rehearsal, clock: 20000 } as typeof rehearsal);
|
||||
expect(generate()[eventA.id].endedAt).toBe(20000);
|
||||
|
||||
// the performance is a different show and must not inherit them
|
||||
const show = makeRuntimeStateData({
|
||||
eventNow: eventB,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 9999,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, show);
|
||||
|
||||
expect(generate()[eventA.id]).toBeUndefined();
|
||||
expect(generate()[eventB.id]).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps accumulating within the same show', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: 1000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
|
||||
|
||||
const next = makeRuntimeStateData({
|
||||
eventNow: eventB,
|
||||
timer: { startedAt: 10000 },
|
||||
clock: 10000,
|
||||
_startEpoch: 1000,
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, next);
|
||||
|
||||
// same show, so the earlier event is still part of the report
|
||||
expect(Object.keys(generate())).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores events without an id', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: null });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateShowReport()', () => {
|
||||
it('captures the plan the show was measured against', () => {
|
||||
const state = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generateShowReport()).toMatchObject({ plannedStart: 68400000, plannedEnd: 75600000 });
|
||||
});
|
||||
|
||||
it('keeps the plan captured at start when the rundown is edited later', () => {
|
||||
const start = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 0 },
|
||||
clock: 0,
|
||||
_startEpoch: 1000,
|
||||
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
// the rundown is reworked mid show, the plan it started against stands
|
||||
const edited = { ...start, rundown: { ...start.rundown, plannedEnd: 99999999 } } as typeof start;
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...edited, clock: 10000 } as typeof start);
|
||||
|
||||
expect(generateShowReport().plannedEnd).toBe(75600000);
|
||||
});
|
||||
|
||||
it('derives actual times from the events that ran', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500, _startEpoch: 1 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 12000 } as typeof state);
|
||||
|
||||
expect(generateShowReport()).toMatchObject({ actualStart: 500, actualEnd: 12000 });
|
||||
});
|
||||
|
||||
it('has no times before anything runs', () => {
|
||||
expect(generateShowReport()).toEqual({
|
||||
plannedStart: null,
|
||||
plannedEnd: null,
|
||||
actualStart: null,
|
||||
actualEnd: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('clears a single event', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
clear(eventA.id);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generate());
|
||||
});
|
||||
|
||||
/**
|
||||
* Show level times, kept separate so the report payload stays as it was
|
||||
* for the integrations that already read it.
|
||||
*/
|
||||
router.get('/show', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generateShowReport());
|
||||
});
|
||||
|
||||
router.delete('/all', (_req: Request, res: Response) => {
|
||||
report.clear();
|
||||
res.status(204).send();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, ShowReport, TimerLifeCycle } from 'ontime-types';
|
||||
import { getActualShowTimes } from 'ontime-utils';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
@@ -8,6 +9,20 @@ const report = new Map<string, OntimeEventReport>();
|
||||
|
||||
let formattedReport: OntimeReport | null = null;
|
||||
|
||||
/**
|
||||
* Identifies the show the current report belongs to.
|
||||
* The report describes one run, so starting a new show begins a fresh one
|
||||
* rather than mixing a rehearsal into the numbers for the performance.
|
||||
*/
|
||||
let currentShowStart: number | null = null;
|
||||
|
||||
/**
|
||||
* The plan the show was measured against, taken when it starts.
|
||||
* Snapshotted for the same reason the per event schedule is: editing the
|
||||
* rundown afterwards must not move the target a past show was judged by.
|
||||
*/
|
||||
let plannedTimes: Pick<ShowReport, 'plannedStart' | 'plannedEnd'> = { plannedStart: null, plannedEnd: null };
|
||||
|
||||
/**
|
||||
* generates a full report
|
||||
* @returns full report
|
||||
@@ -27,9 +42,14 @@ export function clear(id?: string) {
|
||||
formattedReport = null;
|
||||
if (id) {
|
||||
report.delete(id);
|
||||
} else {
|
||||
report.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// clearing everything also forgets which show the report described, so the
|
||||
// next event starts a report rather than resuming the one just discarded
|
||||
report.clear();
|
||||
currentShowStart = null;
|
||||
plannedTimes = { plannedStart: null, plannedEnd: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,15 +69,62 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
startShowIfNew(state);
|
||||
|
||||
report.set(eventId, {
|
||||
startedAt: state.timer.startedAt,
|
||||
endedAt: null,
|
||||
// snapshot the schedule so later rundown edits cannot change how a show
|
||||
// that already happened is reported
|
||||
scheduledStart: state.eventNow.timeStart,
|
||||
scheduledDuration: state.eventNow.duration,
|
||||
});
|
||||
formattedReport = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
||||
const previous = report.get(eventId);
|
||||
report.set(eventId, {
|
||||
startedAt: previous?.startedAt ?? null,
|
||||
endedAt: state.clock,
|
||||
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
|
||||
});
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the report when a new show begins.
|
||||
*
|
||||
* The runtime stamps a show when its first event starts, so a change of stamp
|
||||
* means the previous report described a different run. Without this the report
|
||||
* would accumulate across rehearsals and performances with no way to tell
|
||||
* which numbers belonged to which.
|
||||
* @private
|
||||
*/
|
||||
function startShowIfNew(state: DeepReadonly<RuntimeState>) {
|
||||
const showStart = state._startEpoch ?? state.rundown.actualStart;
|
||||
if (showStart === null || showStart === currentShowStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
currentShowStart = showStart;
|
||||
plannedTimes = {
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show level times for the report.
|
||||
* Planned times are the ones captured when the show started, actual times are
|
||||
* derived from the events that ran.
|
||||
*/
|
||||
export function generateShowReport(): ShowReport {
|
||||
return { ...plannedTimes, ...getActualShowTimes(generate()) };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,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,331 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
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('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,255 @@
|
||||
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'> {
|
||||
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;
|
||||
// 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