refactor(report): summary

This commit is contained in:
Carlos Valente
2026-08-25 21:57:15 +02:00
parent b9683f00dc
commit c91b6c020e
21 changed files with 1729 additions and 140 deletions
+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,277 @@
import type { OntimeEventReport, OntimeReport, RundownEntries } from 'ontime-types';
import { SupportedEntry } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js';
import {
countPlannedEvents,
elapsedBetween,
getActualShowTimes,
getEventVariance,
getGroupReports,
getRunSummary,
getShowOffsets,
} from './reportUtils.js';
const MIN = 60000;
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
return { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, ...patch };
}
function makeEvent(id: string, patch: Record<string, unknown> = {}) {
return { type: SupportedEntry.Event, id, duration: 10000, skip: false, parent: null, ...patch } as never;
}
function makeGroup(id: string, entries: string[], patch: Record<string, unknown> = {}) {
return {
type: SupportedEntry.Group,
id,
title: id,
colour: '',
targetDuration: null,
entries,
...patch,
} as never;
}
describe('elapsedBetween()', () => {
it('measures forward within a day', () => {
expect(elapsedBetween(1000, 5000)).toBe(4000);
});
it('reads a backwards result as having crossed midnight', () => {
// 23:50 to 00:10 is twenty minutes, not minus twenty three hours
const tenToMidnight = dayInMs - 10 * MIN;
expect(elapsedBetween(tenToMidnight, 10 * MIN)).toBe(20 * MIN);
});
});
describe('getEventVariance()', () => {
it('reports an event which never ran', () => {
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null });
});
it('reports an event which started but never finished', () => {
expect(getEventVariance(makeEntry({ endedAt: null }))).toMatchObject({ status: 'not-run' });
});
it('treats sub-second differences as on time', () => {
expect(getEventVariance(makeEntry({ endedAt: 10500 }))).toMatchObject({ status: 'ontime', delta: 500 });
});
it('reports an overrun', () => {
expect(getEventVariance(makeEntry({ endedAt: 15000 }))).toMatchObject({ status: 'over', delta: 5000 });
});
it('reports an underrun', () => {
expect(getEventVariance(makeEntry({ endedAt: 6000 }))).toMatchObject({ status: 'under', delta: -4000 });
});
it('measures against the snapshot, not a later edit', () => {
expect(getEventVariance(makeEntry({ endedAt: 12000, scheduledDuration: 10000 })).delta).toBe(2000);
});
it('handles an event running past midnight', () => {
const entry = makeEntry({ startedAt: dayInMs - 5 * MIN, endedAt: 5 * MIN, scheduledDuration: 10 * MIN });
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10 * MIN });
});
});
describe('getShowOffsets()', () => {
it('reports a late start carried through to a late end', () => {
// started 8 late, ended 8 late: the show itself ran clean
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 8 * MIN,
actualEnd: 21 * 60 * MIN + 8 * MIN,
});
expect(offsets).toEqual({ startOffset: 8 * MIN, endOffset: 8 * MIN, duringShow: 0 });
});
it('separates time lost during the show from a late start', () => {
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 8 * MIN,
actualEnd: 21 * 60 * MIN + 12 * MIN,
});
expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, duringShow: 4 * MIN });
});
it('reports time recovered as a negative', () => {
const offsets = getShowOffsets({
plannedStart: 19 * 60 * MIN,
plannedEnd: 21 * 60 * MIN,
actualStart: 19 * 60 * MIN + 10 * MIN,
actualEnd: 21 * 60 * MIN + 2 * MIN,
});
expect(offsets.duringShow).toBe(-8 * MIN);
});
it('does not read a show ending after midnight as a day early', () => {
const offsets = getShowOffsets({
plannedStart: 23 * 60 * MIN,
plannedEnd: dayInMs - 10 * MIN,
actualStart: 23 * 60 * MIN,
actualEnd: 5 * MIN, // ran past midnight
});
expect(offsets.endOffset).toBe(15 * MIN);
});
it('has nothing to report without a plan', () => {
expect(getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 1, actualEnd: 2 })).toEqual({
startOffset: null,
endOffset: null,
duringShow: null,
});
});
});
describe('getActualShowTimes()', () => {
it('opens on the first start and closes on the last end', () => {
const report: OntimeReport = {
b: makeEntry({ startedAt: 5000, endedAt: 9000 }),
a: makeEntry({ startedAt: 1000, endedAt: 4000 }),
c: makeEntry({ startedAt: 9000, endedAt: 20000 }),
};
expect(getActualShowTimes(report)).toEqual({ actualStart: 1000, actualEnd: 20000 });
});
it('is empty when nothing ran', () => {
expect(getActualShowTimes({})).toEqual({ actualStart: null, actualEnd: null });
});
});
describe('getGroupReports()', () => {
const entries: RundownEntries = {
act1: makeGroup('act1', ['a', 'b'], { targetDuration: 30 * MIN }),
a: makeEvent('a', { duration: 10 * MIN, parent: 'act1' }),
b: makeEvent('b', { duration: 10 * MIN, parent: 'act1' }),
};
it('measures the block against the target the user set', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 12 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 12 * MIN, endedAt: 24 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group).toMatchObject({
targetDuration: 30 * MIN,
elapsed: 24 * MIN,
variance: -6 * MIN, // came in under its 30 minute budget
eventsRun: 2,
eventsPlanned: 2,
});
});
it('reports the time a block spent not running an event', () => {
// 5 minute changeover between the two events
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 15 * MIN, endedAt: 25 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group.elapsed).toBe(25 * MIN);
expect(group.changeover).toBe(5 * MIN);
});
it('falls back to what was scheduled when no target was set', () => {
const noTarget: RundownEntries = { ...entries, act1: makeGroup('act1', ['a', 'b']) };
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 12 * MIN, scheduledDuration: 10 * MIN }),
b: makeEntry({ startedAt: 12 * MIN, endedAt: 24 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, noTarget, ['act1']);
// 24 elapsed against 20 scheduled
expect(group.targetDuration).toBeNull();
expect(group.scheduledDuration).toBe(20 * MIN);
expect(group.variance).toBe(4 * MIN);
});
it('reads a partly run block as incomplete rather than as an overrun', () => {
const report: OntimeReport = {
a: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
};
const [group] = getGroupReports(report, entries, ['act1']);
expect(group.eventsRun).toBe(1);
expect(group.eventsPlanned).toBe(2);
});
it('has no actuals for a block which never ran', () => {
const [group] = getGroupReports({}, entries, ['act1']);
expect(group).toMatchObject({ elapsed: null, changeover: null, variance: null, eventsRun: 0 });
});
it('leaves skipped events out of the block', () => {
const withSkip: RundownEntries = { ...entries, b: makeEvent('b', { duration: 10 * MIN, skip: true }) };
const [group] = getGroupReports({}, withSkip, ['act1']);
expect(group.eventsPlanned).toBe(1);
expect(group.scheduledDuration).toBe(10 * MIN);
});
it('ignores entries which are not groups', () => {
expect(getGroupReports({}, entries, ['a'])).toEqual([]);
});
});
describe('getRunSummary()', () => {
it('counts how events landed', () => {
const report: OntimeReport = {
a: makeEntry({ endedAt: 15000 }), // over
b: makeEntry({ endedAt: 6000 }), // under
c: makeEntry({ endedAt: 10000 }), // on time
};
expect(getRunSummary(report, 4)).toMatchObject({
eventsRun: 3,
eventsPlanned: 4,
eventsOver: 1,
eventsUnder: 1,
eventsOnTime: 1,
});
});
it('identifies the worst overrun', () => {
const report: OntimeReport = {
a: makeEntry({ endedAt: 15000 }),
b: makeEntry({ endedAt: 30000 }),
c: makeEntry({ endedAt: 12000 }),
};
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
});
it('ignores events which did not complete', () => {
const report: OntimeReport = { a: makeEntry({ endedAt: 15000 }), b: makeEntry({ endedAt: null }) };
expect(getRunSummary(report, 2).eventsRun).toBe(1);
});
});
describe('countPlannedEvents()', () => {
it('counts playable events, excluding skipped ones', () => {
const entries: RundownEntries = {
a: makeEvent('a'),
b: makeEvent('b', { skip: true }),
g: makeGroup('g', []),
};
expect(countPlannedEvents(entries, ['a', 'b', 'g'])).toBe(1);
});
});
@@ -0,0 +1,236 @@
import type {
EntryId,
GroupReport,
OntimeEventReport,
OntimeReport,
RundownEntries,
RunSummary,
ShowOffsets,
ShowReport,
} from 'ontime-types';
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from '../date-utils/conversionUtils.js';
export type VarianceStatus = 'ontime' | 'over' | 'under' | 'not-run';
export type EventVariance = {
/** how long the event actually took, null if it never completed */
actualDuration: number | null;
/** actualDuration - scheduledDuration, signed. 0 when the event did not complete */
delta: number;
status: VarianceStatus;
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
/**
* Time between two points in the day.
*
* Report times are times of day, so a show running past midnight would
* otherwise measure as a large negative. A backwards result is read as
* having crossed into the next day.
*/
export function elapsedBetween(from: number, to: number): number {
return to >= from ? to - from : to + dayInMs - from;
}
/**
* Calculates how an event performed against its schedule.
* An event is considered on time if it is within a second of its scheduled duration.
*/
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) {
return notRun;
}
const { startedAt, endedAt, scheduledDuration } = entry;
if (startedAt === null || endedAt === null) {
return notRun;
}
const actualDuration = elapsedBetween(startedAt, endedAt);
const delta = actualDuration - scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) {
return { actualDuration, delta, status: 'ontime' };
}
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
/**
* How the show sat against its plan.
*
* A sum of event overruns cannot answer this: gaps absorb overrun, skipped
* events give time back, and a late start moves the whole show without any
* event running long.
*/
export function getShowOffsets(show: ShowReport): ShowOffsets {
const startOffset = offsetBetween(show.plannedStart, show.actualStart);
const endOffset = offsetBetween(show.plannedEnd, show.actualEnd);
return {
startOffset,
endOffset,
duringShow: startOffset === null || endOffset === null ? null : endOffset - startOffset,
};
}
/**
* Signed distance from a planned time to the time it happened.
* Positive means late, matching Ontime's offset convention.
* @private
*/
function offsetBetween(planned: number | null, actual: number | null): number | null {
if (planned === null || actual === null) {
return null;
}
const diff = actual - planned;
// a show is not half a day early: read a large negative as having crossed midnight
if (diff < -dayInMs / 2) {
return diff + dayInMs;
}
if (diff > dayInMs / 2) {
return diff - dayInMs;
}
return diff;
}
/**
* Derives the show's actual start and end from the events that ran.
* The first event to start opens the show, the last to finish closes it.
*/
export function getActualShowTimes(report: OntimeReport): Pick<ShowReport, 'actualStart' | 'actualEnd'> {
let actualStart: number | null = null;
let actualEnd: number | null = null;
for (const entry of Object.values(report)) {
if (entry.startedAt !== null && (actualStart === null || entry.startedAt < actualStart)) {
actualStart = entry.startedAt;
}
if (entry.endedAt !== null && (actualEnd === null || entry.endedAt > actualEnd)) {
actualEnd = entry.endedAt;
}
}
return { actualStart, actualEnd };
}
/**
* Rolls the report up to the blocks the show was planned in.
*
* A group carries a targetDuration the user committed to while planning, and
* Ontime already tells them whether the schedule fits it. This reports whether
* the show actually did.
*/
export function getGroupReports(report: OntimeReport, entries: RundownEntries, order: EntryId[]): GroupReport[] {
const groups: GroupReport[] = [];
for (const id of order) {
const group = entries[id];
if (!group || !isOntimeGroup(group)) continue;
let scheduledDuration = 0;
let eventsPlanned = 0;
let eventsRun = 0;
let ranDuration = 0;
let actualStart: number | null = null;
let actualEnd: number | null = null;
for (const childId of group.entries) {
const child = entries[childId];
// skipped events were never meant to run and would read as missed
if (!child || !isOntimeEvent(child) || child.skip) continue;
eventsPlanned += 1;
const reported = report[childId];
// measure against what was scheduled at the time where we know it
scheduledDuration += reported?.scheduledDuration ?? child.duration;
const variance = getEventVariance(reported);
if (variance.status === 'not-run') continue;
eventsRun += 1;
ranDuration += variance.actualDuration as number;
const started = reported.startedAt as number;
const ended = reported.endedAt as number;
if (actualStart === null || started < actualStart) actualStart = started;
if (actualEnd === null || ended > actualEnd) actualEnd = ended;
}
const elapsed = actualStart === null || actualEnd === null ? null : elapsedBetween(actualStart, actualEnd);
// the budget if the user set one, otherwise what they scheduled into it
const measuredAgainst = group.targetDuration ?? scheduledDuration;
groups.push({
id: group.id,
title: group.title,
colour: group.colour,
targetDuration: group.targetDuration,
scheduledDuration,
actualStart,
actualEnd,
elapsed,
// whatever the block spent not running an event
changeover: elapsed === null ? null : Math.max(0, elapsed - ranDuration),
variance: elapsed === null ? null : elapsed - measuredAgainst,
eventsRun,
eventsPlanned,
});
}
return groups;
}
/** Counts across the events in the report */
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
const summary: RunSummary = {
eventsRun: 0,
eventsPlanned,
eventsOver: 0,
eventsUnder: 0,
eventsOnTime: 0,
worstOverrun: null,
};
for (const [id, entry] of Object.entries(report)) {
const variance = getEventVariance(entry);
if (variance.status === 'not-run') {
continue;
}
summary.eventsRun += 1;
if (variance.status === 'over') {
summary.eventsOver += 1;
if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) {
summary.worstOverrun = { id, delta: variance.delta };
}
} else if (variance.status === 'under') {
summary.eventsUnder += 1;
} else {
summary.eventsOnTime += 1;
}
}
return summary;
}
/**
* Counts the events a run could have played.
* Skipped events are excluded: they were never meant to run and would
* make the completion figures read as if the show fell short.
*/
export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number {
let count = 0;
for (const id of order) {
const entry = entries[id];
if (entry && isOntimeEvent(entry) && !entry.skip) {
count += 1;
}
}
return count;
}