Compare commits

..

5 Commits

Author SHA1 Message Date
Claude 0f2bbb10ad fix(report): square the group card's bottom corners
Neither precedent rounds a group header the way ReportSummaryCard's
default 3px radius did. The import preview's group row has no radius at
all, and the cuesheet's rounds only the top-left, leaving the bottom
square specifically so its rail runs straight into the row below.

The report's group card sits directly on top of its own event rows with
no gap between them, so rounding all four corners curved the rail away
right at that seam — a visible break between the heading and its first
event, seen in the screenshots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-26 06:43:44 +00:00
Claude 255695bc4a style(report): draw grouped rows the way the app already draws them
The report had invented its own treatment for a group and its events: a
flat surface, a rail drawn as an inset shadow at its own width, and a
foot closing the group off. The cuesheet and the sheet import preview had
already settled this, and settled it the same way as each other, so the
report now follows them rather than adding a third dialect.

Both wash a row with a colour mixed from the entry's own colour and rail
it down the left, deepening for the group that owns the rows: 4% and 3px
for an event, 12% and 4px for the group, over gray-1300. Both read that
colour from a --entry-colour custom property, which is what the report
now sets too. The import preview is the closer precedent of the two,
being a table of grouped entries inside a settings panel, so its exact
mix ratios and rail widths are the ones taken here.

Groups are separated by space above the heading, as the preview does it,
which retires the closing foot. The summary card's surface moves behind a
--card-surface variable so the group tier can wash it without a
specificity fight, and the neutral default stays the gray-1200 that Info
uses. The wash sits on the cells rather than the row so it covers the
panel table's own striping, which no longer shows through on some rows
and not others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-26 06:14:10 +00:00
Claude ef472b513a feat(report): tie events to their group, split running time from finishing time
Three changes from reviewing the panel against a real run.

Events are bracketed by the group they belong to. A coloured rail in the
group's own colour runs down the heading and every event under it, closed
by a foot where the group ends. This is the shape the rundown editor
already uses for a group, so the report reads with the same grammar as
the rundown it reports on, and an event can no longer be mistaken for
belonging to the group above it.

The show summary no longer reduces the run to one figure. Finishing time
and running time answer different questions and can point opposite ways:
a show which starts early and runs over still finishes early, and calling
that a single "total plan deviation" reported it as having come in under
when it had in fact overrun. The two are now separate: the headline is
the show against its planned duration, which is what the team controls
and what carries into the next run of the same rundown, and the finishing
time sits beside it as its own row alongside the start it inherited.
ShowOffsets gains plannedDuration, actualDuration and durationOffset,
replacing duringShow, which was the same figure under a name that
described neither.

Nothing is forced into upper case any more. The summary labels are
sentence case, and event rows carry their values in td rather than th:
the panel's th styling is meant for column headings, so every event
title in the report was being rendered small, bold and shouting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-26 05:43:48 +00:00
Claude d2f2cc0bff refactor(report): one summary shape for the show and its groups
A group heading is a summary of the events inside it in the same way the
show summary is a summary of the groups, so both are now built on one
shell and read the same way: the conclusion at full weight on the left,
the workings beside it, and how much of it ran along the bottom. The
group's dense dot separated stat row is gone; it carries the same shape
at less weight so it belongs to the summary rather than competing with
it.

The show's headline becomes its total plan deviation, the figure a
producer is actually asked about afterwards, read back underneath as the
two parts it is made of: the lateness it inherited from its start and
the time the show itself lost or made up. The previous "recovered during
the show" phrasing named only the second part while showing a figure for
neither.

Drops the counts of events over, under and on time. Almost nothing lands
on the exact second, so those buckets described rounding rather than the
show. The single worst overrun, which was already computed but never
shown, replaces them: it names the event that blew the schedule.

Also:
- a group with events still to run no longer reports a variance, which
  otherwise read as a large underrun for a block that had simply not
  finished. This matches the guard already in place at show level.
- GroupReport.changeover becomes untimed. The same gap can be a
  changeover, a timer started late, or an item run without a timer at
  all, and in roll mode it is the scheduled gap. Naming it for what was
  measured rather than for a cause avoids asserting a workflow.
- the summary names the rundown it reports on, since a project can hold
  several.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
2026-08-25 20:23:18 +00:00
Carlos Valente c91b6c020e refactor(report): summary 2026-08-25 21:57:21 +02:00
30 changed files with 1979 additions and 318 deletions
Binary file not shown.
+1
View File
@@ -20,6 +20,7 @@ export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const REPORT_SHOW = ['report', 'show'];
export const TRANSLATION = ['translation'];
// API URLs
+9 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { OntimeReport, ShowReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -15,6 +15,14 @@ export async function fetchReport(options?: RequestOptions): Promise<OntimeRepor
return res.data;
}
/**
* HTTP request to fetch the show level times for the report
*/
export async function fetchShowReport(options?: RequestOptions): Promise<ShowReport> {
const res = await axios.get(`${reportUrl}/show`, { signal: options?.signal });
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { ShowReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT_SHOW } from '../api/constants';
import { fetchShowReport } from '../api/report';
const emptyShowReport: ShowReport = {
plannedStart: null,
plannedEnd: null,
actualStart: null,
actualEnd: null,
};
/**
* Show level times for the report.
* Invalidated by the same websocket signal as the report itself, since the
* two are read together.
*/
export default function useShowReport() {
const { data } = useQuery<ShowReport>({
queryKey: REPORT_SHOW,
queryFn: ({ signal }) => fetchShowReport({ signal }),
placeholderData: (previousData, _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>
);
@@ -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');
});
});
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
@@ -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>;
}
@@ -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;
-19
View File
@@ -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;
-23
View File
@@ -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;
};
+8 -1
View File
@@ -24,7 +24,14 @@ export { TimerType } from './definitions/TimerType.type.js';
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
export type {
GroupReport,
OntimeReport,
OntimeEventReport,
RunSummary,
ShowOffsets,
ShowReport,
} from './definitions/core/Report.type.js';
// ---> Automations
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
+13
View File
@@ -99,6 +99,19 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
// feature business logic - reports
export {
countPlannedEvents,
elapsedBetween,
getActualShowTimes,
getEventVariance,
getGroupReports,
getRunSummary,
getShowOffsets,
type EventVariance,
type VarianceStatus,
} from './src/report-utils/reportUtils.js';
//Colour
export {
colourToHex,
@@ -0,0 +1,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;
}