diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 778545257..229b49bf6 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 { ReportData } 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 report */ -export async function fetchReport(options?: RequestOptions): Promise { +export async function fetchReport(options?: RequestOptions): Promise { const res = await axios.get(reportUrl, { 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..3b7d2ccd1 100644 --- a/apps/client/src/common/hooks-query/useReport.ts +++ b/apps/client/src/common/hooks-query/useReport.ts @@ -1,17 +1,34 @@ import { useQuery } from '@tanstack/react-query'; -import { OntimeReport } from 'ontime-types'; +import type { ReportData } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { REPORT } from '../api/constants'; import { fetchReport } from '../api/report'; +const emptyReport: ReportData = { + 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: report, refetch } = useQuery({ queryKey: REPORT, queryFn: ({ signal }) => fetchReport({ signal }), - placeholderData: (previousData, _previousQuery) => previousData, + placeholderData: (previousData) => previousData, staleTime: MILLIS_PER_HOUR, }); - return { data: data ?? {}, refetch }; + return { + data: report?.eventReports ?? emptyReport.eventReports, + report: report ?? emptyReport, + refetch, + }; } 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..32aff5493 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,72 @@ 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 { report } = useReport(); + const { eventReports, rundown, show } = report; + + const { combinedReport, groups, summary } = useMemo(() => { + const entries = rundown?.entries ?? {}; + return { + combinedReport: rundown ? getCombinedReport(eventReports, entries, rundown.flatOrder) : [], + groups: rundown ? getGroupReports(eventReports, entries, rundown.order) : [], + summary: getRunSummary(eventReports, entries, rundown?.flatOrder ?? []), + }; + }, [eventReports, rundown]); + + const hasReport = rundown !== null && Object.keys(eventReports).length > 0; + const downloadCSV = () => { + if (!hasReport) return; - const clearReport = async () => await deleteAllReport(); - const downloadCSV = (combinedReport: CombinedReport[]) => { - if (!combinedReport) { - 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..f6aaba526 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -0,0 +1,156 @@ +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', () => { + 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, + 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 }); + }); +}); + +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..a32282b16 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.module.scss @@ -0,0 +1,104 @@ +// 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; +} 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..48007b48f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportShowSummary.tsx @@ -0,0 +1,118 @@ +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; +} + +/** + * 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 }: 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 ? 'Show duration' : 'Show incomplete'} + {didReachEnd && offsets.durationOffset !== null ? ( + + {formatOffset(offsets.durationOffset)} + + ) : ( + The show did not reach the end of the rundown. + )} +
+ +
+ {hasPlan && ( + + )} + {hasPlan && didReachEnd && ( + + )} + {didReachEnd && ( + + )} +
+
+
+
+ ); +} + +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..132cd2ca5 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.module.scss @@ -0,0 +1,144 @@ +$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; + } +} + +th.groupSummary { + padding: 0.5rem 1rem; + box-shadow: inset 4px 0 $rail; + + text-align: left; + text-transform: none; + letter-spacing: normal; + + > * { + text-transform: none; + } +} + +.groupTitle, +.groupLabel, +.groupValues { + 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 { + 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; + } +} + +.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..761f94c03 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/ReportTable.tsx @@ -0,0 +1,182 @@ +import type { EntryId } from 'ontime-types'; +import { useMemo } from 'react'; + +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)} +
+ + )} +
+
+ + + ); +} + +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..0d6305b2b 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,241 @@ -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; +}; + +type ShowOffsets = { + startOffset: MaybeNumber; + endOffset: MaybeNumber; + durationOffset: MaybeNumber; +}; + +export type GroupReport = { + id: EntryId; + title: string; + colour: string; + targetDuration: MaybeNumber; + scheduledDuration: number; + actualStart: MaybeNumber; + actualEnd: MaybeNumber; + elapsed: MaybeNumber; + variance: MaybeNumber; + eventsRun: number; + eventsPlanned: number; +}; + +export type RunSummary = { + eventsRun: number; + eventsPlanned: number; }; /** - * Creates a combined report with the rundown data + * 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), + 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 firstStart = Number.POSITIVE_INFINITY; + let lastEnd = Number.NEGATIVE_INFINITY; + let actualStart: MaybeNumber = null; + let actualEnd: MaybeNumber = null; + + for (const childId of group.entries) { + const child = entries[childId]; + if (!child || !isOntimeEvent(child) || child.skip) continue; + + eventsPlanned += 1; + const reported = report[childId]; + scheduledDuration += reported?.scheduledDuration ?? child.duration; + + const variance = getEventVariance(reported); + if (variance.actualDuration === null || !reported) continue; + + eventsRun += 1; + const start = getReportTimePosition(reported.startedAt, reported.startedAtDay); + const end = getReportTimePosition(reported.endedAt, reported.endedAtDay); + if (start !== null && start < firstStart) { + firstStart = start; + actualStart = reported.startedAt; + } + if (end !== null && end > lastEnd) { + lastEnd = end; + actualEnd = reported.endedAt; + } } - 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, + variance: elapsed === null || !isComplete ? null : elapsed - measuredAgainst, + eventsRun, + eventsPlanned, + }; +} + +export function getRunSummary(report: OntimeReport, entries: RundownEntries, order: EntryId[]): RunSummary { + const eventsPlanned = order.filter((id) => { + const entry = entries[id]; + return entry && isOntimeEvent(entry) && !entry.skip; + }).length; + const eventsRun = Object.values(report).filter((entry) => getEventVariance(entry).status !== 'not-run').length; + return { eventsRun, eventsPlanned }; +} + +/** + * Signed offset, eg "+4m12s" / "-1m", following Ontime's convention that + * positive means behind schedule. + */ +export function formatOffset(value: MaybeNumber): string { + if (value === null) return enDash; + if (Math.abs(value) < MILLIS_PER_SECOND) return 'On time'; + return `${value > 0 ? '+' : '-'}${formatDuration(Math.abs(value), false)}`; +} + +export function offsetTone(value: MaybeNumber): 'over' | 'under' | 'none' { + if (value === null || Math.abs(value) < MILLIS_PER_SECOND) return 'none'; + return value > 0 ? 'over' : 'under'; +} + +function formatCsvTime(value: MaybeNumber): string { + return value === null ? '' : formatTime(value); +} + +const csvHeader = ['Index', 'Group', 'Cue', 'Title', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; + +/** + * Transforms a CombinedReport into a CSV string. + * + * Exported as one row per event with its group named, rather than with + * rollups baked in, so it stays the dataset a report is built from. + */ +export function makeReportCSV(combinedReport: CombinedReport[]) { + const csv = combinedReport.map((entry) => [ + String(entry.index), + entry.groupTitle, + entry.cue, + entry.title, + formatTime(entry.scheduledStart), + formatCsvTime(entry.actualStart), + formatTime(entry.scheduledEnd), + formatCsvTime(entry.actualEnd), + ]); + + return makeCSVFromArrayOfArrays([csvHeader, ...csv]); } 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..bc7f6162d 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 [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/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..b6471fd42 --- /dev/null +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -0,0 +1,113 @@ +import type { PlayableEvent } from 'ontime-types'; +import { RefetchKey, TimerLifeCycle } from 'ontime-types'; +import { MILLIS_PER_MINUTE } from 'ontime-utils'; +import { vi } from 'vitest'; + +import { sendRefetch } from '../../../adapters/WebsocketAdapter.js'; +import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; +import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js'; +import { clear, generate, generateReport, triggerReportEntry } from '../report.service.js'; + +vi.mock('../../../adapters/WebsocketAdapter.js', () => ({ 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(() => { + clear(); + vi.clearAllMocks(); +}); + +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 entries until the report is explicitly cleared', () => { + const firstRun = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 }); + triggerReportEntry(TimerLifeCycle.onStart, firstRun); + triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, eventNow: eventB, _startEpoch: 2 }); + + expect(Object.keys(generate())).toEqual([eventA.id, eventB.id]); +}); + +it('returns the report with the rundown plan captured at its first event', () => { + 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(generateReport()).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); + vi.clearAllMocks(); + clear(); + + expect(generateReport()).toMatchObject({ eventReports: {}, rundown: null }); + expect(sendRefetch).toHaveBeenCalledOnce(); + expect(sendRefetch).toHaveBeenCalledWith(RefetchKey.Report); +}); 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..ddd7d4335 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -7,7 +7,7 @@ import * as report from './report.service.js'; export const router: Router = express.Router(); router.get('/', (_req: Request, res: Response) => { - res.status(200).json(report.generate()); + res.status(200).json(report.generateReport()); }); router.delete('/all', (_req: Request, res: Response) => { diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index f774bfe03..b8cb4fef3 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,12 +1,28 @@ -import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types'; -import { DeepReadonly } from 'ts-essentials'; +import type { OntimeEventReport, OntimeReport, PlayableEvent, ReportData, 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; +const emptyPlannedTimes: Pick = { + plannedStart: null, + plannedEnd: null, + plannedDuration: 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 = emptyPlannedTimes; +let rundownSnapshot: Rundown | null = null; /** * generates a full report @@ -28,8 +44,13 @@ export function clear(id?: string) { if (id) { report.delete(id); } else { + // A full clear makes the next event start a new report instead of resuming this run. report.clear(); + plannedTimes = emptyPlannedTimes; + rundownSnapshot = null; } + + sendRefetch(RefetchKey.Report); } /** @@ -41,6 +62,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 +71,74 @@ export function triggerReportEntry( const eventId = state.eventNow.id; if (cycle === TimerLifeCycle.onStart) { - report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null }); + captureReportPlan(state, rundown); + + report.set(eventId, { + ...getScheduleSnapshot(state.eventNow), + startedAt: state.timer.startedAt, + startedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset, + endedAt: null, + endedAtDay: null, + }); 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); + const schedule = previous ?? getScheduleSnapshot(state.eventNow); + report.set(eventId, { + startedAt: previous?.startedAt ?? null, + startedAtDay: previous?.startedAtDay ?? null, + endedAt: state.clock, + endedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset, + scheduledStart: schedule.scheduledStart, + scheduledDay: schedule.scheduledDay, + scheduledDuration: schedule.scheduledDuration, + }); formattedReport = null; sendRefetch(RefetchKey.Report); } } + +function getScheduleSnapshot( + event: Pick, +): Pick { + return { + scheduledStart: event.timeStart, + scheduledDay: event.dayOffset, + scheduledDuration: event.duration, + }; +} + +/** + * Captures the plan once, when the first event in the report starts. + */ +function captureReportPlan(state: DeepReadonly, rundown: Readonly) { + if (rundownSnapshot !== null) return; + + 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()) }; +} + +export function generateReport(): ReportData { + 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..ca1cf8c25 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 + +- A report is one aggregate containing its event records, show timing, and rundown snapshot. +- Capture the rundown plan when the report starts; later rundown edits must not change it. +- 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..0f00181d4 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; +}; + +/** Recorded event timings together with the rundown plan they are measured against. */ +export type ReportData = { + eventReports: OntimeReport; + rundown: Rundown | null; + show: ShowReport; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..66ea56090 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 { OntimeReport, OntimeEventReport, ReportData, ShowReport } from './definitions/core/Report.type.js'; // ---> Automations export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';