mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05cf7c7c56 | |||
| a851414d13 | |||
| b21385ec4a | |||
| faca1a1c76 | |||
| 8551c161f1 | |||
| 3c2875a956 |
@@ -5,3 +5,28 @@ th.over {
|
||||
th.under {
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
padding: 0 var(--panel-card-padding, 2rem);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: calc(1rem - 3px);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { countPlannedEvents, 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';
|
||||
@@ -7,9 +8,9 @@ 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 { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
|
||||
import style from './ReportSettings.module.scss';
|
||||
|
||||
@@ -31,6 +32,10 @@ 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]);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
@@ -41,7 +46,7 @@ export default function ReportSettings() {
|
||||
Manage report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
<IoDownloadOutline />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
|
||||
@@ -51,6 +56,19 @@ export default function ReportSettings() {
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
</Panel.Section>
|
||||
{summary.eventsRun > 0 && (
|
||||
<Panel.Section>
|
||||
<div className={style.summary}>
|
||||
<Stat label='Events run' value={`${summary.eventsRun} / ${summary.eventsPlanned}`} />
|
||||
<Stat label='Scheduled' value={formatDuration(summary.scheduledDuration, false)} />
|
||||
<Stat label='Actual' value={formatDuration(summary.actualDuration, false)} />
|
||||
<Stat label='Drift' value={formatDrift(summary.drift, summary.eventsRun)} />
|
||||
<Stat label='On time' value={String(summary.eventsOnTime)} />
|
||||
<Stat label='Over' value={String(summary.eventsOver)} />
|
||||
<Stat label='Under' value={String(summary.eventsUnder)} />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
)}
|
||||
<Panel.Section>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
@@ -102,3 +120,12 @@ export default function ReportSettings() {
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={style.stat}>
|
||||
<span className={style.statLabel}>{label}</span>
|
||||
<span className={style.statValue}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeReport,
|
||||
RundownEntries,
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { formatDrift, 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, playCount: 1 },
|
||||
};
|
||||
|
||||
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, playCount: 1 },
|
||||
};
|
||||
|
||||
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('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, playCount: 1 },
|
||||
};
|
||||
|
||||
expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDrift()', () => {
|
||||
it('has nothing to report when no event completed', () => {
|
||||
expect(formatDrift(0, 0)).toBe('–');
|
||||
});
|
||||
|
||||
it('treats sub-second drift as on time', () => {
|
||||
expect(formatDrift(500, 3)).toBe('On time');
|
||||
});
|
||||
|
||||
it('signs the drift in both directions', () => {
|
||||
expect(formatDrift(252000, 3)).toBe('+4m12s');
|
||||
expect(formatDrift(-60000, 3)).toBe('-1m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeReportCSV()', () => {
|
||||
it('produces a header row and one row per entry', () => {
|
||||
const csv = makeReportCSV([
|
||||
{
|
||||
id: 'a',
|
||||
index: 1,
|
||||
title: 'Welcome',
|
||||
cue: '1',
|
||||
scheduledStart: 0,
|
||||
scheduledEnd: 10000,
|
||||
actualStart: 0,
|
||||
actualEnd: 12000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(csv.trim().split('\n')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -15,7 +17,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,
|
||||
@@ -33,7 +40,9 @@ export function getCombinedReport(
|
||||
const entry = rundown[id];
|
||||
if (!entry || !isOntimeEvent(entry)) continue;
|
||||
|
||||
if (!(id in report)) {
|
||||
const reported = report[id];
|
||||
|
||||
if (!reported) {
|
||||
combinedReport.push({
|
||||
id: id,
|
||||
index: index,
|
||||
@@ -44,18 +53,16 @@ export function getCombinedReport(
|
||||
scheduledEnd: entry.timeEnd,
|
||||
actualStart: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (id in report) {
|
||||
} else {
|
||||
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,
|
||||
scheduledStart: reported.scheduledStart,
|
||||
actualEnd: reported.endedAt,
|
||||
scheduledEnd: reported.scheduledStart + reported.scheduledDuration,
|
||||
actualStart: reported.startedAt,
|
||||
});
|
||||
}
|
||||
index++;
|
||||
@@ -64,6 +71,16 @@ export function getCombinedReport(
|
||||
return combinedReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed drift, eg "+4m12s" / "-1m". With nothing completed there is no
|
||||
* meaningful drift to report.
|
||||
*/
|
||||
export function formatDrift(drift: number, eventsRun: number): string {
|
||||
if (eventsRun === 0) return enDash;
|
||||
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
|
||||
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
|
||||
}
|
||||
|
||||
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -141,7 +141,6 @@ function RundownEventInner({
|
||||
isPast={isPast}
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Day } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
|
||||
import { useMemo } from 'react';
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
|
||||
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
totalGap: number;
|
||||
duration: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@ export default function RundownEventChip({
|
||||
className,
|
||||
totalGap,
|
||||
id,
|
||||
duration,
|
||||
isLinkedToLoaded,
|
||||
}: RundownEventChipProps) {
|
||||
const playback = usePlayback();
|
||||
@@ -45,7 +43,7 @@ export default function RundownEventChip({
|
||||
const playbackActive = isPlaybackActive(playback);
|
||||
|
||||
if (!playbackActive || isPast) {
|
||||
return <EventReport className={className} id={id} duration={duration} />;
|
||||
return <EventReport className={className} id={id} />;
|
||||
}
|
||||
|
||||
if (playbackActive) {
|
||||
@@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
interface EventReportProps {
|
||||
className: string;
|
||||
id: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function EventReport(props: EventReportProps) {
|
||||
const { className, id, duration } = props;
|
||||
const { className, id } = props;
|
||||
const { data } = useReport();
|
||||
const currentReport = data[id];
|
||||
|
||||
const [value, overUnderStyle, tooltip] = useMemo(() => {
|
||||
if (!currentReport) {
|
||||
// measured against the schedule recorded when the event ran, so this
|
||||
// agrees with the report panel and survives later rundown edits
|
||||
const variance = getEventVariance(currentReport);
|
||||
if (variance.status === 'not-run') {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const { startedAt, endedAt } = currentReport;
|
||||
if (!startedAt || !endedAt) {
|
||||
return [null, 'none', ''];
|
||||
}
|
||||
|
||||
const actualDuration = endedAt - startedAt;
|
||||
const difference = actualDuration - duration;
|
||||
const absDifference = Math.abs(difference);
|
||||
|
||||
if (absDifference < MILLIS_PER_SECOND) {
|
||||
if (variance.status === 'ontime') {
|
||||
return ['ontime', 'under', 'Event finished on time'];
|
||||
}
|
||||
|
||||
const isOver = difference > 0;
|
||||
|
||||
const absDifference = Math.abs(variance.delta);
|
||||
const isOver = variance.status === 'over';
|
||||
const fullTimeValue = millisToString(absDifference);
|
||||
|
||||
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
|
||||
|
||||
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
|
||||
return [value, isOver ? 'over' : 'under', tooltip];
|
||||
}, [currentReport, duration]);
|
||||
return [value, variance.status, tooltip];
|
||||
}, [currentReport]);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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, 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;
|
||||
|
||||
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,
|
||||
playCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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('counts a re-run rather than losing the previous one', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state);
|
||||
triggerReportEntry(TimerLifeCycle.onStart, { ...state, clock: 5000 } as typeof state);
|
||||
|
||||
expect(generate()[eventA.id].playCount).toBe(2);
|
||||
});
|
||||
|
||||
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,
|
||||
playCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores events without an id', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: null });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -49,14 +49,31 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
// an event started twice is a re-run, not a new record
|
||||
const playCount = (report.get(eventId)?.playCount ?? 0) + 1;
|
||||
|
||||
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,
|
||||
playCount,
|
||||
});
|
||||
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,
|
||||
playCount: previous?.playCount ?? 1,
|
||||
});
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
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;
|
||||
/** how many times the event was started, >1 means it was re-run */
|
||||
playCount: number;
|
||||
};
|
||||
|
||||
export type OntimeReport = Record<string, OntimeEventReport>;
|
||||
export type OntimeReport = Record<EntryId, OntimeEventReport>;
|
||||
|
||||
/** Headline numbers for everything in the current report */
|
||||
export type RunSummary = {
|
||||
/** events which produced a report entry */
|
||||
eventsRun: number;
|
||||
/** playable events in the rundown */
|
||||
eventsPlanned: number;
|
||||
scheduledDuration: number;
|
||||
actualDuration: number;
|
||||
/** actualDuration - scheduledDuration, signed */
|
||||
drift: number;
|
||||
eventsOver: number;
|
||||
eventsUnder: number;
|
||||
eventsOnTime: number;
|
||||
/** largest single overrun, answers "what blew the schedule" */
|
||||
worstOverrun: { id: EntryId; delta: number } | null;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ 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 { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js';
|
||||
|
||||
// ---> Automations
|
||||
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
||||
|
||||
@@ -99,6 +99,15 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// feature business logic - reports
|
||||
export {
|
||||
countPlannedEvents,
|
||||
getEventVariance,
|
||||
getRunSummary,
|
||||
type EventVariance,
|
||||
type VarianceStatus,
|
||||
} from './src/report-utils/reportUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
|
||||
|
||||
import { getEventVariance, getRunSummary } from './reportUtils.js';
|
||||
|
||||
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
|
||||
return {
|
||||
startedAt: 0,
|
||||
endedAt: 10000,
|
||||
scheduledStart: 0,
|
||||
scheduledDuration: 10000,
|
||||
playCount: 1,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getEventVariance()', () => {
|
||||
it('reports an event which never ran', () => {
|
||||
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null, delta: 0 });
|
||||
});
|
||||
|
||||
it('reports an event which started but never finished', () => {
|
||||
const entry = makeEntry({ startedAt: 1000, endedAt: null });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run', actualDuration: null });
|
||||
});
|
||||
|
||||
it('reports an event which never started', () => {
|
||||
const entry = makeEntry({ startedAt: null, endedAt: 1000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run' });
|
||||
});
|
||||
|
||||
it('reports an event which matched its schedule', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10000, delta: 0 });
|
||||
});
|
||||
|
||||
it('treats sub-second differences as on time', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10500, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', delta: 500 });
|
||||
});
|
||||
|
||||
it('reports an overrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'over', actualDuration: 15000, delta: 5000 });
|
||||
});
|
||||
|
||||
it('reports an underrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 6000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'under', actualDuration: 6000, delta: -4000 });
|
||||
});
|
||||
|
||||
it('measures against the snapshot, not the current rundown', () => {
|
||||
// the rundown may have been edited after the run, the snapshot is what counts
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry).delta).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunSummary()', () => {
|
||||
it('returns an empty summary for an empty report', () => {
|
||||
expect(getRunSummary({}, 0)).toMatchObject({
|
||||
eventsRun: 0,
|
||||
eventsPlanned: 0,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
worstOverrun: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates durations and drift across a run', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 15000, endedAt: 21000, scheduledDuration: 10000 }), // -4000
|
||||
c: makeEntry({ startedAt: 21000, endedAt: 31000, scheduledDuration: 10000 }), // 0
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 4)).toMatchObject({
|
||||
eventsRun: 3,
|
||||
eventsPlanned: 4,
|
||||
scheduledDuration: 30000,
|
||||
actualDuration: 31000,
|
||||
drift: 1000,
|
||||
eventsOver: 1,
|
||||
eventsUnder: 1,
|
||||
eventsOnTime: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies the worst overrun', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 0, endedAt: 30000, scheduledDuration: 10000 }), // +20000
|
||||
c: makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }), // +2000
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
|
||||
});
|
||||
|
||||
it('ignores events which did not complete', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }),
|
||||
b: makeEntry({ startedAt: 15000, endedAt: null, scheduledDuration: 10000 }),
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 2)).toMatchObject({
|
||||
eventsRun: 1,
|
||||
scheduledDuration: 10000,
|
||||
actualDuration: 15000,
|
||||
drift: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { EntryId, OntimeEventReport, OntimeReport, RundownEntries, RunSummary } from 'ontime-types';
|
||||
import { isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import { 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' };
|
||||
|
||||
/**
|
||||
* 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 = endedAt - startedAt;
|
||||
const delta = actualDuration - scheduledDuration;
|
||||
|
||||
if (Math.abs(delta) < MILLIS_PER_SECOND) {
|
||||
return { actualDuration, delta, status: 'ontime' };
|
||||
}
|
||||
|
||||
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates a run's per event data into the headline numbers for a show.
|
||||
* @param report the run's per event data
|
||||
* @param eventsPlanned how many playable events the rundown held when the run was made
|
||||
*/
|
||||
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
|
||||
const summary: RunSummary = {
|
||||
eventsRun: 0,
|
||||
eventsPlanned,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
eventsOver: 0,
|
||||
eventsUnder: 0,
|
||||
eventsOnTime: 0,
|
||||
worstOverrun: null,
|
||||
};
|
||||
|
||||
for (const [id, entry] of Object.entries(report)) {
|
||||
const variance = getEventVariance(entry);
|
||||
if (variance.status === 'not-run') {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.eventsRun += 1;
|
||||
summary.scheduledDuration += entry.scheduledDuration;
|
||||
summary.actualDuration += variance.actualDuration as number;
|
||||
|
||||
if (variance.status === 'over') {
|
||||
summary.eventsOver += 1;
|
||||
if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) {
|
||||
summary.worstOverrun = { id, delta: variance.delta };
|
||||
}
|
||||
} else if (variance.status === 'under') {
|
||||
summary.eventsUnder += 1;
|
||||
} else {
|
||||
summary.eventsOnTime += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary.drift = summary.actualDuration - summary.scheduledDuration;
|
||||
|
||||
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