From fa557659120d91bd5c874140524c4e0f18c7f8fe Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 25 Aug 2026 21:57:15 +0200 Subject: [PATCH] refactor(report): improve report summary --- apps/client/src/common/api/constants.ts | 1 + apps/client/src/common/api/report.ts | 10 +- .../src/common/hooks-query/useShowReport.ts | 29 ++ .../feature-panel/ReportSettings.module.scss | 7 - .../panel/feature-panel/ReportSettings.tsx | 146 ++++--- .../__tests__/reportSettings.utils.test.ts | 194 ++++++++++ .../composite/ReportShowSummary.module.scss | 138 +++++++ .../composite/ReportShowSummary.tsx | 131 +++++++ .../composite/ReportTable.module.scss | 160 ++++++++ .../feature-panel/composite/ReportTable.tsx | 195 ++++++++++ .../feature-panel/reportSettings.utils.ts | 120 +++--- .../rundown-event/RundownEventInner.tsx | 1 - .../composite/RundownEventChip.tsx | 35 +- .../report/__tests__/report.service.test.ts | 186 +++++++++ .../src/api-data/report/report.router.ts | 8 + .../src/api-data/report/report.service.ts | 80 +++- .../types/src/definitions/core/Report.type.ts | 97 ++++- packages/types/src/index.ts | 9 +- packages/utils/index.ts | 13 + .../src/report-utils/reportUtils.test.ts | 362 ++++++++++++++++++ .../utils/src/report-utils/reportUtils.ts | 306 +++++++++++++++ 21 files changed, 2066 insertions(+), 162 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useShowReport.ts delete mode 100644 apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx create mode 100644 apps/server/src/api-data/report/__tests__/report.service.test.ts create mode 100644 packages/utils/src/report-utils/reportUtils.test.ts create mode 100644 packages/utils/src/report-utils/reportUtils.ts diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index aa0551931..89caafcb7 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -20,6 +20,7 @@ export const VIEW_SETTINGS = ['viewSettings']; export const CSS_OVERRIDE = ['cssOverride']; export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; +export const REPORT_SHOW = ['report', 'show']; export const TRANSLATION = ['translation']; // API URLs diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 778545257..0d51abce5 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { OntimeReport } from 'ontime-types'; +import { OntimeReport, ShowReport } from 'ontime-types'; import { ontimeQueryClient } from '../../common/queryClient'; import { REPORT, apiEntryUrl } from './constants'; @@ -15,6 +15,14 @@ export async function fetchReport(options?: RequestOptions): Promise { + const res = await axios.get(`${reportUrl}/show`, { signal: options?.signal }); + return res.data; +} + export async function deleteReport(id: string) { await axios.delete(`${reportUrl}/${id}`); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); diff --git a/apps/client/src/common/hooks-query/useShowReport.ts b/apps/client/src/common/hooks-query/useShowReport.ts new file mode 100644 index 000000000..d496a76ba --- /dev/null +++ b/apps/client/src/common/hooks-query/useShowReport.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { ShowReport } from 'ontime-types'; +import { MILLIS_PER_HOUR } from 'ontime-utils'; + +import { REPORT_SHOW } from '../api/constants'; +import { fetchShowReport } from '../api/report'; + +const emptyShowReport: ShowReport = { + plannedStart: null, + plannedEnd: null, + actualStart: null, + actualEnd: null, +}; + +/** + * Show level times for the report. + * Invalidated by the same websocket signal as the report itself, since the + * two are read together. + */ +export default function useShowReport() { + const { data } = useQuery({ + queryKey: REPORT_SHOW, + queryFn: ({ signal }) => fetchShowReport({ signal }), + placeholderData: (previousData) => previousData, + staleTime: MILLIS_PER_HOUR, + }); + + return { data: data ?? emptyShowReport }; +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss deleted file mode 100644 index a85b4b5fe..000000000 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -th.over { - color: $playback-over; -} - -th.under { - color: $playback-under; -} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index 924bda3d4..6e4e7cfac 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -1,103 +1,97 @@ +import { isOntimeEvent } from 'ontime-types'; +import { countPlannedEvents, getGroupReports, getRunSummary } from 'ontime-utils'; import { useMemo } from 'react'; -import { IoTrashBin } from 'react-icons/io5'; +import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5'; import { deleteAllReport } from '../../../../common/api/report'; import { createBlob, downloadBlob } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import useReport from '../../../../common/hooks-query/useReport'; import useRundown from '../../../../common/hooks-query/useRundown'; -import { cx } from '../../../../common/utils/styleUtils'; -import { formatTime } from '../../../../common/utils/time'; +import useShowReport from '../../../../common/hooks-query/useShowReport'; import * as Panel from '../../panel-utils/PanelUtils'; -import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils'; - -import style from './ReportSettings.module.scss'; +import ReportShowSummary from './composite/ReportShowSummary'; +import ReportTable from './composite/ReportTable'; +import { getCombinedReport, makeReportCSV } from './reportSettings.utils'; export default function ReportSettings() { const { data: reportData } = useReport(); + const { data: showReport } = useShowReport(); const { data } = useRundown(); - const clearReport = async () => await deleteAllReport(); - const downloadCSV = (combinedReport: CombinedReport[]) => { - if (!combinedReport) { - return; + const clearReport = () => deleteAllReport(); + const combinedReport = useMemo( + () => getCombinedReport(reportData, data.entries, data.flatOrder), + [reportData, data.entries, data.flatOrder], + ); + + const summary = useMemo( + () => getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder)), + [reportData, data.entries, data.flatOrder], + ); + + const groups = useMemo( + () => getGroupReports(reportData, data.entries, data.order), + [reportData, data.entries, data.order], + ); + + let worstOverrunTitle: string | null = null; + if (summary.worstOverrun !== null) { + const entry = data.entries[summary.worstOverrun.id]; + if (entry && isOntimeEvent(entry)) { + worstOverrunTitle = entry.title; } + } + + const hasReport = combinedReport.length > 0; + const downloadCSV = () => { + if (!hasReport) return; + const csv = makeReportCSV(combinedReport); const blob = createBlob(csv, 'text/csv;charset=utf-8;'); downloadBlob(blob, 'ontime-report.csv'); }; - const combinedReport = useMemo(() => { - return getCombinedReport(reportData, data.entries, data.flatOrder); - }, [reportData, data.entries, data.flatOrder]); - return ( - Report + + Report + + + + + - - - Manage report - - - - - - - - - - - # - Cue - Title - Scheduled Start - Actual Start - Scheduled End - Actual End - - - - {combinedReport.length === 0 && ( - - )} - {combinedReport.map((entry) => { - const start = (() => { - if (entry.actualStart === null) return null; - if (entry.actualStart <= entry.scheduledStart) return 'under'; - return 'over'; - })(); - const end = (() => { - if (entry.actualEnd === null) return null; - if (entry.actualEnd <= entry.scheduledEnd) return 'under'; - return 'over'; - })(); - return ( - - {entry.index} - {entry.cue} - {entry.title} - {formatTime(entry.scheduledStart)} - {formatTime(entry.actualStart)} - {formatTime(entry.scheduledEnd)} - {formatTime(entry.actualEnd)} - - ); - })} - - - + {!hasReport ? ( + + + + ) : ( + <> + + + + + + + + )} ); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts new file mode 100644 index 000000000..369dbd0c0 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -0,0 +1,194 @@ +import { + EndAction, + OntimeEvent, + OntimeReport, + RundownEntries, + SupportedEntry, + TimeStrategy, + TimerType, +} from 'ontime-types'; + +import { formatOffset, getCombinedReport, makeReportCSV } from '../reportSettings.utils'; + +function makeEvent(patch: Partial): OntimeEvent { + return { + type: SupportedEntry.Event, + id: 'event', + flag: false, + cue: '1', + title: 'event title', + note: '', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: false, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 0, + timeEnd: 10000, + duration: 10000, + skip: false, + colour: '', + timeWarning: 0, + timeDanger: 0, + custom: {}, + triggers: [], + parent: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + ...patch, + } as OntimeEvent; +} + +describe('getCombinedReport()', () => { + it('returns an empty list when nothing has run', () => { + expect(getCombinedReport({}, {}, [])).toEqual([]); + }); + + it('measures a run event against the schedule recorded at the time', () => { + // the rundown was edited after the show, the report must not follow it + const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999, colour: '#ff0000' }); + const report: OntimeReport = { + a: { + startedAt: 100, + endedAt: 10100, + scheduledStart: 0, + scheduledDuration: 10000, + }, + }; + + const result = getCombinedReport(report, { a: entry }, ['a']); + + expect(result[0]).toMatchObject({ + scheduledStart: 0, + scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999 + actualStart: 100, + actualEnd: 10100, + colour: '#ff0000', + }); + }); + + it('falls back to the rundown for an event which has not run', () => { + const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); + const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 }); + const report: OntimeReport = { + b: { + startedAt: 10000, + endedAt: 20000, + scheduledStart: 10000, + scheduledDuration: 10000, + }, + }; + + const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']); + + expect(result[0]).toMatchObject({ + id: 'a', + scheduledStart: 0, + scheduledEnd: 10000, + actualStart: null, + actualEnd: null, + }); + }); + + it('leaves out skipped events, matching what the summary counts', () => { + const ran = makeEvent({ id: 'a' }); + const skipped = makeEvent({ id: 'b', skip: true }); + const report: OntimeReport = { + a: { + startedAt: 0, + endedAt: 10000, + scheduledStart: 0, + scheduledDuration: 10000, + }, + }; + + expect(getCombinedReport(report, { a: ran, b: skipped }, ['a', 'b']).map((row) => row.id)).toEqual(['a']); + }); + + it('skips entries which are not events', () => { + const entry = makeEvent({ id: 'a' }); + const rundownEntries: RundownEntries = { + a: entry, + delay: { + type: SupportedEntry.Delay, + id: 'delay', + duration: 1000, + parent: null, + }, + }; + const report: OntimeReport = { + a: { + startedAt: 0, + endedAt: 10000, + scheduledStart: 0, + scheduledDuration: 10000, + }, + }; + + expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']); + }); +}); + +describe('formatOffset()', () => { + it('has nothing to show without a value', () => { + expect(formatOffset(null)).toBe('–'); + }); + + it('treats a sub-second offset as on time', () => { + expect(formatOffset(500)).toBe('On time'); + }); + + it('signs the offset in both directions', () => { + expect(formatOffset(252000)).toBe('+4m12s'); + expect(formatOffset(-60000)).toBe('-1m'); + }); +}); + +describe('makeReportCSV()', () => { + it('leaves the cell empty for an event which never ran', () => { + const csv = makeReportCSV([ + { + id: 'a', + index: 1, + title: 'Welcome', + cue: '1', + parent: null, + groupTitle: '', + scheduledStart: 0, + scheduledEnd: 10000, + actualStart: null, + actualEnd: null, + }, + ]); + + // empty fields rather than a placeholder a spreadsheet would read as text + const fields = csv.trim().split('\n')[1].split(','); + expect(fields[5]).toBe(''); + expect(fields[7]).toBe(''); + expect(csv).not.toContain('...'); + }); + + it('produces a header row and one row per entry', () => { + const csv = makeReportCSV([ + { + id: 'a', + index: 1, + title: 'Welcome', + cue: '1', + parent: 'act1', + groupTitle: 'Act 1', + scheduledStart: 0, + scheduledEnd: 10000, + actualStart: 0, + actualEnd: 12000, + }, + ]); + + const rows = csv.trim().split('\n'); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain('Group'); + expect(rows[1]).toContain('Act 1'); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss new file mode 100644 index 000000000..bd6889303 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss @@ -0,0 +1,138 @@ +// Panel.Table nests its own padding inside the section it sits in, so the +// summary takes the same inset to keep one left edge down the whole panel +.inset { + padding: 0 var(--panel-card-padding, 2rem); +} + +.summary { + padding: 1.25rem; + background-color: $gray-1200; + border-radius: 3px; + + display: flex; + flex-direction: column; + gap: 1rem; +} + +.title { + margin: 0; + color: $ui-white; + font-size: 1rem; + font-weight: 600; +} + +.body { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem 2.5rem; +} + +.headline { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.headlineLabel, +.metricLabel { + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.headlineValue { + font-size: 1.75rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.unavailable { + max-width: 32rem; + color: $warning-orange; + font-size: calc(1rem - 2px); + line-height: 1.4; +} + +.metrics { + display: grid; + grid-template-columns: auto auto auto; + align-items: baseline; + gap: 0.375rem 1.5rem; + margin: 0; +} + +.metricValue, +.metricOffset { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.metricValue { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.planned, +.arrow { + color: $gray-300; +} + +.actual { + color: $ui-white; + font-weight: 600; +} + +.metricOffset { + justify-self: end; + font-weight: 600; +} + +.over { + color: $playback-over; +} + +.under { + color: $playback-under; +} + +.none { + color: $gray-300; +} + +.footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + + padding-top: 0.875rem; + border-top: 1px solid $white-10; + + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.footerItem { + white-space: nowrap; + + & + &::before { + content: '·'; + margin-right: 0.75rem; + color: $gray-500; + } + + b { + color: $ui-white; + font-weight: 600; + } +} + +.overrun { + color: $playback-over; + font-weight: 600; + font-variant-numeric: tabular-nums; +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx new file mode 100644 index 000000000..92b5dec2b --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx @@ -0,0 +1,131 @@ +import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types'; +import { getShowOffsets } from 'ontime-utils'; +import { useMemo } from 'react'; + +import { cx, enDash } from '../../../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../../../common/utils/time'; +import { formatOffset, offsetTone } from '../reportSettings.utils'; + +import style from './ReportShowSummary.module.scss'; + +interface ReportShowSummaryProps { + rundownTitle: string; + show: ShowReport; + summary: RunSummary; + worstOverrunTitle: string | null; +} + +/** + * Leads the report with whether the show ran to the length it was planned for. + * + * Running time is the headline rather than finishing time because it is the + * part the team controls and the part that carries into the next run of the + * same rundown. Finishing time is the other question a report is asked, and + * the two can point opposite ways, so it stays beside it as its own row + * rather than being folded into a single figure. + */ +export default function ReportShowSummary({ rundownTitle, show, summary, worstOverrunTitle }: ReportShowSummaryProps) { + const offsets = useMemo(() => getShowOffsets(show), [show]); + + /** + * A show that stopped early has no meaningful end: its last event is simply + * where it got to. Measuring that against the plan would report a show which + * never finished as having come in comfortably short. + */ + const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned; + const hasPlan = offsets.startOffset !== null; + + return ( +
+
+

+ {rundownTitle || 'Untitled rundown'} +

+ +
+
+ {didReachEnd ? 'Against planned duration' : 'Show incomplete'} + {didReachEnd && offsets.durationOffset !== null ? ( + + {formatOffset(offsets.durationOffset)} + + ) : ( + + The show did not reach the end of the rundown, so there is nothing to measure it against. + + )} +
+ +
+ {hasPlan && ( + + )} + {hasPlan && didReachEnd && ( + + )} + {didReachEnd && ( + + )} +
+
+ + {summary.worstOverrun !== null && ( +
+ + longest overrun {worstOverrunTitle || 'an event'}{' '} + +{formatDuration(summary.worstOverrun.delta, false)} + +
+ )} +
+
+ ); +} + +function Metric({ + label, + planned, + actual, + offset, +}: { + label: string; + planned: string; + actual: string; + offset?: MaybeNumber; +}) { + return ( + <> +
{label}
+
+ {planned} + + {actual} +
+
+ {offset === undefined ? '' : formatOffset(offset)} +
+ + ); +} + +function formatMaybeTime(value: MaybeNumber): string { + return value === null ? enDash : formatTime(value); +} + +function formatMaybeDuration(value: MaybeNumber): string { + return value === null ? enDash : formatDuration(value, false); +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss new file mode 100644 index 000000000..b8e6c5acb --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss @@ -0,0 +1,160 @@ +$rail: var(--user-bg, #{$gray-500}); +$event-wash: var(--event-bg, transparent); + +// event rows carry their values in td, not th: the panel's th styling is meant +// for column headings and renders whatever it holds small, bold and upper case +td.over { + color: $playback-over; +} + +td.under { + color: $playback-under; +} + +.eventRow td { + background-color: color-mix(in srgb, #{$gray-1300} 96%, #{$event-wash} 4%); +} + +.groupedRow td:first-child { + box-shadow: inset 2px 0 $rail; + // clear the rail rather than sitting against it + padding-left: 0.75rem; +} + +.groupRow > * { + background-color: color-mix(in srgb, #{$gray-1300} 88%, #{$rail} 12%); + vertical-align: top; +} + +.groupSpacer { + height: 0.75rem; + background: transparent !important; + + td { + height: 0.75rem; + padding: 0; + background: transparent !important; + } +} + +.groupSummary { + padding: 0.875rem 1rem; + box-shadow: inset 4px 0 $rail; + + text-align: left; + text-transform: none; + letter-spacing: normal; + + > * { + text-transform: none; + } +} + +.groupTitle, +.groupLabel, +.groupValues, +.groupFooter { + display: block; +} + +.groupTitle { + color: $ui-white; + font-size: 1rem; + font-weight: 600; + text-transform: none; +} + +.groupBody { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem 2.5rem; + margin-top: 0.75rem; +} + +.groupHeadline { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.groupLabel, +.groupMetrics dt, +.groupFooter { + color: $gray-400; + font-size: calc(1rem - 3px); + font-weight: 400; +} + +.groupHeadlineValue { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.groupMetrics { + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: 0.375rem 1.5rem; + margin: 0; + font-variant-numeric: tabular-nums; +} + +.groupMetrics dd { + margin: 0; +} + +.groupValues { + display: flex; + gap: 0.5rem; + color: $gray-300; + + b { + color: $ui-white; + font-weight: 600; + } +} + +.groupFooter { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid $white-10; + + b { + color: $ui-white; + font-weight: 600; + } +} + +.unavailable { + color: $gray-400; + font-size: calc(1rem - 3px); + font-weight: 400; +} + +.arrow { + color: $gray-500; +} + +.over { + color: $playback-over; +} + +.under { + color: $playback-under; +} + +.none { + color: $ui-white; +} + +.eventCue, +.eventIndex { + color: $gray-300; +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx new file mode 100644 index 000000000..fc84ce1ac --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx @@ -0,0 +1,195 @@ +import { EntryId, GroupReport } from 'ontime-types'; +import { useMemo } from 'react'; + +import Tooltip from '../../../../../common/components/tooltip/Tooltip'; +import { cx, enDash } from '../../../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../../../common/utils/time'; +import * as Panel from '../../../panel-utils/PanelUtils'; +import { CombinedReport, formatOffset, offsetTone } from '../reportSettings.utils'; + +import style from './ReportTable.module.scss'; + +interface ReportTableProps { + rows: CombinedReport[]; + groups: GroupReport[]; +} + +/** + * The report laid out the way the show was planned: blocks, then the events + * inside them. Each block carries how it ran against the budget set for it. + */ +export default function ReportTable({ rows, groups }: ReportTableProps) { + // groups are rendered where their first event appears, so the table follows + // the rundown rather than a separate ordering + const sections = useMemo(() => makeSections(rows, groups), [rows, groups]); + + return ( + + + + # + Cue + Title + Scheduled Start + Actual Start + Scheduled End + Actual End + + + {sections.map((section, index) => ( + + {section.group && index > 0 && } + {section.group && } + {section.rows.map((entry) => ( + + ))} + + ))} + + ); +} + +function GroupSpacer() { + return ( + + + + ); +} + +/** + * A group read the same way as the show above it: what it was measured + * against, what it actually did, and how much of it ran. + */ +function GroupRow({ group }: { group: GroupReport }) { + const isComplete = group.eventsRun === group.eventsPlanned; + const hasTarget = group.targetDuration !== null; + const measuredAgainst = group.targetDuration ?? group.scheduledDuration; + const unavailableReason = isComplete ? 'Did not run' : 'Still running'; + + return ( + + + {group.title || 'Untitled group'} +
+
+ {hasTarget ? 'Against target' : 'Against schedule'} + {group.variance === null ? ( + {unavailableReason} + ) : ( + + {formatOffset(group.variance)} + + )} +
+
+
{hasTarget ? 'Target' : 'Scheduled'}
+
+ {formatDuration(measuredAgainst, false)} + + {group.elapsed === null ? enDash : formatDuration(group.elapsed, false)} +
+ {group.actualStart !== null && group.actualEnd !== null && ( + <> +
Ran
+
+ {formatTime(group.actualStart)} + + {formatTime(group.actualEnd)} +
+ + )} +
+
+ {group.untimed !== null && group.untimed > 0 && ( + + } + > + {formatDuration(group.untimed, false)} untimed + + + )} + + + ); +} + +function EventRow({ entry, groupColour, grouped }: { entry: CombinedReport; groupColour?: string; grouped: boolean }) { + const start = punctuality(entry.actualStart, entry.scheduledStart); + const end = punctuality(entry.actualEnd, entry.scheduledEnd); + + return ( + + {entry.index} + {entry.cue} + {entry.title} + {formatTime(entry.scheduledStart)} + {formatTime(entry.actualStart)} + {formatTime(entry.scheduledEnd)} + {formatTime(entry.actualEnd)} + + ); +} + +/** + * Uses the cuesheet's custom property for group colour. Left unset when the + * group has none so the stylesheet can provide a neutral edge. + */ +function groupColourStyle(colour?: string): React.CSSProperties { + return { '--user-bg': colour || undefined } as React.CSSProperties; +} + +/** Keeps the event wash distinct from its parent group's identifying rail. */ +function eventColours(eventColour: string, groupColour?: string): React.CSSProperties { + return { + '--event-bg': eventColour || undefined, + '--user-bg': groupColour || undefined, + } as React.CSSProperties; +} + +/** Whether an actual time landed before (under) or after (over) its schedule */ +function punctuality(actual: number | null, scheduled: number): 'under' | 'over' | null { + if (actual === null) return null; + return actual <= scheduled ? 'under' : 'over'; +} + +type Section = { + key: string; + group: GroupReport | null; + rows: CombinedReport[]; +}; + +/** + * Splits the rows into the blocks they belong to, keeping rundown order and + * leaving ungrouped events in their own run of rows. + */ +function makeSections(rows: CombinedReport[], groups: GroupReport[]): Section[] { + const byId = new Map(groups.map((group) => [group.id, group])); + const sections: Section[] = []; + let current: Section | null = null; + + let currentParent: EntryId | null | undefined; + + for (const row of rows) { + if (current === null || row.parent !== currentParent) { + currentParent = row.parent; + // index keeps the key unique even if a group were to appear twice + current = { + key: `${row.parent ?? 'ungrouped'}-${sections.length}`, + group: row.parent ? (byId.get(row.parent) ?? null) : null, + rows: [], + }; + sections.push(current); + } + current.rows.push(row); + } + + return sections; +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts index 75de3c57b..f471948eb 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts @@ -1,13 +1,19 @@ import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; +import { MILLIS_PER_SECOND } from 'ontime-utils'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; -import { formatTime } from '../../../../common/utils/time'; +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; scheduledEnd: number; @@ -15,15 +21,19 @@ export type CombinedReport = { }; /** - * Creates a combined report with the rundown data + * 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) return []; - if (flatOrder.length === 0) return []; + if (Object.keys(report).length === 0 || flatOrder.length === 0) return []; const combinedReport: CombinedReport[] = []; @@ -31,59 +41,77 @@ export function getCombinedReport( for (let i = 0; i < flatOrder.length; i++) { const id = flatOrder[i]; const entry = rundown[id]; - if (!entry || !isOntimeEvent(entry)) continue; + // 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; - if (!(id in report)) { - combinedReport.push({ - id: id, - index: index, - title: entry.title, - cue: entry.cue, - scheduledStart: entry.timeStart, - actualEnd: null, - scheduledEnd: entry.timeEnd, - actualStart: null, - }); - } + const parent = entry.parent; + const group = parent ? rundown[parent] : undefined; + const reported = report[id]; - if (id in report) { - combinedReport.push({ - id: id, - index: index, - title: entry.title, - cue: entry.cue, - scheduledStart: entry.timeStart, - actualEnd: report[id].endedAt, - scheduledEnd: entry.timeEnd, - actualStart: report[id].startedAt, - }); - } + combinedReport.push({ + id, + index, + title: entry.title, + cue: entry.cue, + colour: entry.colour, + parent, + groupTitle: group && 'title' in 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: reported?.scheduledStart ?? entry.timeStart, + scheduledEnd: reported ? reported.scheduledStart + reported.scheduledDuration : entry.timeEnd, + actualStart: reported?.startedAt ?? null, + actualEnd: reported?.endedAt ?? null, + }); index++; } return combinedReport; } -const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; +/** + * 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)}`; +} + +/** Whether an offset is behind, ahead, or neither, for colouring */ +export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' { + if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none'; + return value > 0 ? 'over' : 'under'; +} + +/** @private */ +function csvTime(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 + * 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: string[][] = []; - csv.push(csvHeader); + const csv = combinedReport.map((entry) => [ + String(entry.index), + entry.groupTitle, + entry.cue, + entry.title, + formatTime(entry.scheduledStart), + // an event that never ran leaves the cell empty rather than a + // placeholder, so a spreadsheet reads it as missing + csvTime(entry.actualStart), + formatTime(entry.scheduledEnd), + csvTime(entry.actualEnd), + ]); - for (const entry of combinedReport) { - csv.push([ - String(entry.index), - entry.title, - entry.cue, - formatTime(entry.scheduledStart), - formatTime(entry.actualStart), - formatTime(entry.scheduledEnd), - formatTime(entry.actualEnd), - ]); - } - - return makeCSVFromArrayOfArrays(csv); + return makeCSVFromArrayOfArrays([csvHeader, ...csv]); } diff --git a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx index a0b53e715..192431633 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx @@ -141,7 +141,6 @@ function RundownEventInner({ isPast={isPast} isLoaded={loaded} totalGap={totalGap} - duration={duration} /> )}
diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx index 55cde6fcd..d7c0e6ddb 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -1,5 +1,5 @@ import { Day } from 'ontime-types'; -import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils'; +import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils'; import { useMemo } from 'react'; import { IoCheckmarkCircle } from 'react-icons/io5'; @@ -20,7 +20,6 @@ interface RundownEventChipProps { isLoaded: boolean; className: string; totalGap: number; - duration: number; isLinkedToLoaded: boolean; } @@ -33,7 +32,6 @@ export default function RundownEventChip({ className, totalGap, id, - duration, isLinkedToLoaded, }: RundownEventChipProps) { const playback = usePlayback(); @@ -45,7 +43,7 @@ export default function RundownEventChip({ const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return ; + return ; } if (playbackActive) { @@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: interface EventReportProps { className: string; id: string; - duration: number; } function EventReport(props: EventReportProps) { - const { className, id, duration } = props; + const { className, id } = props; const { data } = useReport(); const currentReport = data[id]; const [value, overUnderStyle, tooltip] = useMemo(() => { - if (!currentReport) { + // measured against the schedule recorded when the event ran, so this + // agrees with the report panel and survives later rundown edits + const variance = getEventVariance(currentReport); + if (variance.status === 'not-run') { return [null, 'none', '']; } - const { startedAt, endedAt } = currentReport; - if (!startedAt || !endedAt) { - return [null, 'none', '']; - } - - const actualDuration = endedAt - startedAt; - const difference = actualDuration - duration; - const absDifference = Math.abs(difference); - - if (absDifference < MILLIS_PER_SECOND) { + if (variance.status === 'ontime') { return ['ontime', 'under', 'Event finished on time']; } - const isOver = difference > 0; - + const absDifference = Math.abs(variance.delta); + const isOver = variance.status === 'over'; const fullTimeValue = millisToString(absDifference); - const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; - const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; - return [value, isOver ? 'over' : 'under', tooltip]; - }, [currentReport, duration]); + return [value, variance.status, tooltip]; + }, [currentReport]); if (!value) { return null; diff --git a/apps/server/src/api-data/report/__tests__/report.service.test.ts b/apps/server/src/api-data/report/__tests__/report.service.test.ts new file mode 100644 index 000000000..82ac4d29e --- /dev/null +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -0,0 +1,186 @@ +import { RefetchKey, TimerLifeCycle } from 'ontime-types'; +import type { PlayableEvent } from 'ontime-types'; +import { vi } from 'vitest'; + +import { sendRefetch } from '../../../adapters/WebsocketAdapter.js'; +import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; +import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; +import { clear, generate, generateShowReport, triggerReportEntry } from '../report.service.js'; + +vi.mock('../../../adapters/WebsocketAdapter.js', () => ({ + sendRefetch: vi.fn(), +})); + +const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent; +const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent; + +beforeEach(() => { + vi.clearAllMocks(); + clear(); +}); + +describe('triggerReportEntry()', () => { + it('snapshots the schedule when an event starts', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + + expect(generate()[eventA.id]).toEqual({ + startedAt: 500, + endedAt: null, + scheduledStart: eventA.timeStart, + scheduledDuration: eventA.duration, + }); + }); + + it('notifies clients when an event starts', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 }); + + triggerReportEntry(TimerLifeCycle.onStart, state); + + expect(sendRefetch).toHaveBeenCalledWith(RefetchKey.Report); + }); + + it('keeps the snapshot taken at start when the event stops', () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toMatchObject({ + startedAt: 0, + endedAt: 12000, + scheduledStart: eventA.timeStart, + scheduledDuration: eventA.duration, + }); + }); + + it('records the schedule as it was, not as it later becomes', () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + // the event is edited to a different duration, then stopped + const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent; + const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id].scheduledDuration).toBe(10000); + }); + + it('falls back to the current event when a stop arrives with no start', () => { + const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toMatchObject({ + startedAt: null, + endedAt: 10000, + scheduledDuration: eventA.duration, + }); + }); + + it('starts a fresh report when a new show begins', () => { + // a rehearsal ran earlier and left its numbers behind + const rehearsal = makeRuntimeStateData({ + eventNow: eventA, + timer: { startedAt: 0 }, + clock: 0, + _startEpoch: 1000, + }); + triggerReportEntry(TimerLifeCycle.onStart, rehearsal); + triggerReportEntry(TimerLifeCycle.onStop, { ...rehearsal, clock: 20000 } as typeof rehearsal); + expect(generate()[eventA.id].endedAt).toBe(20000); + + // the performance is a different show and must not inherit them + const show = makeRuntimeStateData({ + eventNow: eventB, + timer: { startedAt: 0 }, + clock: 0, + _startEpoch: 9999, + }); + triggerReportEntry(TimerLifeCycle.onStart, show); + + expect(generate()[eventA.id]).toBeUndefined(); + expect(generate()[eventB.id]).toBeDefined(); + }); + + it('keeps accumulating within the same show', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: 1000 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); + + const next = makeRuntimeStateData({ + eventNow: eventB, + timer: { startedAt: 10000 }, + clock: 10000, + _startEpoch: 1000, + }); + triggerReportEntry(TimerLifeCycle.onStart, next); + + // same show, so the earlier event is still part of the report + expect(Object.keys(generate())).toHaveLength(2); + }); + + it('ignores events without an id', () => { + const state = makeRuntimeStateData({ eventNow: null }); + triggerReportEntry(TimerLifeCycle.onStart, state); + expect(generate()).toEqual({}); + }); +}); + +describe('generateShowReport()', () => { + it('captures the plan the show was measured against', () => { + const state = makeRuntimeStateData({ + eventNow: eventA, + timer: { startedAt: 0 }, + clock: 0, + _startEpoch: 1000, + rundown: { plannedStart: 68400000, plannedEnd: 75600000 }, + }); + triggerReportEntry(TimerLifeCycle.onStart, state); + + expect(generateShowReport()).toMatchObject({ plannedStart: 68400000, plannedEnd: 75600000 }); + }); + + it('keeps the plan captured at start when the rundown is edited later', () => { + const start = makeRuntimeStateData({ + eventNow: eventA, + timer: { startedAt: 0 }, + clock: 0, + _startEpoch: 1000, + rundown: { plannedStart: 68400000, plannedEnd: 75600000 }, + }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + // the rundown is reworked mid show, the plan it started against stands + const edited = { ...start, rundown: { ...start.rundown, plannedEnd: 99999999 } } as typeof start; + triggerReportEntry(TimerLifeCycle.onStop, { ...edited, clock: 10000 } as typeof start); + + expect(generateShowReport().plannedEnd).toBe(75600000); + }); + + it('derives actual times from the events that ran', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500, _startEpoch: 1 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 12000 } as typeof state); + + expect(generateShowReport()).toMatchObject({ actualStart: 500, actualEnd: 12000 }); + }); + + it('has no times before anything runs', () => { + expect(generateShowReport()).toEqual({ + plannedStart: null, + plannedEnd: null, + actualStart: null, + actualEnd: null, + }); + }); +}); + +describe('clear()', () => { + it('clears a single event', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + clear(eventA.id); + expect(generate()).toEqual({}); + }); +}); diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index 94b35dc42..d2220ceee 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -10,6 +10,14 @@ router.get('/', (_req: Request, res: Response) => { res.status(200).json(report.generate()); }); +/** + * Show level times, kept separate so the report payload stays as it was + * for the integrations that already read it. + */ +router.get('/show', (_req: Request, res: Response) => { + res.status(200).json(report.generateShowReport()); +}); + router.delete('/all', (_req: Request, res: Response) => { report.clear(); res.status(204).send(); diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index f774bfe03..db1a12e95 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,4 +1,5 @@ -import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types'; +import { OntimeEventReport, OntimeReport, RefetchKey, ShowReport, TimerLifeCycle } from 'ontime-types'; +import { getActualShowTimes } from 'ontime-utils'; import { DeepReadonly } from 'ts-essentials'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; @@ -8,6 +9,20 @@ const report = new Map(); let formattedReport: OntimeReport | null = null; +/** + * Identifies the show the current report belongs to. + * The report describes one run, so starting a new show begins a fresh one + * rather than mixing a rehearsal into the numbers for the performance. + */ +let currentShowStart: number | null = null; + +/** + * The plan the show was measured against, taken when it starts. + * Snapshotted for the same reason the per event schedule is: editing the + * rundown afterwards must not move the target a past show was judged by. + */ +let plannedTimes: Pick = { plannedStart: null, plannedEnd: null }; + /** * generates a full report * @returns full report @@ -27,9 +42,14 @@ export function clear(id?: string) { formattedReport = null; if (id) { report.delete(id); - } else { - report.clear(); + return; } + + // clearing everything also forgets which show the report described, so the + // next event starts a report rather than resuming the one just discarded + report.clear(); + currentShowStart = null; + plannedTimes = { plannedStart: null, plannedEnd: null }; } /** @@ -49,15 +69,63 @@ export function triggerReportEntry( const eventId = state.eventNow.id; if (cycle === TimerLifeCycle.onStart) { - report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null }); + startShowIfNew(state); + + report.set(eventId, { + startedAt: state.timer.startedAt, + endedAt: null, + // snapshot the schedule so later rundown edits cannot change how a show + // that already happened is reported + scheduledStart: state.eventNow.timeStart, + scheduledDuration: state.eventNow.duration, + }); formattedReport = null; + sendRefetch(RefetchKey.Report); return; } if (cycle === TimerLifeCycle.onStop) { - const startedAt = report.get(eventId)?.startedAt ?? null; - report.set(eventId, { startedAt, endedAt: state.clock }); + const previous = report.get(eventId); + report.set(eventId, { + startedAt: previous?.startedAt ?? null, + endedAt: state.clock, + scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart, + scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration, + }); formattedReport = null; sendRefetch(RefetchKey.Report); } } + +/** + * Clears the report when a new show begins. + * + * The runtime stamps a show when its first event starts, so a change of stamp + * means the previous report described a different run. Without this the report + * would accumulate across rehearsals and performances with no way to tell + * which numbers belonged to which. + * @private + */ +function startShowIfNew(state: DeepReadonly) { + const showStart = state._startEpoch ?? state.rundown.actualStart; + if (showStart === null || showStart === currentShowStart) { + return; + } + + report.clear(); + formattedReport = null; + currentShowStart = showStart; + plannedTimes = { + plannedStart: state.rundown.plannedStart, + plannedEnd: state.rundown.plannedEnd, + }; +} + +/** + * Show level times for the report. + * Planned times are the ones captured when the show started, actual times are + * derived from the events that ran. + */ +export function generateShowReport(): ShowReport { + return { ...plannedTimes, ...getActualShowTimes(generate()) }; +} diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index c34a0978d..877d15517 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -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; +export type OntimeReport = Record; + +/** + * 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; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..8e020fb37 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index eff99e854..9d542bb5f 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -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, diff --git a/packages/utils/src/report-utils/reportUtils.test.ts b/packages/utils/src/report-utils/reportUtils.test.ts new file mode 100644 index 000000000..fe774cf37 --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.test.ts @@ -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 { + return { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, ...patch }; +} + +function makeEvent(id: string, patch: Record = {}) { + return { type: SupportedEntry.Event, id, duration: 10000, skip: false, parent: null, ...patch } as never; +} + +function makeGroup(id: string, entries: string[], patch: Record = {}) { + 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); + }); +}); diff --git a/packages/utils/src/report-utils/reportUtils.ts b/packages/utils/src/report-utils/reportUtils.ts new file mode 100644 index 000000000..25892bae3 --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.ts @@ -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 { + 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 { + const origins = new Set(); + 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; +}