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 { 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; }