diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 778545257..1b25c4f9d 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 type { LastRunReport } from 'ontime-types'; import { ontimeQueryClient } from '../../common/queryClient'; import { REPORT, apiEntryUrl } from './constants'; @@ -8,18 +8,13 @@ import type { RequestOptions } from './requestOptions'; export const reportUrl = `${apiEntryUrl}/report`; /** - * HTTP request to fetch all reports + * HTTP request to fetch the one report retained from the latest run. */ -export async function fetchReport(options?: RequestOptions): Promise { - const res = await axios.get(reportUrl, { signal: options?.signal }); +export async function fetchLastRunReport(options?: RequestOptions): Promise { + const res = await axios.get(`${reportUrl}/last-run`, { signal: options?.signal }); return res.data; } -export async function deleteReport(id: string) { - await axios.delete(`${reportUrl}/${id}`); - await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); -} - export async function deleteAllReport() { await axios.delete(`${reportUrl}/all`); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); diff --git a/apps/client/src/common/hooks-query/useReport.ts b/apps/client/src/common/hooks-query/useReport.ts index 9f4510a52..2298bf71c 100644 --- a/apps/client/src/common/hooks-query/useReport.ts +++ b/apps/client/src/common/hooks-query/useReport.ts @@ -1,17 +1,30 @@ import { useQuery } from '@tanstack/react-query'; -import { OntimeReport } from 'ontime-types'; +import type { LastRunReport } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { REPORT } from '../api/constants'; -import { fetchReport } from '../api/report'; +import { fetchLastRunReport } from '../api/report'; + +const emptyLastRunReport: LastRunReport = { + eventReports: {}, + rundown: null, + show: { + plannedStart: null, + plannedEnd: null, + plannedDuration: null, + actualStart: null, + actualEnd: null, + actualDuration: null, + }, +}; export default function useReport() { - const { data, refetch } = useQuery({ + const { data } = useQuery({ queryKey: REPORT, - queryFn: ({ signal }) => fetchReport({ signal }), - placeholderData: (previousData, _previousQuery) => previousData, + queryFn: ({ signal }) => fetchLastRunReport({ signal }), + placeholderData: (previousData) => previousData, staleTime: MILLIS_PER_HOUR, }); - return { data: data ?? {}, refetch }; + return { data: data ?? emptyLastRunReport }; } diff --git a/apps/client/src/common/utils/__tests__/report.test.ts b/apps/client/src/common/utils/__tests__/report.test.ts new file mode 100644 index 000000000..879313964 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/report.test.ts @@ -0,0 +1,23 @@ +import type { OntimeEventReport } from 'ontime-types'; +import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils'; + +import { getEventVariance } from '../report'; + +it('uses captured days when measuring an event across midnight', () => { + const report: OntimeEventReport = { + startedAt: dayInMs - 5 * MILLIS_PER_MINUTE, + startedAtDay: 0, + endedAt: 5 * MILLIS_PER_MINUTE, + endedAtDay: 1, + scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE, + scheduledDay: 0, + scheduledDuration: 10 * MILLIS_PER_MINUTE, + }; + + expect(getEventVariance(report)).toMatchObject({ + actualDuration: 10 * MILLIS_PER_MINUTE, + delta: 0, + status: 'ontime', + }); + expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' }); +}); diff --git a/apps/client/src/common/utils/report.ts b/apps/client/src/common/utils/report.ts new file mode 100644 index 000000000..f001b3988 --- /dev/null +++ b/apps/client/src/common/utils/report.ts @@ -0,0 +1,29 @@ +import type { MaybeNumber, OntimeEventReport } from 'ontime-types'; +import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils'; + +type EventVariance = { + actualDuration: MaybeNumber; + delta: number; + status: 'ontime' | 'over' | 'under' | 'not-run'; +}; + +const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' }; + +export function getReportTimePosition(time: number, day: number): number; +export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber; +export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber { + return time === null || day === null ? null : day * dayInMs + time; +} + +export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance { + if (!entry) return notRun; + + const start = getReportTimePosition(entry.startedAt, entry.startedAtDay); + const end = getReportTimePosition(entry.endedAt, entry.endedAtDay); + if (start === null || end === null) return notRun; + + const actualDuration = end - start; + const delta = actualDuration - entry.scheduledDuration; + if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' }; + return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' }; +} 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..709d9d12d 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,92 @@ +import { isOntimeEvent } from 'ontime-types'; 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 * 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, getGroupReports, getRunSummary, makeReportCSV } from './reportSettings.utils'; export default function ReportSettings() { - const { data: reportData } = useReport(); - const { data } = useRundown(); + const { data: lastRun } = useReport(); + const { eventReports, rundown, show } = lastRun; - const clearReport = async () => await deleteAllReport(); - const downloadCSV = (combinedReport: CombinedReport[]) => { - if (!combinedReport) { - return; + const combinedReport = useMemo( + () => (rundown ? getCombinedReport(eventReports, rundown.entries, rundown.flatOrder) : []), + [eventReports, rundown], + ); + + const summary = useMemo( + () => getRunSummary(eventReports, rundown?.entries ?? {}, rundown?.flatOrder ?? []), + [eventReports, rundown], + ); + + const groups = useMemo( + () => (rundown ? getGroupReports(eventReports, rundown.entries, rundown.order) : []), + [eventReports, rundown], + ); + + let worstOverrunTitle: string | null = null; + if (summary.worstOverrun !== null) { + const entry = rundown?.entries[summary.worstOverrun.id]; + if (entry && isOntimeEvent(entry)) { + worstOverrunTitle = entry.title; } + } + + const hasReport = rundown !== null && Object.keys(eventReports).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..933c6c3fb --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -0,0 +1,161 @@ +import type { OntimeEventReport, OntimeReport } from 'ontime-types'; +import { + createDelay, + createEvent, + createGroup, + dayInMs, + MILLIS_PER_HOUR, + MILLIS_PER_MINUTE, + MILLIS_PER_SECOND, +} from 'ontime-utils'; + +import { + formatOffset, + getCombinedReport, + getGroupReports, + getRunSummary, + getShowOffsets, + makeReportCSV, +} from '../reportSettings.utils'; + +function makeEvent(id: string, patch = {}) { + const event = createEvent({ id, title: id, ...patch }); + if (!event) throw new Error('Failed to create test event'); + return event; +} + +function makeReport(patch: Partial = {}): OntimeEventReport { + return { + startedAt: 5 * MILLIS_PER_MINUTE, + startedAtDay: 1, + endedAt: 15 * MILLIS_PER_MINUTE, + endedAtDay: 1, + scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE, + scheduledDay: 0, + scheduledDuration: 10 * MILLIS_PER_MINUTE, + ...patch, + }; +} + +describe('getCombinedReport()', () => { + it('uses the captured schedule and absolute day when calculating offsets', () => { + const entry = makeEvent('a', { timeStart: 0, duration: 99 * MILLIS_PER_MINUTE }); + const rows = getCombinedReport({ a: makeReport() }, { a: entry }, ['a']); + + expect(rows[0]).toMatchObject({ + scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE, + scheduledEnd: dayInMs + 5 * MILLIS_PER_MINUTE, + startOffset: 10 * MILLIS_PER_MINUTE, + endOffset: 10 * MILLIS_PER_MINUTE, + }); + }); + + it('includes unplayed events but excludes skipped and non-event entries', () => { + const ran = makeEvent('ran'); + const unplayed = makeEvent('unplayed', { timeStart: 20 * MILLIS_PER_MINUTE }); + const skipped = makeEvent('skipped', { skip: true }); + const delay = createDelay({ id: 'delay' }); + const report: OntimeReport = { ran: makeReport({ scheduledStart: 0, scheduledDay: 0 }) }; + + const rows = getCombinedReport(report, { ran, unplayed, skipped, delay }, ['ran', 'unplayed', 'skipped', 'delay']); + + expect(rows.map(({ id }) => id)).toEqual(['ran', 'unplayed']); + expect(rows[1]).toMatchObject({ scheduledStart: unplayed.timeStart, actualStart: null, actualEnd: null }); + }); +}); + +describe('report calculations', () => { + it('keeps finishing time separate from running time', () => { + const offsets = getShowOffsets({ + plannedStart: 19 * MILLIS_PER_HOUR, + plannedEnd: 21 * MILLIS_PER_HOUR, + plannedDuration: 2 * MILLIS_PER_HOUR, + actualStart: 19 * MILLIS_PER_HOUR - 10 * MILLIS_PER_MINUTE, + actualEnd: 21 * MILLIS_PER_HOUR - 6 * MILLIS_PER_MINUTE, + actualDuration: 2 * MILLIS_PER_HOUR + 4 * MILLIS_PER_MINUTE, + }); + + expect(offsets).toMatchObject({ + startOffset: -10 * MILLIS_PER_MINUTE, + endOffset: -6 * MILLIS_PER_MINUTE, + durationOffset: 4 * MILLIS_PER_MINUTE, + }); + }); + + it('measures completed groups against their target and exposes untimed gaps', () => { + const group = createGroup({ id: 'group', entries: ['a', 'b'], targetDuration: 30 * MILLIS_PER_MINUTE }); + const entries = { + group, + a: makeEvent('a', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }), + b: makeEvent('b', { parent: group.id, duration: 10 * MILLIS_PER_MINUTE }), + }; + const report: OntimeReport = { + a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 10 * MILLIS_PER_MINUTE, endedAtDay: 0 }), + b: makeReport({ + startedAt: 15 * MILLIS_PER_MINUTE, + startedAtDay: 0, + endedAt: 25 * MILLIS_PER_MINUTE, + endedAtDay: 0, + }), + }; + + expect(getGroupReports(report, entries, [group.id])[0]).toMatchObject({ + elapsed: 25 * MILLIS_PER_MINUTE, + untimed: 5 * MILLIS_PER_MINUTE, + variance: -5 * MILLIS_PER_MINUTE, + eventsRun: 2, + eventsPlanned: 2, + }); + expect(getGroupReports({ a: report.a }, entries, [group.id])[0].variance).toBeNull(); + }); + + it('summarises completed events and excludes skipped events from the plan', () => { + const entries = { a: makeEvent('a'), b: makeEvent('b', { skip: true }) }; + const report = { + a: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 15 * MILLIS_PER_MINUTE, endedAtDay: 0 }), + b: makeReport({ startedAt: 0, startedAtDay: 0, endedAt: 30 * MILLIS_PER_MINUTE, endedAtDay: 0 }), + }; + + expect(getRunSummary(report, entries, ['a', 'b'])).toEqual({ + eventsRun: 2, + eventsPlanned: 1, + worstOverrun: { id: 'b', delta: 20 * MILLIS_PER_MINUTE }, + }); + }); +}); + +describe('report formatting', () => { + it.each([ + [null, '–'], + [MILLIS_PER_SECOND / 2, 'On time'], + [4 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND, '+4m12s'], + [-MILLIS_PER_MINUTE, '-1m'], + ])('formats offset %s', (value, expected) => { + expect(formatOffset(value)).toBe(expected); + }); + + it('exports group context and leaves missing actual times empty', () => { + const csv = makeReportCSV([ + { + id: 'a', + index: 1, + title: 'Welcome', + cue: '1', + parent: 'act1', + groupTitle: 'Act 1', + scheduledStart: 0, + scheduledEnd: 10 * MILLIS_PER_MINUTE, + actualStart: null, + startOffset: null, + actualEnd: null, + endOffset: null, + }, + ]); + + const fields = csv.trim().split('\n')[1].split(','); + expect(csv).toContain('Group'); + expect(fields[1]).toBe('Act 1'); + expect(fields[5]).toBe(''); + expect(fields[7]).toBe(''); + }); +}); 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..bd0a89d55 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx @@ -0,0 +1,130 @@ +import type { MaybeNumber, ShowReport } from 'ontime-types'; + +import { cx, enDash } from '../../../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../../../common/utils/time'; +import { formatOffset, getShowOffsets, offsetTone } from '../reportSettings.utils'; +import type { RunSummary } 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 = getShowOffsets(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..116a9d9cb --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx @@ -0,0 +1,193 @@ +import type { EntryId } 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 { formatOffset, offsetTone } from '../reportSettings.utils'; +import type { CombinedReport, GroupReport } from '../reportSettings.utils'; + +import style from './ReportTable.module.scss'; + +interface ReportTableProps { + rows: CombinedReport[]; + groups: GroupReport[]; +} + +type ReportColourStyle = React.CSSProperties & Partial>; + +/** + * 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 hasTarget = group.targetDuration !== null; + const measuredAgainst = group.targetDuration ?? group.scheduledDuration; + const unavailableReason = group.eventsRun === 0 ? '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 = offsetTone(entry.startOffset); + const end = offsetTone(entry.endOffset); + + 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): ReportColourStyle { + const style: ReportColourStyle = {}; + if (colour) style['--user-bg'] = colour; + return style; +} + +/** Keeps the event wash distinct from its parent group's identifying rail. */ +function eventColours(eventColour: string, groupColour?: string): ReportColourStyle { + const style: ReportColourStyle = {}; + if (eventColour) style['--event-bg'] = eventColour; + if (groupColour) style['--user-bg'] = groupColour; + return style; +} + +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..bb0b4d339 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,89 +1,262 @@ -import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; +import type { EntryId, MaybeNumber, OntimeGroup, OntimeReport, RundownEntries, ShowReport } from 'ontime-types'; +import { isOntimeEvent, isOntimeGroup } from 'ontime-types'; +import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; -import { formatTime } from '../../../../common/utils/time'; +import { getEventVariance, getReportTimePosition } from '../../../../common/utils/report'; +import { enDash } from '../../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../../common/utils/time'; export type CombinedReport = { id: EntryId; index: number; title: string; cue: string; + colour: string; + /** the group this event belongs to, so the report can mirror the rundown */ + parent: EntryId | null; + groupTitle: string; scheduledStart: number; actualStart: MaybeNumber; + startOffset: MaybeNumber; scheduledEnd: number; actualEnd: MaybeNumber; + endOffset: MaybeNumber; +}; + +export type ShowOffsets = { + startOffset: MaybeNumber; + endOffset: MaybeNumber; + plannedDuration: MaybeNumber; + actualDuration: MaybeNumber; + durationOffset: MaybeNumber; +}; + +export type GroupReport = { + id: EntryId; + title: string; + colour: string; + targetDuration: MaybeNumber; + scheduledDuration: number; + actualStart: MaybeNumber; + actualEnd: MaybeNumber; + elapsed: MaybeNumber; + untimed: MaybeNumber; + variance: MaybeNumber; + eventsRun: number; + eventsPlanned: number; +}; + +export type RunSummary = { + eventsRun: number; + eventsPlanned: number; + worstOverrun: { id: EntryId; delta: number } | null; }; /** - * 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[] = []; let index = 1; - for (let i = 0; i < flatOrder.length; i++) { - const id = flatOrder[i]; + for (const id of flatOrder) { 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]; + const scheduledStart = reported?.scheduledStart ?? entry.timeStart; + const scheduledDay = reported?.scheduledDay ?? entry.dayOffset; + const scheduledStartPosition = getReportTimePosition(scheduledStart, scheduledDay); + const actualStartPosition = reported ? getReportTimePosition(reported.startedAt, reported.startedAtDay) : null; + const actualEndPosition = reported ? getReportTimePosition(reported.endedAt, reported.endedAtDay) : null; + const scheduledDuration = reported?.scheduledDuration ?? entry.duration; - 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 && isOntimeGroup(group) ? group.title : '', + // an event that ran is measured against the plan it ran on, one that + // did not has no snapshot and falls back to the rundown + scheduledStart, + scheduledEnd: scheduledStart + scheduledDuration, + actualStart: reported?.startedAt ?? null, + startOffset: getOffset(actualStartPosition, scheduledStartPosition), + actualEnd: reported?.endedAt ?? null, + endOffset: getOffset(actualEndPosition, scheduledStartPosition + scheduledDuration), + }); index++; } return combinedReport; } -const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; +function getOffset(actual: MaybeNumber, scheduled: number): MaybeNumber { + return actual === null ? null : actual - scheduled; +} -/** - * Transforms a CombinedReport into a CSV string - */ -export function makeReportCSV(combinedReport: CombinedReport[]) { - const csv: string[][] = []; - csv.push(csvHeader); +export function getShowOffsets(show: ShowReport): ShowOffsets { + const { plannedDuration, actualDuration } = show; + return { + startOffset: getWallClockOffset(show.plannedStart, show.actualStart), + endOffset: getWallClockOffset(show.plannedEnd, show.actualEnd), + plannedDuration, + actualDuration, + durationOffset: plannedDuration === null || actualDuration === null ? null : actualDuration - plannedDuration, + }; +} - 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), - ]); +function getWallClockOffset(planned: MaybeNumber, actual: MaybeNumber): MaybeNumber { + if (planned === null || actual === null) return null; + + const offset = actual - planned; + if (offset < -dayInMs / 2) return offset + dayInMs; + if (offset > dayInMs / 2) return offset - dayInMs; + return offset; +} + +export function getGroupReports(report: OntimeReport, entries: RundownEntries, order: EntryId[]): GroupReport[] { + const groups: GroupReport[] = []; + for (const id of order) { + const group = entries[id]; + if (group && isOntimeGroup(group)) groups.push(getGroupReport(group, report, entries)); + } + return groups; +} + +function getGroupReport(group: OntimeGroup, report: OntimeReport, entries: RundownEntries): GroupReport { + let scheduledDuration = 0; + let eventsPlanned = 0; + let eventsRun = 0; + let ranDuration = 0; + let firstStart = Number.POSITIVE_INFINITY; + let lastEnd = Number.NEGATIVE_INFINITY; + let actualStart: MaybeNumber = null; + let actualEnd: MaybeNumber = null; + + for (const childId of group.entries) { + const child = entries[childId]; + if (!child || !isOntimeEvent(child) || child.skip) continue; + + eventsPlanned += 1; + const reported = report[childId]; + scheduledDuration += reported?.scheduledDuration ?? child.duration; + + const variance = getEventVariance(reported); + if (variance.actualDuration === null || !reported) continue; + + eventsRun += 1; + ranDuration += variance.actualDuration; + const start = getReportTimePosition(reported.startedAt, reported.startedAtDay); + const end = getReportTimePosition(reported.endedAt, reported.endedAtDay); + if (start !== null && start < firstStart) { + firstStart = start; + actualStart = reported.startedAt; + } + if (end !== null && end > lastEnd) { + lastEnd = end; + actualEnd = reported.endedAt; + } } - return makeCSVFromArrayOfArrays(csv); + const elapsed = actualStart === null || actualEnd === null ? null : lastEnd - firstStart; + const measuredAgainst = group.targetDuration ?? scheduledDuration; + const isComplete = eventsRun > 0 && eventsRun === eventsPlanned; + + return { + id: group.id, + title: group.title, + colour: group.colour, + targetDuration: group.targetDuration, + scheduledDuration, + actualStart, + actualEnd, + elapsed, + untimed: elapsed === null ? null : Math.max(0, elapsed - ranDuration), + variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst, + eventsRun, + eventsPlanned, + }; +} + +export function getRunSummary(report: OntimeReport, entries: RundownEntries, order: EntryId[]): RunSummary { + const eventsPlanned = order.filter((id) => { + const entry = entries[id]; + return entry && isOntimeEvent(entry) && !entry.skip; + }).length; + const 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?.delta ?? 0) < variance.delta) { + summary.worstOverrun = { id, delta: variance.delta }; + } + } + return summary; +} + +/** + * 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'; +} + +function formatCsvTime(value: MaybeNumber): string { + return value === null ? '' : formatTime(value); +} + +const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; + +/** + * Transforms a CombinedReport into a CSV string. + * + * Exported as one row per event with its group named, rather than with + * rollups baked in, so it stays the dataset a report is built from. + */ +export function makeReportCSV(combinedReport: CombinedReport[]) { + const csv = combinedReport.map((entry) => [ + String(entry.index), + entry.groupTitle, + entry.cue, + entry.title, + formatTime(entry.scheduledStart), + // an event that never ran leaves the cell empty rather than a + // placeholder, so a spreadsheet reads it as missing + formatCsvTime(entry.actualStart), + formatTime(entry.scheduledEnd), + formatCsvTime(entry.actualEnd), + ]); + + return makeCSVFromArrayOfArrays([csvHeader, ...csv]); } diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx index cd11cdc3c..fed1c3ebb 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx @@ -99,7 +99,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO function StoppedStatus() { const { data } = useReport(); - const hasReport = Object.keys(data).length > 0; + const hasReport = Object.keys(data.eventReports).length > 0; if (hasReport) { return Go to report management; 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..db0a05692 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -6,6 +6,7 @@ import { IoCheckmarkCircle } from 'react-icons/io5'; import Tooltip from '../../../../common/components/tooltip/Tooltip'; import useReport from '../../../../common/hooks-query/useReport'; import { usePlayback } from '../../../../common/hooks/useSocket'; +import { getEventVariance } from '../../../../common/utils/report'; import { cx } from '../../../../common/utils/styleUtils'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; @@ -20,7 +21,6 @@ interface RundownEventChipProps { isLoaded: boolean; className: string; totalGap: number; - duration: number; isLinkedToLoaded: boolean; } @@ -33,7 +33,6 @@ export default function RundownEventChip({ className, totalGap, id, - duration, isLinkedToLoaded, }: RundownEventChipProps) { const playback = usePlayback(); @@ -45,25 +44,20 @@ export default function RundownEventChip({ const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return ; + return ; } - if (playbackActive) { - // we extracted the component to avoid unnecessary calculations and re-renders - return ( - } className={className}> - - - ); - } - - return null; + return ( + } className={className}> + + + ); } interface EventUntilProps { @@ -86,41 +80,30 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: interface EventReportProps { className: string; id: string; - duration: number; } -function EventReport(props: EventReportProps) { - const { className, id, duration } = props; +function EventReport({ className, id }: EventReportProps) { const { data } = useReport(); - const currentReport = data[id]; + const currentReport = data.eventReports[id]; const [value, overUnderStyle, tooltip] = useMemo(() => { - if (!currentReport) { + // Use the schedule recorded when the event ran so later rundown edits do + // not change its report. + 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 fullTimeValue = millisToString(absDifference); - - const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; - + const absDifference = Math.abs(variance.delta); + const isOver = variance.status === 'over'; + const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${millisToString(absDifference)}`; 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/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index ced544457..1ed7996ff 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -107,7 +107,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode // a subscribed group is live when any of its children is the selected/running event const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; const isArmed = !isLive && activeEntryId === selectedEventId; - const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); + const countdownEvent = extendEventData( + event, + currentDay, + actualStart, + plannedStart, + offset, + mode, + reportData.eventReports, + ); const displayTitle = getPropertyValue(event, mainSource ?? 'title'); return (
({ sendRefetch: vi.fn() })); + +const eventA = makeOntimeEvent({ + id: 'event-a', + dayOffset: 0, + timeStart: 0, + timeEnd: MILLIS_PER_MINUTE, + duration: MILLIS_PER_MINUTE, +}) as PlayableEvent; +const eventB = makeOntimeEvent({ + id: 'event-b', + dayOffset: 0, + timeStart: MILLIS_PER_MINUTE, + timeEnd: 2 * MILLIS_PER_MINUTE, + duration: MILLIS_PER_MINUTE, +}) as PlayableEvent; + +beforeEach(() => { + vi.clearAllMocks(); + clear(); +}); + +it('records lifecycle times while keeping the schedule captured at start', () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, _startEpoch: 1 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + const edited = { ...eventA, timeStart: 999, duration: 999 } as PlayableEvent; + const stop = makeRuntimeStateData({ eventNow: edited, clock: 2 * MILLIS_PER_MINUTE, rundown: { currentDay: 1 } }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toEqual({ + startedAt: 500, + startedAtDay: 0, + endedAt: 2 * MILLIS_PER_MINUTE, + endedAtDay: 1, + scheduledStart: eventA.timeStart, + scheduledDay: eventA.dayOffset, + scheduledDuration: eventA.duration, + }); + expect(sendRefetch).toHaveBeenCalledTimes(2); + expect(sendRefetch).toHaveBeenLastCalledWith(RefetchKey.Report); +}); + +it('falls back to the current event when a stop arrives without a start', () => { + const stop = makeRuntimeStateData({ eventNow: eventA, clock: MILLIS_PER_MINUTE }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toMatchObject({ + startedAt: null, + endedAt: MILLIS_PER_MINUTE, + scheduledDuration: eventA.duration, + }); +}); + +it('accumulates one run and replaces it when the next run starts', () => { + const firstRun = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 }); + triggerReportEntry(TimerLifeCycle.onStart, firstRun); + triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, eventNow: eventB }); + expect(Object.keys(generate())).toHaveLength(2); + + triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, _startEpoch: 2 }); + expect(Object.keys(generate())).toEqual([eventA.id]); +}); + +it('returns the latest report with the rundown plan captured at run start', () => { + const rundown = makeRundown({ + id: 'run-1', + title: 'Original title', + order: [eventA.id, eventB.id], + entries: { [eventA.id]: eventA, [eventB.id]: eventB }, + }); + const start = makeRuntimeStateData({ + eventNow: eventA, + timer: { startedAt: 500 }, + _startEpoch: 1, + rundown: { plannedStart: 0, plannedEnd: 2 * MILLIS_PER_MINUTE }, + }); + triggerReportEntry(TimerLifeCycle.onStart, start, rundown); + triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: MILLIS_PER_MINUTE }); + rundown.title = 'Edited later'; + + expect(generateLastRunReport()).toMatchObject({ + rundown: { id: 'run-1', title: 'Original title' }, + eventReports: { [eventA.id]: { scheduledDuration: MILLIS_PER_MINUTE } }, + show: { + plannedStart: 0, + plannedEnd: 2 * MILLIS_PER_MINUTE, + plannedDuration: 2 * MILLIS_PER_MINUTE, + actualStart: 500, + actualEnd: MILLIS_PER_MINUTE, + }, + }); +}); + +it('clears the retained report and rundown snapshot together', () => { + const rundown = makeRundown({ order: [eventA.id], entries: { [eventA.id]: eventA } }); + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 }); + triggerReportEntry(TimerLifeCycle.onStart, state, rundown); + clear(); + + expect(generateLastRunReport()).toMatchObject({ eventReports: {}, rundown: null }); +}); diff --git a/apps/server/src/api-data/report/__tests__/report.utils.test.ts b/apps/server/src/api-data/report/__tests__/report.utils.test.ts new file mode 100644 index 000000000..d47ce9750 --- /dev/null +++ b/apps/server/src/api-data/report/__tests__/report.utils.test.ts @@ -0,0 +1,72 @@ +import type { OntimeEventReport, OntimeReport, PlayableEvent } from 'ontime-types'; +import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils'; + +import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js'; +import { getActualShowTimes, getPlannedShowDuration } from '../report.utils.js'; + +function makeReport(patch: Partial): OntimeEventReport { + return { + startedAt: 0, + startedAtDay: 0, + endedAt: 0, + endedAtDay: 0, + scheduledStart: 0, + scheduledDay: 0, + scheduledDuration: 0, + ...patch, + }; +} + +describe('getActualShowTimes()', () => { + it('preserves long gaps within the same day', () => { + const report: OntimeReport = { + morning: makeReport({ startedAt: 6 * MILLIS_PER_HOUR, endedAt: 7 * MILLIS_PER_HOUR }), + evening: makeReport({ startedAt: 20 * MILLIS_PER_HOUR, endedAt: 21 * MILLIS_PER_HOUR }), + }; + + expect(getActualShowTimes(report)).toEqual({ + actualStart: 6 * MILLIS_PER_HOUR, + actualEnd: 21 * MILLIS_PER_HOUR, + actualDuration: 15 * MILLIS_PER_HOUR, + }); + }); + + it('orders events by their captured day across midnight', () => { + const report: OntimeReport = { + beforeMidnight: makeReport({ + startedAt: dayInMs - 10 * MILLIS_PER_MINUTE, + endedAt: dayInMs - 5 * MILLIS_PER_MINUTE, + }), + afterMidnight: makeReport({ + startedAt: 0, + startedAtDay: 1, + endedAt: 10 * MILLIS_PER_MINUTE, + endedAtDay: 1, + }), + }; + + expect(getActualShowTimes(report).actualDuration).toBe(20 * MILLIS_PER_MINUTE); + }); +}); + +it('derives planned duration from playable, non-skipped events', () => { + const first = makeOntimeEvent({ + id: 'first', + dayOffset: 0, + timeStart: 23 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }) as PlayableEvent; + const last = makeOntimeEvent({ + id: 'last', + dayOffset: 1, + timeStart: MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }) as PlayableEvent; + const skipped = makeOntimeEvent({ id: 'skipped', dayOffset: 2, timeStart: 0, duration: MILLIS_PER_HOUR, skip: true }); + const rundown = makeRundown({ + order: [first.id, last.id, skipped.id], + entries: { [first.id]: first, [last.id]: last, [skipped.id]: skipped }, + }); + + expect(getPlannedShowDuration(rundown)).toBe(3 * MILLIS_PER_HOUR); +}); diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index 94b35dc42..2c82696ee 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -10,6 +10,10 @@ router.get('/', (_req: Request, res: Response) => { res.status(200).json(report.generate()); }); +router.get('/last-run', (_req: Request, res: Response) => { + res.status(200).json(report.generateLastRunReport()); +}); + 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..0712695b4 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,13 +1,35 @@ -import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types'; -import { DeepReadonly } from 'ts-essentials'; +import type { LastRunReport, OntimeEventReport, OntimeReport, Rundown, ShowReport } from 'ontime-types'; +import { RefetchKey, TimerLifeCycle } from 'ontime-types'; +import type { DeepReadonly } from 'ts-essentials'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; -import { RuntimeState } from '../../stores/runtimeState.js'; +import type { RuntimeState } from '../../stores/runtimeState.js'; +import { getCurrentRundown } from '../rundown/rundown.dao.js'; +import { getActualShowTimes, getPlannedShowDuration } from './report.utils.js'; 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, + plannedDuration: null, +}; +let rundownSnapshot: Rundown | null = null; + /** * generates a full report * @returns full report @@ -27,9 +49,15 @@ 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, plannedDuration: null }; + rundownSnapshot = null; } /** @@ -41,6 +69,7 @@ export function clear(id?: string) { export function triggerReportEntry( cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop, state: DeepReadonly, + rundown: Readonly = getCurrentRundown(), ) { if (!state.eventNow?.id) { return; @@ -49,15 +78,80 @@ export function triggerReportEntry( const eventId = state.eventNow.id; if (cycle === TimerLifeCycle.onStart) { - report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null }); + startShowIfNew(state, rundown); + + report.set(eventId, { + startedAt: state.timer.startedAt, + startedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset, + endedAt: null, + endedAtDay: null, + // snapshot the schedule so later rundown edits cannot change how a show + // that already happened is reported + scheduledStart: state.eventNow.timeStart, + scheduledDay: state.eventNow.dayOffset, + 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, + startedAtDay: previous?.startedAtDay ?? null, + endedAt: state.clock, + endedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset, + scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart, + scheduledDay: previous?.scheduledDay ?? state.eventNow.dayOffset, + 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, rundown: Readonly) { + const showStart = state._startEpoch ?? state.rundown.actualStart; + if (showStart === null || showStart === currentShowStart) { + return; + } + + report.clear(); + formattedReport = null; + currentShowStart = showStart; + rundownSnapshot = structuredClone(rundown); + plannedTimes = { + plannedStart: state.rundown.plannedStart, + plannedEnd: state.rundown.plannedEnd, + plannedDuration: getPlannedShowDuration(rundownSnapshot), + }; +} + +/** + * 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. + */ +function generateShowReport(): ShowReport { + return { ...plannedTimes, ...getActualShowTimes(generate()) }; +} + +/** The single report retained for the UI: the latest run and its captured plan. */ +export function generateLastRunReport(): LastRunReport { + return { + eventReports: generate(), + rundown: rundownSnapshot, + show: generateShowReport(), + }; +} diff --git a/apps/server/src/api-data/report/report.utils.ts b/apps/server/src/api-data/report/report.utils.ts new file mode 100644 index 000000000..a62720326 --- /dev/null +++ b/apps/server/src/api-data/report/report.utils.ts @@ -0,0 +1,52 @@ +import type { OntimeReport, Rundown, ShowReport } from 'ontime-types'; +import { isOntimeEvent } from 'ontime-types'; +import { dayInMs } from 'ontime-utils'; + +export function getActualShowTimes( + report: OntimeReport, +): Pick { + let firstStart = Number.POSITIVE_INFINITY; + let lastEnd = Number.NEGATIVE_INFINITY; + let actualStart: number | null = null; + let actualEnd: number | null = null; + + for (const entry of Object.values(report)) { + if (entry.startedAt !== null && entry.startedAtDay !== null) { + const start = entry.startedAtDay * dayInMs + entry.startedAt; + if (start < firstStart) { + firstStart = start; + actualStart = entry.startedAt; + } + } + + if (entry.endedAt !== null && entry.endedAtDay !== null) { + const end = entry.endedAtDay * dayInMs + entry.endedAt; + if (end > lastEnd) { + lastEnd = end; + actualEnd = entry.endedAt; + } + } + } + + return { + actualStart, + actualEnd, + actualDuration: actualStart === null || actualEnd === null ? null : lastEnd - firstStart, + }; +} + +export function getPlannedShowDuration(rundown: Rundown): number | null { + let firstStart = Number.POSITIVE_INFINITY; + let lastEnd = Number.NEGATIVE_INFINITY; + + for (const id of rundown.flatOrder) { + const entry = rundown.entries[id]; + if (!entry || !isOntimeEvent(entry) || entry.skip) continue; + + const start = entry.dayOffset * dayInMs + entry.timeStart; + firstStart = Math.min(firstStart, start); + lastEnd = Math.max(lastEnd, start + entry.duration); + } + + return Number.isFinite(firstStart) ? lastEnd - firstStart : null; +} diff --git a/docs/agent-guides/domain-invariants.md b/docs/agent-guides/domain-invariants.md index 9418e4901..47457d6b9 100644 --- a/docs/agent-guides/domain-invariants.md +++ b/docs/agent-guides/domain-invariants.md @@ -38,6 +38,12 @@ When relevant, cover interactions among: Pass time/state explicitly to keep rules deterministic and unit-testable. +## Reports + +- Retain only the most recent run. A report is one aggregate containing its event records, show timing, and rundown snapshot. +- Capture the rundown plan when the run starts; later rundown edits must not change a completed report. +- Store day offsets with report timestamps. Use absolute timeline positions for ordering and duration, and wall-clock values only for display. + ## Imports and migrations - Treat project files, spreadsheets, custom fields, migrated data as untrusted. diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index c34a0978d..1e579dae7 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -1,8 +1,42 @@ import type { MaybeNumber } from '../../utils/utils.type.js'; +import type { EntryId } from './OntimeEntry.js'; +import type { Rundown } from './Rundown.type.js'; export type OntimeEventReport = { startedAt: MaybeNumber; + startedAtDay: number | null; endedAt: MaybeNumber; + endedAtDay: number | null; + /** + * 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; + scheduledDay: 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; + plannedDuration: MaybeNumber; + actualStart: MaybeNumber; + actualEnd: MaybeNumber; + actualDuration: MaybeNumber; +}; + +/** The only report retained by the server: the most recent run and its plan. */ +export type LastRunReport = { + eventReports: OntimeReport; + rundown: Rundown | null; + show: ShowReport; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..0672683a0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,7 +24,7 @@ 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 { LastRunReport, OntimeReport, OntimeEventReport, ShowReport } from './definitions/core/Report.type.js'; // ---> Automations export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';