mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-14 18:43:09 +00:00
242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
import type { EntryId, MaybeNumber, OntimeGroup, OntimeReport, RundownEntries, ShowReport } from 'ontime-types';
|
|
import { isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
|
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
|
|
|
|
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
|
|
import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report';
|
|
import { enDash } from '../../../../common/utils/styleUtils';
|
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
|
|
|
export type CombinedReport = {
|
|
id: EntryId;
|
|
index: number;
|
|
title: string;
|
|
cue: string;
|
|
colour: string;
|
|
/** the group this event belongs to, so the report can mirror the rundown */
|
|
parent: EntryId | null;
|
|
groupTitle: string;
|
|
scheduledStart: number;
|
|
actualStart: MaybeNumber;
|
|
startOffset: MaybeNumber;
|
|
scheduledEnd: number;
|
|
actualEnd: MaybeNumber;
|
|
endOffset: MaybeNumber;
|
|
};
|
|
|
|
type ShowOffsets = {
|
|
startOffset: MaybeNumber;
|
|
endOffset: MaybeNumber;
|
|
durationOffset: MaybeNumber;
|
|
};
|
|
|
|
export type GroupReport = {
|
|
id: EntryId;
|
|
title: string;
|
|
colour: string;
|
|
targetDuration: MaybeNumber;
|
|
scheduledDuration: number;
|
|
actualStart: MaybeNumber;
|
|
actualEnd: MaybeNumber;
|
|
elapsed: MaybeNumber;
|
|
variance: MaybeNumber;
|
|
eventsRun: number;
|
|
eventsPlanned: number;
|
|
};
|
|
|
|
export type RunSummary = {
|
|
eventsRun: number;
|
|
eventsPlanned: number;
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
rundown: RundownEntries,
|
|
flatOrder: EntryId[],
|
|
): CombinedReport[] {
|
|
if (Object.keys(report).length === 0 || flatOrder.length === 0) return [];
|
|
|
|
const combinedReport: CombinedReport[] = [];
|
|
|
|
let index = 1;
|
|
for (const id of flatOrder) {
|
|
const entry = rundown[id];
|
|
// skipped events were never meant to run, listing them alongside events
|
|
// that did would also disagree with the summary, which excludes them
|
|
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
|
|
|
|
const parent = entry.parent;
|
|
const group = parent ? rundown[parent] : undefined;
|
|
const reported = report[id];
|
|
const scheduledStart = reported?.scheduledStart ?? entry.timeStart;
|
|
const scheduledDay = reported?.scheduledDay ?? entry.dayOffset;
|
|
const scheduledStartPosition = getReportTimePosition(scheduledStart, scheduledDay);
|
|
const actualStartPosition = reported ? getReportTimePosition(reported.startedAt, reported.startedAtDay) : null;
|
|
const actualEndPosition = reported ? getReportTimePosition(reported.endedAt, reported.endedAtDay) : null;
|
|
const scheduledDuration = reported?.scheduledDuration ?? entry.duration;
|
|
|
|
combinedReport.push({
|
|
id,
|
|
index,
|
|
title: entry.title,
|
|
cue: entry.cue,
|
|
colour: entry.colour,
|
|
parent,
|
|
groupTitle: group && isOntimeGroup(group) ? group.title : '',
|
|
// an event that ran is measured against the plan it ran on, one that
|
|
// did not has no snapshot and falls back to the rundown
|
|
scheduledStart,
|
|
scheduledEnd: scheduledStart + scheduledDuration,
|
|
actualStart: reported?.startedAt ?? null,
|
|
startOffset: getOffset(actualStartPosition, scheduledStartPosition),
|
|
actualEnd: reported?.endedAt ?? null,
|
|
endOffset: getOffset(actualEndPosition, scheduledStartPosition + scheduledDuration),
|
|
});
|
|
index++;
|
|
}
|
|
|
|
return combinedReport;
|
|
}
|
|
|
|
function getOffset(actual: MaybeNumber, scheduled: number): MaybeNumber {
|
|
return actual === null ? null : actual - scheduled;
|
|
}
|
|
|
|
export function getShowOffsets(show: ShowReport): ShowOffsets {
|
|
const { plannedDuration, actualDuration } = show;
|
|
return {
|
|
startOffset: getWallClockOffset(show.plannedStart, show.actualStart),
|
|
endOffset: getWallClockOffset(show.plannedEnd, show.actualEnd),
|
|
durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration,
|
|
};
|
|
}
|
|
|
|
function getWallClockOffset(planned: MaybeNumber, actual: MaybeNumber): MaybeNumber {
|
|
if (planned === null || actual === null) return null;
|
|
|
|
const offset = actual - planned;
|
|
if (offset < -dayInMs / 2) return offset + dayInMs;
|
|
if (offset > dayInMs / 2) return offset - dayInMs;
|
|
return offset;
|
|
}
|
|
|
|
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)) groups.push(getGroupReport(group, report, entries));
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function getGroupReport(group: OntimeGroup, report: OntimeReport, entries: RundownEntries): GroupReport {
|
|
let scheduledDuration = 0;
|
|
let eventsPlanned = 0;
|
|
let eventsRun = 0;
|
|
let firstStart = Number.POSITIVE_INFINITY;
|
|
let lastEnd = Number.NEGATIVE_INFINITY;
|
|
let actualStart: MaybeNumber = null;
|
|
let actualEnd: MaybeNumber = null;
|
|
|
|
for (const childId of group.entries) {
|
|
const child = entries[childId];
|
|
if (!child || !isOntimeEvent(child) || child.skip) continue;
|
|
|
|
eventsPlanned += 1;
|
|
const reported = report[childId];
|
|
scheduledDuration += reported?.scheduledDuration ?? child.duration;
|
|
|
|
const variance = getEventVariance(reported);
|
|
if (variance.actualDuration === null || !reported) continue;
|
|
|
|
eventsRun += 1;
|
|
const start = getReportTimePosition(reported.startedAt, reported.startedAtDay);
|
|
const end = getReportTimePosition(reported.endedAt, reported.endedAtDay);
|
|
if (start !== null && start < firstStart) {
|
|
firstStart = start;
|
|
actualStart = reported.startedAt;
|
|
}
|
|
if (end !== null && end > lastEnd) {
|
|
lastEnd = end;
|
|
actualEnd = reported.endedAt;
|
|
}
|
|
}
|
|
|
|
const elapsed = actualStart === null || actualEnd === null ? null : lastEnd - firstStart;
|
|
const measuredAgainst = group.targetDuration ?? scheduledDuration;
|
|
const isComplete = eventsRun > 0 && eventsRun === eventsPlanned;
|
|
|
|
return {
|
|
id: group.id,
|
|
title: group.title,
|
|
colour: group.colour,
|
|
targetDuration: group.targetDuration,
|
|
scheduledDuration,
|
|
actualStart,
|
|
actualEnd,
|
|
elapsed,
|
|
variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst,
|
|
eventsRun,
|
|
eventsPlanned,
|
|
};
|
|
}
|
|
|
|
export function getRunSummary(report: OntimeReport, entries: RundownEntries, order: EntryId[]): RunSummary {
|
|
const eventsPlanned = order.filter((id) => {
|
|
const entry = entries[id];
|
|
return entry && isOntimeEvent(entry) && !entry.skip;
|
|
}).length;
|
|
const eventsRun = Object.values(report).filter((entry) => getEventVariance(entry).status !== 'not-run').length;
|
|
return { eventsRun, eventsPlanned };
|
|
}
|
|
|
|
/**
|
|
* Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that
|
|
* positive means behind schedule.
|
|
*/
|
|
export function formatOffset(value: MaybeNumber): string {
|
|
if (value === null) return enDash;
|
|
if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time';
|
|
return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`;
|
|
}
|
|
|
|
export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' {
|
|
if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none';
|
|
return value > 0 ? 'over' : 'under';
|
|
}
|
|
|
|
function formatCsvTime(value: MaybeNumber): string {
|
|
return value === null ? '' : formatTime(value);
|
|
}
|
|
|
|
const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
|
|
|
/**
|
|
* Transforms a CombinedReport into a CSV string.
|
|
*
|
|
* Exported as one row per event with its group named, rather than with
|
|
* rollups baked in, so it stays the dataset a report is built from.
|
|
*/
|
|
export function makeReportCSV(combinedReport: CombinedReport[]) {
|
|
const csv = combinedReport.map((entry) => [
|
|
String(entry.index),
|
|
entry.groupTitle,
|
|
entry.cue,
|
|
entry.title,
|
|
formatTime(entry.scheduledStart),
|
|
formatCsvTime(entry.actualStart),
|
|
formatTime(entry.scheduledEnd),
|
|
formatCsvTime(entry.actualEnd),
|
|
]);
|
|
|
|
return makeCSVFromArrayOfArrays([csvHeader, ...csv]);
|
|
}
|