From c91b6c020ea34e11907e8d910801ded6df0dae25 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 25 Aug 2026 21:57:15 +0200 Subject: [PATCH] refactor(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 | 113 +++---- .../__tests__/reportSettings.utils.test.ts | 168 +++++++++++ .../composite/ReportShowSummary.module.scss | 137 +++++++++ .../composite/ReportShowSummary.tsx | 132 +++++++++ .../composite/ReportTable.module.scss | 82 ++++++ .../feature-panel/composite/ReportTable.tsx | 172 +++++++++++ .../feature-panel/reportSettings.utils.ts | 105 +++++-- .../rundown-event/RundownEventInner.tsx | 1 - .../composite/RundownEventChip.tsx | 35 +-- .../report/__tests__/report.service.test.ts | 176 +++++++++++ .../src/api-data/report/report.router.ts | 8 + .../src/api-data/report/report.service.ts | 79 ++++- .../types/src/definitions/core/Report.type.ts | 79 ++++- packages/types/src/index.ts | 9 +- packages/utils/index.ts | 13 + .../src/report-utils/reportUtils.test.ts | 277 ++++++++++++++++++ .../utils/src/report-utils/reportUtils.ts | 236 +++++++++++++++ 21 files changed, 1729 insertions(+), 140 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..5077946d5 --- /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, _previousQuery) => 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..c010d8eab 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,25 +1,26 @@ +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 ReportShowSummary from './composite/ReportShowSummary'; +import ReportTable from './composite/ReportTable'; import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils'; -import style from './ReportSettings.module.scss'; - 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) { + if (combinedReport.length === 0) { return; } const csv = makeReportCSV(combinedReport); @@ -31,73 +32,49 @@ export default function ReportSettings() { return getCombinedReport(reportData, data.entries, data.flatOrder); }, [reportData, data.entries, data.flatOrder]); + const summary = useMemo(() => { + return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder)); + }, [reportData, data.entries, data.flatOrder]); + + const groups = useMemo(() => { + return getGroupReports(reportData, data.entries, data.order); + }, [reportData, data.entries, data.order]); + + const hasReport = combinedReport.length > 0; + 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..7561dd85f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -0,0 +1,168 @@ +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 }); + 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, + }); + }); + + 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..e3e6c693f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss @@ -0,0 +1,137 @@ +// a block within the panel card, matching how Info nests inside a card +.summary { + margin: 0 var(--panel-card-padding, 2rem); + padding: 1.25rem; + background-color: $gray-1200; + border-radius: 3px; + + display: flex; + flex-direction: column; + gap: 1rem; +} + +.top { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem 2.5rem; +} + +// the conclusion, given the most weight in the block +.verdict { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.verdictLabel { + font-size: calc(1rem - 3px); + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-300; +} + +.verdictValue { + font-size: 1.75rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + + &.over { + color: $playback-over; + } + + &.under { + color: $playback-under; + } + + &.none { + color: $ui-white; + } +} + +.incompleteNote { + max-width: 34rem; + color: $warning-orange; + font-size: calc(1rem - 2px); + line-height: 1.4; +} + +// label | planned -> actual | offset, so every offset lands in one column +.lines { + display: grid; + grid-template-columns: auto auto auto; + align-items: baseline; + gap: 0.375rem 1.5rem; +} + +.label { + color: $gray-300; + font-size: calc(1rem - 2px); +} + +.times { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-variant-numeric: tabular-nums; +} + +.planned { + color: $gray-300; +} + +.arrow { + color: $gray-500; +} + +.actual { + color: $ui-white; + font-weight: 600; +} + +.offset { + justify-self: end; + font-weight: 600; + font-variant-numeric: tabular-nums; + + &.over { + color: $playback-over; + } + + &.under { + color: $playback-under; + } + + &.none { + color: $gray-300; + } +} + +.counts { + 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); +} + +.count { + white-space: nowrap; + + b { + color: $ui-white; + font-weight: 600; + } +} + +.separator { + color: $gray-500; +} 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..e496bd81a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx @@ -0,0 +1,132 @@ +import { MaybeNumber, RunSummary, ShowReport } from 'ontime-types'; +import { getShowOffsets, MILLIS_PER_SECOND } from 'ontime-utils'; +import { Fragment, useMemo } from 'react'; + +import Tooltip from '../../../../../common/components/tooltip/Tooltip'; +import { cx, enDash } from '../../../../../common/utils/styleUtils'; +import { formatTime } from '../../../../../common/utils/time'; +import { formatOffset, offsetTone } from '../reportSettings.utils'; + +import style from './ReportShowSummary.module.scss'; + +interface ReportShowSummaryProps { + show: ShowReport; + summary: RunSummary; +} + +/** + * Leads the report with how the show sat against its plan. + * + * Start and end offsets are kept apart because they have different causes: a + * late start is rarely the show's doing, while time lost between the two is. + * That difference is the conclusion, so it carries the weight in the block. + */ +export default function ReportShowSummary({ show, summary }: 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. Comparing that against the planned end would report a + * large recovery for a show that never finished. + */ + const didReachEnd = summary.eventsRun > 0 && summary.eventsRun === summary.eventsPlanned; + const hasPlan = offsets.startOffset !== null; + + return ( +
+ {hasPlan && ( +
+
+ {didReachEnd ? ( + <> + } + className={style.verdictLabel} + > + {verdictLabel(offsets.duringShow)} + + + {formatOffset(offsets.duringShow)} + + + ) : ( + <> + Show incomplete + + The show did not reach the end of the rundown, so there is nothing to compare the finish against. + + + )} +
+ +
+ + {didReachEnd && ( + + )} +
+
+ )} + +
+ + + {summary.eventsRun} of {summary.eventsPlanned} + {' '} + events run + + + + +
+
+ ); +} + +function verdictLabel(duringShow: MaybeNumber): string { + if (duringShow === null || Math.abs(duringShow) < MILLIS_PER_SECOND) { + return 'Held schedule during the show'; + } + return duringShow > 0 ? 'Lost during the show' : 'Recovered during the show'; +} + +function TimeLine({ + label, + planned, + actual, + offset, +}: { + label: string; + planned: MaybeNumber; + actual: MaybeNumber; + offset: MaybeNumber; +}) { + return ( + + {label} + + {planned === null ? enDash : formatTime(planned)} + + {actual === null ? enDash : formatTime(actual)} + + {formatOffset(offset)} + + ); +} + +function Count({ label, value }: { label: string; value: number }) { + return ( + <> + · + + {value} {label} + + + ); +} 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..9ee8c08a1 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss @@ -0,0 +1,82 @@ +th.over { + color: $playback-over; +} + +th.under { + color: $playback-under; +} + +.groupRow { + background-color: $white-3; + + td { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } +} + +.groupHeading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; +} + +.groupTitle { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: $ui-white; +} + +.groupColour { + width: 0.25rem; + height: 1rem; + border-radius: 2px; + background-color: var(--group-colour, #{$gray-500}); + flex-shrink: 0; +} + +.groupStats { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem 0.625rem; + color: $gray-300; + font-variant-numeric: tabular-nums; +} + +.stat b { + color: $ui-white; + font-weight: 600; +} + +.variance { + font-weight: 600; + + &.over { + color: $playback-over; + } + + &.under { + color: $playback-under; + } + + &.none { + color: $gray-300; + } +} + +.separator { + color: $gray-500; +} + +.incomplete { + color: $warning-orange; +} + +.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..99d6aa4f6 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx @@ -0,0 +1,172 @@ +import { EntryId, GroupReport } from 'ontime-types'; +import { Fragment, 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) => ( + + {section.group && } + {section.rows.map((entry) => ( + + ))} + + ))} + + + ); +} + +function GroupRow({ group }: { group: GroupReport }) { + const isComplete = group.eventsRun === group.eventsPlanned; + const measure = group.targetDuration === null ? 'scheduled' : 'target'; + const measureValue = group.targetDuration ?? group.scheduledDuration; + + return ( + + +
+ + + {group.title || 'Untitled group'} + + + + } + className={style.stat} + > + {formatDuration(measureValue, false)} {measure} + + + · + + {group.elapsed === null ? enDash : formatDuration(group.elapsed, false)} actual + + + {group.variance !== null && ( + <> + · + + {formatOffset(group.variance)} + + + )} + + {group.changeover !== null && group.changeover > 0 && ( + <> + · + } + className={style.stat} + > + {formatDuration(group.changeover, false)} changeover + + + )} + + · + + + {group.eventsRun}/{group.eventsPlanned} + {' '} + {isComplete ? 'events' : 'events run'} + + +
+ + + ); +} + +function EventRow({ entry }: { entry: CombinedReport }) { + 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)} + + ); +} + +/** 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..1ede84fa1 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,18 @@ 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; + /** 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,7 +20,12 @@ 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, @@ -31,43 +41,71 @@ 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, + 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[][] = []; @@ -76,12 +114,15 @@ export function makeReportCSV(combinedReport: CombinedReport[]) { for (const entry of combinedReport) { csv.push([ String(entry.index), - entry.title, + entry.groupTitle, entry.cue, + entry.title, formatTime(entry.scheduledStart), - formatTime(entry.actualStart), + // 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), - formatTime(entry.actualEnd), + csvTime(entry.actualEnd), ]); } 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..10b54493c --- /dev/null +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -0,0 +1,176 @@ +import { TimerLifeCycle } from 'ontime-types'; +import type { PlayableEvent } from 'ontime-types'; +import { vi } from 'vitest'; + +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(() => { + 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('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..43198124a 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,62 @@ 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; 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..862068f26 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -1,8 +1,85 @@ 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. + * + * Follows Ontime's offset convention: positive means behind schedule. + * `duringShow` separates a late start from a badly run show, which have + * different causes and different remedies. + */ +export type ShowOffsets = { + /** actual start against planned start */ + startOffset: MaybeNumber; + /** actual end against planned end */ + endOffset: MaybeNumber; + /** time lost (positive) or recovered (negative) between start and end */ + duringShow: 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 not covered by a running event, ie changeovers. + * elapsed minus the sum of the events' own durations. + */ + changeover: MaybeNumber; + /** measured against targetDuration where set, otherwise scheduledDuration */ + variance: MaybeNumber; + eventsRun: number; + eventsPlanned: number; +}; + +/** Counts across the events in the report */ +export type RunSummary = { + eventsRun: number; + eventsPlanned: number; + eventsOver: number; + eventsUnder: number; + eventsOnTime: 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..f2f3bef12 --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.test.ts @@ -0,0 +1,277 @@ +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()', () => { + 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 * 60 * MIN, + plannedEnd: 21 * 60 * MIN, + actualStart: 19 * 60 * MIN + 8 * MIN, + actualEnd: 21 * 60 * MIN + 8 * MIN, + }); + + expect(offsets).toEqual({ startOffset: 8 * MIN, endOffset: 8 * MIN, duringShow: 0 }); + }); + + it('separates time lost during the show from a late start', () => { + const offsets = getShowOffsets({ + plannedStart: 19 * 60 * MIN, + plannedEnd: 21 * 60 * MIN, + actualStart: 19 * 60 * MIN + 8 * MIN, + actualEnd: 21 * 60 * MIN + 12 * MIN, + }); + + expect(offsets).toMatchObject({ startOffset: 8 * MIN, endOffset: 12 * MIN, duringShow: 4 * MIN }); + }); + + it('reports time recovered as a negative', () => { + const offsets = getShowOffsets({ + plannedStart: 19 * 60 * MIN, + plannedEnd: 21 * 60 * MIN, + actualStart: 19 * 60 * MIN + 10 * MIN, + actualEnd: 21 * 60 * MIN + 2 * MIN, + }); + + expect(offsets.duringShow).toBe(-8 * MIN); + }); + + it('does not read a show ending after midnight as a day early', () => { + const offsets = getShowOffsets({ + plannedStart: 23 * 60 * MIN, + plannedEnd: dayInMs - 10 * MIN, + actualStart: 23 * 60 * MIN, + actualEnd: 5 * MIN, // ran past midnight + }); + + expect(offsets.endOffset).toBe(15 * MIN); + }); + + it('has nothing to report without a plan', () => { + expect(getShowOffsets({ plannedStart: null, plannedEnd: null, actualStart: 1, actualEnd: 2 })).toEqual({ + startOffset: null, + endOffset: null, + duringShow: 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 }); + }); +}); + +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 not running an event', () => { + // 5 minute changeover between the two events + 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.changeover).toBe(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('reads a partly run block as incomplete rather than as an overrun', () => { + 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); + }); + + it('has no actuals for a block which never ran', () => { + const [group] = getGroupReports({}, entries, ['act1']); + + expect(group).toMatchObject({ elapsed: null, changeover: 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 events landed', () => { + const report: OntimeReport = { + a: makeEntry({ endedAt: 15000 }), // over + b: makeEntry({ endedAt: 6000 }), // under + c: makeEntry({ endedAt: 10000 }), // on time + }; + + expect(getRunSummary(report, 4)).toMatchObject({ + eventsRun: 3, + eventsPlanned: 4, + eventsOver: 1, + eventsUnder: 1, + eventsOnTime: 1, + }); + }); + + 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('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..e33fd4299 --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.ts @@ -0,0 +1,236 @@ +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; +}