mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 04:19:12 +00:00
237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
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;
|
|
}
|