mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 12:29:10 +00:00
refactor(report): improve report summary
This commit is contained in:
@@ -1,8 +1,103 @@
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
import type { EntryId } from './OntimeEntry.js';
|
||||
|
||||
export type OntimeEventReport = {
|
||||
startedAt: MaybeNumber;
|
||||
endedAt: MaybeNumber;
|
||||
/**
|
||||
* Snapshot of the schedule taken when the event ran.
|
||||
* Keeping a copy is what makes a report a record: editing the rundown
|
||||
* afterwards no longer changes how a show that already happened is reported.
|
||||
*/
|
||||
scheduledStart: number;
|
||||
scheduledDuration: number;
|
||||
};
|
||||
|
||||
export type OntimeReport = Record<string, OntimeEventReport>;
|
||||
export type OntimeReport = Record<EntryId, OntimeEventReport>;
|
||||
|
||||
/**
|
||||
* Show level times for the report.
|
||||
*
|
||||
* Planned times are snapshotted when the show starts, for the same reason the
|
||||
* per event schedule is. Actual times are derived from the events that ran.
|
||||
*/
|
||||
export type ShowReport = {
|
||||
plannedStart: MaybeNumber;
|
||||
plannedEnd: MaybeNumber;
|
||||
actualStart: MaybeNumber;
|
||||
actualEnd: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* How the show sat against its plan, as the two separate questions it answers.
|
||||
*
|
||||
* Follows Ontime's offset convention: positive means behind schedule.
|
||||
*
|
||||
* `endOffset` asks whether the show came off air when it promised to, which is
|
||||
* what an audience or a venue booking is measured against. `durationOffset`
|
||||
* asks whether the show itself ran long, which is what the team controls and
|
||||
* what carries over to the next run of the same rundown. They differ by
|
||||
* exactly `startOffset`: a show can run over and still finish early if it
|
||||
* started early, so reporting either one alone is misleading.
|
||||
*/
|
||||
export type ShowOffsets = {
|
||||
/** actual start against planned start */
|
||||
startOffset: MaybeNumber;
|
||||
/** actual end against planned end */
|
||||
endOffset: MaybeNumber;
|
||||
/** how long the show was planned to take */
|
||||
plannedDuration: MaybeNumber;
|
||||
/** how long it actually took */
|
||||
actualDuration: MaybeNumber;
|
||||
/** actualDuration against plannedDuration, ie whether the show ran long */
|
||||
durationOffset: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a group ran against the budget set for it.
|
||||
*
|
||||
* Ontime already compares a group's scheduled duration against its
|
||||
* targetDuration while planning. This closes that loop after the show.
|
||||
*/
|
||||
export type GroupReport = {
|
||||
id: EntryId;
|
||||
title: string;
|
||||
colour: string;
|
||||
/** the budget the user set for the block, null when they set none */
|
||||
targetDuration: MaybeNumber;
|
||||
/** what was scheduled into the block */
|
||||
scheduledDuration: number;
|
||||
actualStart: MaybeNumber;
|
||||
actualEnd: MaybeNumber;
|
||||
/** wall time from the first event starting to the last one ending */
|
||||
elapsed: MaybeNumber;
|
||||
/**
|
||||
* Time inside the block with no event running: elapsed minus the sum of the
|
||||
* events' own durations. Deliberately named for what was measured rather
|
||||
* than for a cause, since the same gap can be a changeover, a timer started
|
||||
* late, or an event run without the timer at all.
|
||||
*/
|
||||
untimed: MaybeNumber;
|
||||
/**
|
||||
* Measured against targetDuration where set, otherwise scheduledDuration.
|
||||
* Null while the group is incomplete: the events still to run would make a
|
||||
* partial group read as a large underrun.
|
||||
*/
|
||||
variance: MaybeNumber;
|
||||
eventsRun: number;
|
||||
eventsPlanned: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts across the events in the report.
|
||||
*
|
||||
* Deliberately not a tally of events over, under and on time: events rarely
|
||||
* land on the exact second, so those buckets describe rounding more than they
|
||||
* describe the show. The single worst overrun is the fact that acts on.
|
||||
*/
|
||||
export type RunSummary = {
|
||||
eventsRun: number;
|
||||
eventsPlanned: number;
|
||||
/** largest single overrun, answers "what blew the schedule" */
|
||||
worstOverrun: { id: EntryId; delta: number } | null;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,14 @@ export { TimerType } from './definitions/TimerType.type.js';
|
||||
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
|
||||
|
||||
// ---> Report
|
||||
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
||||
export type {
|
||||
GroupReport,
|
||||
OntimeReport,
|
||||
OntimeEventReport,
|
||||
RunSummary,
|
||||
ShowOffsets,
|
||||
ShowReport,
|
||||
} from './definitions/core/Report.type.js';
|
||||
|
||||
// ---> Automations
|
||||
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
||||
|
||||
@@ -99,6 +99,19 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// feature business logic - reports
|
||||
export {
|
||||
countPlannedEvents,
|
||||
elapsedBetween,
|
||||
getActualShowTimes,
|
||||
getEventVariance,
|
||||
getGroupReports,
|
||||
getRunSummary,
|
||||
getShowOffsets,
|
||||
type EventVariance,
|
||||
type VarianceStatus,
|
||||
} from './src/report-utils/reportUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
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 });
|
||||
});
|
||||
|
||||
it('keeps events after midnight later than events before midnight', () => {
|
||||
const report: OntimeReport = {
|
||||
beforeMidnight: makeEntry({
|
||||
startedAt: dayInMs - 10 * MIN,
|
||||
endedAt: dayInMs - 5 * MIN,
|
||||
}),
|
||||
afterMidnight: makeEntry({ startedAt: 0, endedAt: 10 * MIN }),
|
||||
};
|
||||
|
||||
expect(getActualShowTimes(report)).toEqual({
|
||||
actualStart: dayInMs - 10 * MIN,
|
||||
actualEnd: 10 * MIN,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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('measures a block across midnight in chronological order', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: dayInMs - 10 * MIN, endedAt: dayInMs - 5 * MIN, scheduledDuration: 5 * MIN }),
|
||||
b: makeEntry({ startedAt: 0, endedAt: 10 * MIN, scheduledDuration: 10 * MIN }),
|
||||
};
|
||||
|
||||
const [group] = getGroupReports(report, entries, ['act1']);
|
||||
|
||||
expect(group).toMatchObject({
|
||||
actualStart: dayInMs - 10 * MIN,
|
||||
actualEnd: 10 * MIN,
|
||||
elapsed: 20 * MIN,
|
||||
untimed: 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,306 @@
|
||||
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'> {
|
||||
return getActualReportTimes(Object.values(report));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the chronological bounds of report entries whose timestamps are times
|
||||
* of day. Each observed timestamp is tried as the timeline origin; the origin
|
||||
* producing the shortest span is the one that preserves a midnight rollover.
|
||||
*/
|
||||
function getActualReportTimes(entries: OntimeEventReport[]): Pick<ShowReport, 'actualStart' | 'actualEnd'> {
|
||||
const origins = new Set<number>();
|
||||
for (const entry of entries) {
|
||||
if (entry.startedAt !== null) origins.add(entry.startedAt);
|
||||
if (entry.endedAt !== null) origins.add(entry.endedAt);
|
||||
}
|
||||
|
||||
let bestSpan = Number.POSITIVE_INFINITY;
|
||||
let bestStart: number | null = null;
|
||||
let bestEnd: number | null = null;
|
||||
|
||||
for (const origin of origins) {
|
||||
let timelineStart = Number.POSITIVE_INFINITY;
|
||||
let timelineEnd = Number.NEGATIVE_INFINITY;
|
||||
let earliestStartPosition = Number.POSITIVE_INFINITY;
|
||||
let latestEndPosition = Number.NEGATIVE_INFINITY;
|
||||
let actualStart: number | null = null;
|
||||
let actualEnd: number | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startedAt === null) {
|
||||
if (entry.endedAt !== null) {
|
||||
const endPosition = elapsedBetween(origin, entry.endedAt);
|
||||
timelineStart = Math.min(timelineStart, endPosition);
|
||||
timelineEnd = Math.max(timelineEnd, endPosition);
|
||||
if (endPosition > latestEndPosition) {
|
||||
latestEndPosition = endPosition;
|
||||
actualEnd = entry.endedAt;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const startPosition = elapsedBetween(origin, entry.startedAt);
|
||||
timelineStart = Math.min(timelineStart, startPosition);
|
||||
if (startPosition < earliestStartPosition) {
|
||||
earliestStartPosition = startPosition;
|
||||
actualStart = entry.startedAt;
|
||||
}
|
||||
timelineEnd = Math.max(timelineEnd, startPosition);
|
||||
|
||||
if (entry.endedAt !== null) {
|
||||
const endPosition = startPosition + elapsedBetween(entry.startedAt, entry.endedAt);
|
||||
timelineEnd = Math.max(timelineEnd, endPosition);
|
||||
if (endPosition > latestEndPosition) {
|
||||
latestEndPosition = endPosition;
|
||||
actualEnd = entry.endedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const span = timelineEnd - timelineStart;
|
||||
if (span < bestSpan) {
|
||||
bestSpan = span;
|
||||
bestStart = actualStart;
|
||||
bestEnd = actualEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return { actualStart: bestStart, actualEnd: bestEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const completedReports: OntimeEventReport[] = [];
|
||||
|
||||
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.actualDuration === null) continue;
|
||||
|
||||
eventsRun += 1;
|
||||
ranDuration += variance.actualDuration;
|
||||
completedReports.push(reported);
|
||||
}
|
||||
|
||||
const { actualStart, actualEnd } = getActualReportTimes(completedReports);
|
||||
const elapsed = actualStart === null || actualEnd === null ? null : elapsedBetween(actualStart, actualEnd);
|
||||
// the budget if the user set one, otherwise what they scheduled into it
|
||||
const measuredAgainst = group.targetDuration ?? scheduledDuration;
|
||||
// a group with events still to run has not spent its budget yet: comparing
|
||||
// what it has used so far against the whole would report a large underrun
|
||||
const isComplete = eventsRun > 0 && eventsRun === eventsPlanned;
|
||||
|
||||
groups.push({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
colour: group.colour,
|
||||
targetDuration: group.targetDuration,
|
||||
scheduledDuration,
|
||||
actualStart,
|
||||
actualEnd,
|
||||
elapsed,
|
||||
// whatever the block spent with no event running
|
||||
untimed: elapsed === null ? null : Math.max(0, elapsed - ranDuration),
|
||||
variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst,
|
||||
eventsRun,
|
||||
eventsPlanned,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts across the events in the report.
|
||||
*
|
||||
* Only how much of the show ran, and which single event ran longest over its
|
||||
* schedule. Bucketing every event as over, under or on time reads as precision
|
||||
* the data does not have: almost nothing lands on the exact second.
|
||||
*/
|
||||
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
|
||||
const summary: RunSummary = {
|
||||
eventsRun: 0,
|
||||
eventsPlanned,
|
||||
worstOverrun: null,
|
||||
};
|
||||
|
||||
for (const [id, entry] of Object.entries(report)) {
|
||||
const variance = getEventVariance(entry);
|
||||
if (variance.status === 'not-run') {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.eventsRun += 1;
|
||||
|
||||
if (variance.status === 'over' && (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta)) {
|
||||
summary.worstOverrun = { id, delta: variance.delta };
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the events a run could have played.
|
||||
* Skipped events are excluded: they were never meant to run and would
|
||||
* make the completion figures read as if the show fell short.
|
||||
*/
|
||||
export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number {
|
||||
let count = 0;
|
||||
for (const id of order) {
|
||||
const entry = entries[id];
|
||||
if (entry && isOntimeEvent(entry) && !entry.skip) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user