From 3c2875a956d98764e9c44fbe6d691caac3be3cac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 11:08:28 +0000 Subject: [PATCH] feat(report): persistent show run history Turns the reporter from a live-only, single-run curiosity into a persistent, per-project, per-rundown history of runs. - Extend OntimeEventReport with a schedule snapshot (scheduledStart, scheduledDuration) taken when an event starts, plus playCount. Reports are now a record that survives later rundown edits, rather than a live join against the current rundown. - Add a run lifecycle: a run opens on the first event start and closes on a full stop, archiving the previous run to history so the next start begins fresh. - Persist runs to a per-project sidecar file (services/report-service), patterned on the existing restore-service, so a crash or restart no longer loses the whole show's record. Runs are scoped by rundownId for multi-rundown projects, cascade-deleted with their rundown or project, renamed alongside a project rename, and deliberately not copied when a project is duplicated. - Extract the over/under/on-time variance and run summary maths shared by the rundown chip, the report settings panel, and the server into ontime-utils (getEventVariance, getRunSummary, countPlannedEvents). - Extend the report API with run history endpoints (list, get, latest, rename, delete) while keeping GET /report's existing shape so Companion and HTTP automations are unaffected. - Replace the settings report table with a run browser: a list of runs with a rundown filter, inline rename, delete, and a detail view with per-run summary stats and CSV export. - Add a third, muted state to the rundown event chip: an event with nothing in the current run yet previews how it went last time. Fixes a real bug found while testing the new store: emptyStore() was a shared object, so its runs array leaked mutations across project loads. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV --- apps/client/src/common/api/constants.ts | 2 + apps/client/src/common/api/report.ts | 51 +++- .../common/hooks-query/useProjectRundowns.ts | 10 + apps/client/src/common/hooks-query/useRuns.ts | 49 +++ .../feature-panel/ReportSettings.module.scss | 7 - .../panel/feature-panel/ReportSettings.tsx | 129 +++----- .../__tests__/reportSettings.utils.test.ts | 143 +++++++++ .../composite/RunDetail.module.scss | 32 ++ .../feature-panel/composite/RunDetail.tsx | 113 +++++++ .../composite/RunsList.module.scss | 12 + .../feature-panel/composite/RunsList.tsx | 156 ++++++++++ .../feature-panel/reportSettings.utils.ts | 119 +++++--- .../rundown-event/RundownEventInner.tsx | 1 - .../composite/RundownEventChip.module.scss | 6 + .../composite/RundownEventChip.tsx | 78 +++-- .../report/__tests__/report.service.test.ts | 285 ++++++++++++++++++ .../src/api-data/report/report.router.ts | 69 ++++- .../src/api-data/report/report.service.ts | 208 ++++++++++++- .../src/api-data/report/report.validation.ts | 14 + .../src/api-data/rundown/rundown.service.ts | 4 + .../project-service/ProjectService.ts | 13 + .../__tests__/report.store.test.ts | 213 +++++++++++++ .../services/report-service/report.parser.ts | 130 ++++++++ .../services/report-service/report.store.ts | 182 +++++++++++ .../runtime-service/runtime.service.ts | 4 +- apps/server/src/setup/config.ts | 1 + apps/server/src/setup/index.ts | 2 + .../types/src/definitions/core/Report.type.ts | 52 +++- packages/types/src/index.ts | 9 +- packages/utils/index.ts | 9 + .../src/report-utils/reportUtils.test.ts | 112 +++++++ .../utils/src/report-utils/reportUtils.ts | 101 +++++++ 32 files changed, 2144 insertions(+), 172 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useRuns.ts delete mode 100644 apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/RunDetail.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/RunDetail.tsx create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.tsx create mode 100644 apps/server/src/api-data/report/__tests__/report.service.test.ts create mode 100644 apps/server/src/api-data/report/report.validation.ts create mode 100644 apps/server/src/services/report-service/__tests__/report.store.test.ts create mode 100644 apps/server/src/services/report-service/report.parser.ts create mode 100644 apps/server/src/services/report-service/report.store.ts create mode 100644 packages/utils/src/report-utils/reportUtils.test.ts create mode 100644 packages/utils/src/report-utils/reportUtils.ts diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index aa0551931..7cd8d3805 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -20,6 +20,8 @@ export const VIEW_SETTINGS = ['viewSettings']; export const CSS_OVERRIDE = ['cssOverride']; export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; +export const REPORT_RUNS = ['report', 'runs']; +export const getLatestRunQueryKey = (rundownId?: string) => ['report', 'runs', 'latest', rundownId ?? null]; export const TRANSLATION = ['translation']; // API URLs diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 778545257..22a651ce6 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { OntimeReport } from 'ontime-types'; +import { OntimeReport, ShowRun, ShowRunSummary } from 'ontime-types'; import { ontimeQueryClient } from '../../common/queryClient'; import { REPORT, apiEntryUrl } from './constants'; @@ -24,3 +24,52 @@ export async function deleteAllReport() { await axios.delete(`${reportUrl}/all`); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); } + +/** + * HTTP request to fetch the run history, optionally scoped to a rundown + */ +export async function fetchRuns(rundownId?: string, options?: RequestOptions): Promise { + const res = await axios.get(`${reportUrl}/runs`, { + signal: options?.signal, + params: rundownId ? { rundownId } : undefined, + }); + return res.data; +} + +/** + * HTTP request to fetch a single run, including its per event data + */ +export async function fetchRun(id: string, options?: RequestOptions): Promise { + const res = await axios.get(`${reportUrl}/runs/${id}`, { signal: options?.signal }); + return res.data; +} + +/** + * HTTP request to fetch the most recently closed run, optionally scoped to a rundown + * @returns null if there is no closed run yet + */ +export async function fetchLatestRun(rundownId?: string, options?: RequestOptions): Promise { + try { + const res = await axios.get(`${reportUrl}/runs/latest`, { + signal: options?.signal, + params: rundownId ? { rundownId } : undefined, + }); + return res.data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } +} + +export async function renameRun(id: string, label: string): Promise { + const res = await axios.patch(`${reportUrl}/runs/${id}`, { label }); + await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); + return res.data; +} + +export async function deleteRun(id: string) { + await axios.delete(`${reportUrl}/runs/${id}`); + await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); +} diff --git a/apps/client/src/common/hooks-query/useProjectRundowns.ts b/apps/client/src/common/hooks-query/useProjectRundowns.ts index 45d83f42f..32299a0ec 100644 --- a/apps/client/src/common/hooks-query/useProjectRundowns.ts +++ b/apps/client/src/common/hooks-query/useProjectRundowns.ts @@ -26,6 +26,16 @@ export function useProjectRundowns() { return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; } +/** + * The id of the currently loaded rundown, or null if none is loaded yet. + * Reads from the same lightweight summary query as `useProjectRundowns`, so + * consumers that only need the id are not subscribed to full rundown entries. + */ +export function useLoadedRundownId(): string | null { + const { data } = useProjectRundowns(); + return data.loaded || null; +} + export function useMutateProjectRundowns() { const ontimeQueryClient = useQueryClient(); diff --git a/apps/client/src/common/hooks-query/useRuns.ts b/apps/client/src/common/hooks-query/useRuns.ts new file mode 100644 index 000000000..fb5940f30 --- /dev/null +++ b/apps/client/src/common/hooks-query/useRuns.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { ShowRun, ShowRunSummary } from 'ontime-types'; +import { MILLIS_PER_HOUR } from 'ontime-utils'; + +import { getLatestRunQueryKey, REPORT_RUNS } from '../api/constants'; +import { fetchLatestRun, fetchRun, fetchRuns } from '../api/report'; + +/** + * Run history for the current project, optionally scoped to a rundown + */ +export default function useRuns(rundownId?: string) { + const { data, status, refetch } = useQuery({ + queryKey: rundownId ? [...REPORT_RUNS, rundownId] : REPORT_RUNS, + queryFn: ({ signal }) => fetchRuns(rundownId, { signal }), + placeholderData: (previousData, _previousQuery) => previousData, + staleTime: MILLIS_PER_HOUR, + }); + + return { data: data ?? [], status, refetch }; +} + +/** + * A single run with its per event data, fetched on demand when a run is selected + */ +export function useRun(id: string | null) { + const { data, status } = useQuery({ + queryKey: [...REPORT_RUNS, id], + queryFn: ({ signal }) => fetchRun(id as string, { signal }), + enabled: id !== null, + staleTime: MILLIS_PER_HOUR, + }); + + return { data, status }; +} + +/** + * Most recently closed run, used to compare a rundown against its last outing. + * Returns null while there is no run to compare against. + */ +export function useLatestRun(rundownId?: string) { + const { data, status } = useQuery({ + queryKey: getLatestRunQueryKey(rundownId), + queryFn: ({ signal }) => fetchLatestRun(rundownId, { signal }), + placeholderData: (previousData, _previousQuery) => previousData, + staleTime: MILLIS_PER_HOUR, + }); + + return { data: data ?? null, status }; +} 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..46446a0b4 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,104 +1,57 @@ -import { useMemo } from 'react'; -import { IoTrashBin } from 'react-icons/io5'; +import { useEffect, useState } from 'react'; -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 Select from '../../../../common/components/select/Select'; +import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; +import useRuns from '../../../../common/hooks-query/useRuns'; import * as Panel from '../../panel-utils/PanelUtils'; -import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils'; +import RunDetail from './composite/RunDetail'; +import RunsList from './composite/RunsList'; -import style from './ReportSettings.module.scss'; +const allRundowns = 'all'; export default function ReportSettings() { - const { data: reportData } = useReport(); - const { data } = useRundown(); + const { data: rundownsList } = useProjectRundowns(); + const [rundownFilter, setRundownFilter] = useState(allRundowns); + const { data: runs } = useRuns(rundownFilter === allRundowns ? undefined : rundownFilter); + const [selectedRunId, setSelectedRunId] = useState(null); - 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'); - }; + // keep a valid selection: default to the most recent run, and fall off the + // current one when it drops out of the filtered list + useEffect(() => { + if (selectedRunId && runs.some((run) => run.id === selectedRunId)) return; + setSelectedRunId(runs[0]?.id ?? null); + }, [runs, selectedRunId]); - const combinedReport = useMemo(() => { - return getCombinedReport(reportData, data.entries, data.flatOrder); - }, [reportData, data.entries, data.flatOrder]); + const rundownOptions = [ + { value: allRundowns, label: 'All rundowns' }, + ...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })), + ]; return ( - Report + + Show reports + {rundownsList.rundowns.length > 1 && ( + setRenameValue(event.target.value)} + onKeyDown={(event) => handleRenameKeyDown(event, run.id)} + /> + submitRename(run.id)}> + + + setRenamingId(null)} + > + + + + ) : ( + run.label + )} + + {run.rundownTitle} + {new Date(run.startedAt).toLocaleString()} + {formatDrift(run.summary.drift, run.summary.eventsRun)} + {run.endedAt === null && Ongoing} + event.stopPropagation()}> + {!isRenaming && ( + <> + startRename(run)}> + + + handleDelete(run.id)} + > + + + + )} + + + ); + })} + + + {error && {error}} + + ); +} 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..eba870aec 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,7 +1,18 @@ -import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; +import { EntryId, MaybeNumber, OntimeEventReport, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; +import { MILLIS_PER_SECOND } from 'ontime-utils'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; -import { formatTime } from '../../../../common/utils/time'; +import { formatDuration, formatTime } from '../../../../common/utils/time'; + +/** + * Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed + * events has no meaningful drift to report. + */ +export function formatDrift(drift: number, eventsRun: number): string { + if (eventsRun === 0) return '–'; + if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time'; + return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`; +} export type CombinedReport = { id: EntryId; @@ -12,59 +23,96 @@ export type CombinedReport = { actualStart: MaybeNumber; scheduledEnd: number; actualEnd: MaybeNumber; + playCount: number; }; /** - * Creates a combined report with the rundown data + * Creates a combined report joining a run's per event data with the rundown. + * + * A run's report keeps its own snapshot of the schedule (`scheduledStart` / + * `scheduledDuration`), so an event that ran is shown against the schedule as + * it was at the time, not against whatever the rundown has been edited to since. + * + * Events that ran but no longer exist in the rundown (deleted since the run) + * are still included, from the snapshot alone, so a historical run stays a + * complete record even after the rundown changes. */ export function getCombinedReport( report: OntimeReport, - rundown: RundownEntries, + rundownEntries: 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[] = []; - + const seen = new Set(); let index = 1; - for (let i = 0; i < flatOrder.length; i++) { - const id = flatOrder[i]; - const entry = rundown[id]; + + for (const id of flatOrder) { + const entry = rundownEntries[id]; if (!entry || !isOntimeEvent(entry)) 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, - }); - } + seen.add(id); + combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd)); + index++; + } - 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, - }); - } + for (const [id, reportEntry] of Object.entries(report)) { + if (seen.has(id)) continue; + combinedReport.push( + makeCombinedEntry( + id, + index, + '(deleted event)', + '–', + reportEntry, + reportEntry.scheduledStart, + reportEntry.scheduledStart + reportEntry.scheduledDuration, + ), + ); index++; } return combinedReport; } -const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; +function makeCombinedEntry( + id: EntryId, + index: number, + title: string, + cue: string, + reportEntry: OntimeEventReport | undefined, + fallbackStart: number, + fallbackEnd: number, +): CombinedReport { + if (!reportEntry) { + return { + id, + index, + title, + cue, + scheduledStart: fallbackStart, + scheduledEnd: fallbackEnd, + actualStart: null, + actualEnd: null, + playCount: 0, + }; + } + + return { + id, + index, + title, + cue, + scheduledStart: reportEntry.scheduledStart, + scheduledEnd: reportEntry.scheduledStart + reportEntry.scheduledDuration, + actualStart: reportEntry.startedAt, + actualEnd: reportEntry.endedAt, + playCount: reportEntry.playCount, + }; +} + +const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End', 'Play count']; /** * Transforms a CombinedReport into a CSV string @@ -82,6 +130,7 @@ export function makeReportCSV(combinedReport: CombinedReport[]) { formatTime(entry.actualStart), formatTime(entry.scheduledEnd), formatTime(entry.actualEnd), + String(entry.playCount), ]); } 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.module.scss b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss index a98c07977..e918bdbf0 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss @@ -16,4 +16,10 @@ &.due { color: $warning-orange; } + + // preview of how this event went last time it ran, shown before it plays again + &.muted { + color: $label-gray; + opacity: 0.7; + } } 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..d489aa887 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -1,10 +1,19 @@ import { Day } from 'ontime-types'; -import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils'; +import { + EventVariance, + MILLIS_PER_MINUTE, + MILLIS_PER_SECOND, + getEventVariance, + isPlaybackActive, + millisToString, +} from 'ontime-utils'; import { useMemo } from 'react'; import { IoCheckmarkCircle } from 'react-icons/io5'; import Tooltip from '../../../../common/components/tooltip/Tooltip'; +import { useLoadedRundownId } from '../../../../common/hooks-query/useProjectRundowns'; import useReport from '../../../../common/hooks-query/useReport'; +import { useLatestRun } from '../../../../common/hooks-query/useRuns'; import { usePlayback } from '../../../../common/hooks/useSocket'; import { cx } from '../../../../common/utils/styleUtils'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; @@ -20,7 +29,6 @@ interface RundownEventChipProps { isLoaded: boolean; className: string; totalGap: number; - duration: number; isLinkedToLoaded: boolean; } @@ -33,7 +41,6 @@ export default function RundownEventChip({ className, totalGap, id, - duration, isLinkedToLoaded, }: RundownEventChipProps) { const playback = usePlayback(); @@ -45,7 +52,7 @@ export default function RundownEventChip({ const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return ; + return ; } if (playbackActive) { @@ -86,49 +93,60 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: interface EventReportProps { className: string; id: string; - duration: number; } function EventReport(props: EventReportProps) { - const { className, id, duration } = props; + const { className, id } = props; const { data } = useReport(); const currentReport = data[id]; - const [value, overUnderStyle, tooltip] = useMemo(() => { - if (!currentReport) { - return [null, 'none', '']; + // an event with nothing in the current run's report yet can still show how + // it went last time, so a repeat show previews its schedule before it plays + const loadedRundownId = useLoadedRundownId(); + const { data: lastRun } = useLatestRun(loadedRundownId ?? undefined); + + const [value, chipStyle, tooltip] = useMemo(() => { + // compares against the schedule as it was when the event ran, not the + // rundown's current values, so an edit made afterwards cannot change this + const variance = getEventVariance(currentReport); + if (variance.status !== 'not-run') { + return describeVariance(variance, false); } - const { startedAt, endedAt } = currentReport; - if (!startedAt || !endedAt) { - return [null, 'none', '']; + const lastRunVariance = getEventVariance(lastRun?.report[id]); + if (lastRunVariance.status !== 'not-run') { + return describeVariance(lastRunVariance, true); } - const actualDuration = endedAt - startedAt; - const difference = actualDuration - duration; - const absDifference = Math.abs(difference); - - if (absDifference < MILLIS_PER_SECOND) { - 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 value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; - return [value, isOver ? 'over' : 'under', tooltip]; - }, [currentReport, duration]); + return [null, 'none', '']; + }, [currentReport, id, lastRun]); if (!value) { return null; } return ( - } className={cx([style.chip, style[overUnderStyle], className])}> + } className={cx([style.chip, style[chipStyle], className])}> {value === 'ontime' ? : value} ); } + +/** + * Formats a variance into the chip's value, style and tooltip. + * `muted` marks a preview of a past run rather than a live status. + */ +function describeVariance(variance: EventVariance, muted: boolean): [string, string, string] { + const prefix = muted ? 'Last run: ' : ''; + + if (variance.status === 'ontime') { + return ['ontime', muted ? 'muted' : 'under', `${prefix}Event finished on time`]; + } + + const absDifference = Math.abs(variance.delta); + const isOver = variance.status === 'over'; + const fullTimeValue = millisToString(absDifference); + const tooltip = `${prefix}Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; + const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; + return [value, muted ? 'muted' : isOver ? 'over' : 'under', tooltip]; +} 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..ab9edcd84 --- /dev/null +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -0,0 +1,285 @@ +import { TimerLifeCycle } from 'ontime-types'; +import type { PlayableEvent, ShowRun } from 'ontime-types'; +import { vi } from 'vitest'; + +import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js'; +import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; + +// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts +let runs: ShowRun[] = []; + +vi.mock('../../../services/report-service/report.store.js', () => ({ + // isolation between tests comes from the top-level beforeEach resetting `runs`, + // this mirrors the real store returning whatever is already on "disk" + loadReports: vi.fn(async () => ({ runs })), + getRuns: vi.fn(() => runs), + getRun: vi.fn((id: string) => runs.find((run) => run.id === id)), + upsertRun: vi.fn(async (run: ShowRun) => { + const index = runs.findIndex((candidate) => candidate.id === run.id); + if (index === -1) { + runs.unshift(run); + } else { + runs[index] = run; + } + }), + deleteRun: vi.fn(async (id: string) => { + const index = runs.findIndex((run) => run.id === id); + if (index === -1) return false; + runs.splice(index, 1); + return true; + }), + deleteRunsForRundown: vi.fn(async (rundownId: string) => { + const before = runs.length; + runs = runs.filter((run) => run.rundownId !== rundownId); + return before - runs.length; + }), + deleteAllRuns: vi.fn(async () => { + runs = []; + }), +})); + +let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' }); + +vi.mock('../../rundown/rundown.dao.js', () => ({ + getCurrentRundown: vi.fn(() => currentRundown), +})); + +const { + generate, + clear, + triggerReportEntry, + closeRun, + initReports, + listRuns, + getRun, + getLatestRun, + renameRun, + deleteRun, + deleteAllRuns, +} = await import('../report.service.js'); + +const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent; +const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000, duration: 10000 }) as PlayableEvent; + +beforeEach(async () => { + runs = []; + currentRundown = makeRundown({ + id: 'rundown-1', + title: 'Test rundown', + entries: { [eventA.id]: eventA, [eventB.id]: eventB }, + order: [eventA.id, eventB.id], + flatOrder: [eventA.id, eventB.id], + }); + await initReports('project-a'); +}); + +describe('triggerReportEntry()', () => { + it('captures a snapshot of the schedule when an event starts', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + + expect(generate()).toMatchObject({ + [eventA.id]: { + startedAt: 500, + endedAt: null, + scheduledStart: eventA.timeStart, + scheduledDuration: eventA.duration, + playCount: 1, + }, + }); + }); + + it('records the end time on stop, keeping the snapshot taken at start', () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toMatchObject({ + startedAt: 0, + endedAt: 12000, + scheduledDuration: eventA.duration, + }); + }); + + it('increments playCount when an event is re-run within the same show', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state); + triggerReportEntry(TimerLifeCycle.onStart, { ...state, clock: 5000 } as typeof state); + + expect(generate()[eventA.id].playCount).toBe(2); + }); + + it('ignores events without an id', () => { + const state = makeRuntimeStateData({ eventNow: null }); + triggerReportEntry(TimerLifeCycle.onStart, state); + expect(generate()).toEqual({}); + }); + + it('persists a run to history on the first event stop', async () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + // persistence happens off the event loop + await new Promise((resolve) => setImmediate(resolve)); + + expect(listRuns()).toHaveLength(1); + expect(listRuns()[0].rundownId).toBe('rundown-1'); + }); +}); + +describe('closeRun()', () => { + it('closes the open run and starts a fresh one on the next event', async () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); + + closeRun(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listRuns()).toHaveLength(1); + expect(listRuns()[0].endedAt).toBe(10000); + + // a new start after closing opens a second run rather than reusing the first + const secondStart = makeRuntimeStateData({ eventNow: eventB, timer: { startedAt: 20000 }, clock: 20000 }); + triggerReportEntry(TimerLifeCycle.onStart, secondStart); + triggerReportEntry(TimerLifeCycle.onStop, { ...secondStart, clock: 30000 } as typeof secondStart); + await new Promise((resolve) => setImmediate(resolve)); + + expect(listRuns()).toHaveLength(2); + }); + + it('does nothing when no run is open', () => { + expect(() => closeRun()).not.toThrow(); + expect(listRuns()).toHaveLength(0); + }); +}); + +describe('initReports()', () => { + it('closes a dangling run left open by a crash or shutdown', async () => { + runs = [ + { + id: 'dangling', + rundownId: 'rundown-1', + rundownTitle: 'Test rundown', + label: 'unfinished', + startedAt: 0, + endedAt: null, + report: { + [eventA.id]: { + startedAt: 0, + endedAt: 9000, + scheduledStart: 0, + scheduledDuration: 10000, + playCount: 1, + }, + }, + summary: { + eventsRun: 1, + eventsPlanned: 2, + scheduledDuration: 10000, + actualDuration: 9000, + drift: -1000, + eventsOver: 0, + eventsUnder: 1, + eventsOnTime: 0, + worstOverrun: null, + }, + }, + ]; + + await initReports('project-a'); + + const recovered = getRun('dangling'); + expect(recovered?.endedAt).toBe(9000); + }); +}); + +describe('run history queries and edits', () => { + async function makeClosedRun(id: string, rundownId = 'rundown-1') { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + currentRundown = { ...currentRundown, id: rundownId }; + triggerReportEntry(TimerLifeCycle.onStart, start); + triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); + closeRun(); + await new Promise((resolve) => setImmediate(resolve)); + // stamp a predictable id so tests can address the run directly + const created = listRuns()[0]; + runs[0] = { ...runs[0], id }; + return created; + } + + it('filters listRuns by rundown', async () => { + await makeClosedRun('run-a', 'rundown-1'); + await makeClosedRun('run-b', 'rundown-2'); + + expect(listRuns('rundown-1').map((run) => run.id)).toEqual(['run-a']); + expect(listRuns('rundown-2').map((run) => run.id)).toEqual(['run-b']); + expect(listRuns()).toHaveLength(2); + }); + + it('returns the most recently started closed run', async () => { + await makeClosedRun('older'); + await makeClosedRun('newer'); + runs.find((run) => run.id === 'older')!.startedAt = 0; + runs.find((run) => run.id === 'newer')!.startedAt = 100000; + + expect(getLatestRun()?.id).toBe('newer'); + }); + + it('renames a run', async () => { + await makeClosedRun('run-a'); + const renamed = await renameRun('run-a', 'Dress rehearsal'); + expect(renamed?.label).toBe('Dress rehearsal'); + expect(getRun('run-a')?.label).toBe('Dress rehearsal'); + }); + + it('returns undefined when renaming a run that does not exist', async () => { + expect(await renameRun('missing', 'x')).toBeUndefined(); + }); + + it('deletes a single run', async () => { + await makeClosedRun('run-a'); + expect(await deleteRun('run-a')).toBe(true); + expect(getRun('run-a')).toBeUndefined(); + }); + + it('clears the in-progress report when the open run is deleted', async () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); + await new Promise((resolve) => setImmediate(resolve)); + + const openRunId = listRuns()[0].id; + await deleteRun(openRunId); + + expect(generate()).toEqual({}); + }); + + it('deletes all run history', async () => { + await makeClosedRun('run-a'); + await makeClosedRun('run-b'); + await deleteAllRuns(); + expect(listRuns()).toHaveLength(0); + }); +}); + +describe('clear()', () => { + it('clears a single event from the in-progress report', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + clear(eventA.id); + expect(generate()).toEqual({}); + }); + + it('clears the entire in-progress report', () => { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, state); + clear(); + expect(generate()).toEqual({}); + }); +}); diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index 94b35dc42..8758b83cd 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -3,15 +3,80 @@ import type { Request, Response, Router } from 'express'; import { paramsWithId } from '../validation-utils/validationFunction.js'; import * as report from './report.service.js'; +import { validateRundownIdQuery, validateRunLabel } from './report.validation.js'; export const router: Router = express.Router(); +/** + * Current run's report, kept unchanged so existing HTTP automations and the + * Companion module are unaffected. + */ router.get('/', (_req: Request, res: Response) => { res.status(200).json(report.generate()); }); -router.delete('/all', (_req: Request, res: Response) => { - report.clear(); +/** + * Run history, most recent first. `?rundownId=` scopes the list to one rundown. + */ +router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => { + const { rundownId } = req.query as { rundownId?: string }; + res.status(200).json(report.listRuns(rundownId)); +}); + +/** + * Most recently closed run, used to compare a rundown against its last outing. + * Registered ahead of /runs/:id so "latest" is not read as an id. + */ +router.get('/runs/latest', validateRundownIdQuery, (req: Request, res: Response) => { + const { rundownId } = req.query as { rundownId?: string }; + const run = report.getLatestRun(rundownId); + if (!run) { + res.status(404).send(); + return; + } + res.status(200).json(run); +}); + +router.get('/runs/:id', paramsWithId, (req: Request, res: Response) => { + const { id } = req.params; + const run = report.getRun(id); + if (!run) { + res.status(404).send(); + return; + } + res.status(200).json(run); +}); + +/** + * Renames a run, the only field a user can edit after the fact. + */ +router.patch('/runs/:id', validateRunLabel, async (req: Request, res: Response) => { + const { id } = req.params; + const { label } = req.body as { label: string }; + const run = await report.renameRun(id, label); + if (!run) { + res.status(404).send(); + return; + } + res.status(200).json(run); +}); + +/** + * Deletes a single run, eg: a test run that should not pollute the history. + */ +router.delete('/runs/:id', paramsWithId, async (req: Request, res: Response) => { + const { id } = req.params; + const didDelete = await report.deleteRun(id); + if (!didDelete) { + res.status(404).send(); + return; + } + res.status(204).send(); +}); + +router.delete('/all', async (_req: Request, res: Response) => { + // clears both the run history and the report of the run in progress + await report.deleteAllRuns(); 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..f0d28f8af 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,13 +1,28 @@ -import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types'; +import { + EntryId, + OntimeEventReport, + OntimeReport, + RefetchKey, + ShowRun, + ShowRunSummary, + TimerLifeCycle, +} from 'ontime-types'; +import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils'; import { DeepReadonly } from 'ts-essentials'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; +import * as reportStore from '../../services/report-service/report.store.js'; import { RuntimeState } from '../../stores/runtimeState.js'; +import { getCurrentRundown } from '../rundown/rundown.dao.js'; -const report = new Map(); +/** per event data for the run currently in progress */ +const report = new Map(); let formattedReport: OntimeReport | null = null; +/** metadata for the run in progress, null when no run is open */ +let openRun: Omit | null = null; + /** * generates a full report * @returns full report @@ -30,6 +45,7 @@ export function clear(id?: string) { } else { report.clear(); } + void persistOpenRun(); } /** @@ -49,15 +65,197 @@ export function triggerReportEntry( const eventId = state.eventNow.id; if (cycle === TimerLifeCycle.onStart) { - report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null }); + openRunIfNeeded(state); + + // an event started twice in the same run is a re-run, not a new record + const playCount = (report.get(eventId)?.playCount ?? 0) + 1; + + report.set(eventId, { + startedAt: state.timer.startedAt, + endedAt: null, + // snapshot the schedule so later rundown edits cannot rewrite this run + scheduledStart: state.eventNow.timeStart, + scheduledDuration: state.eventNow.duration, + playCount, + }); formattedReport = null; return; } if (cycle === TimerLifeCycle.onStop) { - const startedAt = report.get(eventId)?.startedAt ?? null; - report.set(eventId, { startedAt, endedAt: state.clock }); + const previous = report.get(eventId); + report.set(eventId, { + startedAt: previous?.startedAt ?? null, + endedAt: state.clock, + scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart, + scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration, + playCount: previous?.playCount ?? 1, + }); formattedReport = null; + void persistOpenRun(); sendRefetch(RefetchKey.Report); } } + +/** + * Closes the run in progress. + * Called when playback stops and events are unloaded, which is the operator + * saying the show is over. Pausing or loading another event does not end a run. + */ +export function closeRun() { + if (openRun === null) { + return; + } + + // detach the run before the async write so a start arriving in between + // opens a new run instead of appending to the one we are closing + const closing = { ...openRun, endedAt: lastEndedAt() }; + openRun = null; + + void persistRun(closing, generate()); + sendRefetch(RefetchKey.Report); +} + +/** + * Opens a run on the first event start after the previous run closed. + * The in progress report is reset here rather than on close, so the rundown + * chips keep showing the run that just finished. + * @private + */ +function openRunIfNeeded(state: DeepReadonly) { + if (openRun !== null) { + return; + } + + report.clear(); + formattedReport = null; + + const rundown = getCurrentRundown(); + const startedAt = state.rundown.actualStart ?? state.clock; + + openRun = { + id: generateId(), + rundownId: rundown.id, + rundownTitle: rundown.title, + label: new Date().toISOString(), + startedAt, + endedAt: null, + }; +} + +/** + * Writes the run in progress to the sidecar. + * Persisting on every event stop means an interrupted show still leaves a record. + * @private + */ +async function persistOpenRun(): Promise { + if (openRun === null) { + return; + } + await persistRun(openRun, generate()); +} + +/** + * Writes a run and its derived summary to the sidecar + * @private + */ +async function persistRun(run: Omit, currentReport: OntimeReport): Promise { + const rundown = getCurrentRundown(); + const eventsPlanned = countPlannedEvents(rundown.entries, rundown.flatOrder); + + await reportStore.upsertRun({ + ...run, + report: structuredClone(currentReport), + summary: getRunSummary(currentReport, eventsPlanned), + }); +} + +/** + * Timestamp of the last event to finish in this run + * @private + */ +function lastEndedAt(): number | null { + let latest: number | null = null; + for (const entry of report.values()) { + if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) { + latest = entry.endedAt; + } + } + return latest; +} + +/** + * Prepares reporting for a newly loaded project. + * Any run left open by a crash or shutdown is closed against its own data + * so it cannot absorb events from the next show. + */ +export async function initReports(projectFilename: string): Promise { + report.clear(); + formattedReport = null; + openRun = null; + + await reportStore.loadReports(projectFilename); + + const dangling = reportStore.getRuns().find((run) => run.endedAt === null); + if (dangling) { + const endedAt = Object.values(dangling.report).reduce((latest, entry) => { + if (entry.endedAt === null) return latest; + return latest === null || entry.endedAt > latest ? entry.endedAt : latest; + }, null); + await reportStore.upsertRun({ ...dangling, endedAt }); + } +} + +/** Run history for the current project, without per event data */ +export function listRuns(rundownId?: string): ShowRunSummary[] { + return reportStore + .getRuns() + .filter((run) => rundownId === undefined || run.rundownId === rundownId) + .map(({ report: _report, ...rest }) => rest); +} + +export function getRun(id: string): ShowRun | undefined { + return reportStore.getRun(id); +} + +/** Most recent closed run, used to compare a rundown against its last outing */ +export function getLatestRun(rundownId?: string): ShowRun | undefined { + return reportStore + .getRuns() + .filter((run) => run.endedAt !== null && (rundownId === undefined || run.rundownId === rundownId)) + .sort((a, b) => b.startedAt - a.startedAt) + .at(0); +} + +export async function renameRun(id: string, label: string): Promise { + const run = reportStore.getRun(id); + if (!run) { + return undefined; + } + + const renamed = { ...run, label }; + await reportStore.upsertRun(renamed); + sendRefetch(RefetchKey.Report); + return renamed; +} + +export async function deleteRun(id: string): Promise { + const didDelete = await reportStore.deleteRun(id); + if (didDelete) { + if (openRun?.id === id) { + openRun = null; + report.clear(); + formattedReport = null; + } + sendRefetch(RefetchKey.Report); + } + return didDelete; +} + +export async function deleteAllRuns(): Promise { + await reportStore.deleteAllRuns(); + openRun = null; + report.clear(); + formattedReport = null; + sendRefetch(RefetchKey.Report); +} diff --git a/apps/server/src/api-data/report/report.validation.ts b/apps/server/src/api-data/report/report.validation.ts new file mode 100644 index 000000000..85400244e --- /dev/null +++ b/apps/server/src/api-data/report/report.validation.ts @@ -0,0 +1,14 @@ +import { body, param, query } from 'express-validator'; + +import { requestValidationFunction } from '../validation-utils/validationFunction.js'; + +export const validateRundownIdQuery = [ + query('rundownId').optional().isString().trim().notEmpty(), + requestValidationFunction, +]; + +export const validateRunLabel = [ + param('id').isString().trim().notEmpty(), + body('label').isString().trim().notEmpty(), + requestValidationFunction, +]; diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 44c2f1637..b5830461d 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -25,6 +25,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { logger } from '../../classes/Logger.js'; import { makeNewRundown } from '../../models/dataModel.js'; import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js'; +import { deleteRunsForRundown } from '../../services/report-service/report.store.js'; import { runtimeService } from '../../services/runtime-service/runtime.service.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { parseCustomFields } from '../custom-fields/customFields.parser.js'; @@ -892,6 +893,9 @@ export async function deleteRundown(id: string) { const projectRundowns = await dataProvider.deleteRundown(id); + // a rundown's run history has no meaning once the rundown is gone + await deleteRunsForRundown(id); + setImmediate(() => { sendRefetch(RefetchKey.ProjectRundowns); }); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 3cad948c3..e6da398f2 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -6,6 +6,8 @@ import { getErrorMessage, getFirstRundown } from 'ontime-utils'; import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js'; import { parseDatabaseModel } from '../../api-data/db/db.parser.js'; +import { initReports } from '../../api-data/report/report.service.js'; +import { deleteReportsForProject, renameReportsForProject } from '../report-service/report.store.js'; import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { parseRundowns } from '../../api-data/rundown/rundown.parser.js'; import { initRundown } from '../../api-data/rundown/rundown.service.js'; @@ -63,6 +65,7 @@ function init() { ensureDirectory(publicDir.corruptDir); ensureDirectory(publicDir.logoDir); ensureDirectory(publicDir.migrateDir); + ensureDirectory(publicDir.reportsDir); } export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { @@ -88,6 +91,9 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown // stop the runtime service runtimeService.stop(); + // point reporting at this project's sidecar, reports do not cross projects + await initReports(fileName); + // load the rundown given by key otherwise load the first in the project const rundown = rundownId && rundownId in projectData.rundowns @@ -263,6 +269,8 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st const pathToDuplicate = getPathToProject(newFilename); await copyFile(projectFilePath, pathToDuplicate); + // deliberately not copying report history: a duplicate is a new show and + // inheriting another project's run history would be misleading return; } @@ -284,6 +292,9 @@ export async function renameProjectFile(originalFile: string, newFilename: strin const pathToRenamed = getPathToProject(newFilename); await dockerSafeRename(projectFilePath, pathToRenamed); + // run history follows the project it belongs to + await renameReportsForProject(originalFile, newFilename); + // Update the last loaded project config if current loaded project is the one being renamed const isLoaded = await isLastLoadedProject(originalFile); if (isLoaded) { @@ -332,6 +343,8 @@ export async function deleteProjectFile(filename: string) { } await deleteFile(projectFilePath); + // reports are owned by their project and do not outlive it + await deleteReportsForProject(filename); } /** diff --git a/apps/server/src/services/report-service/__tests__/report.store.test.ts b/apps/server/src/services/report-service/__tests__/report.store.test.ts new file mode 100644 index 000000000..65c7a6adc --- /dev/null +++ b/apps/server/src/services/report-service/__tests__/report.store.test.ts @@ -0,0 +1,213 @@ +import type { ShowRun } from 'ontime-types'; +import { vi } from 'vitest'; + +// in-memory stand-in for the JSON file on disk, keyed by path +const files = new Map(); + +vi.mock('lowdb/node', () => { + class JSONFile { + private path: string; + constructor(path: string) { + this.path = path; + } + async read() { + return files.has(this.path) ? files.get(this.path) : null; + } + async write(data: unknown) { + files.set(this.path, data); + } + } + return { JSONFile }; +}); + +vi.mock('../../../utils/fileManagement.js', async () => { + const actual = await vi.importActual( + '../../../utils/fileManagement.js', + ); + return { + ...actual, + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + dockerSafeRename: vi.fn(async (oldPath: string, newPath: string) => { + if (files.has(oldPath)) { + files.set(newPath, files.get(oldPath)); + files.delete(oldPath); + } + }), + statIfExists: vi.fn(async (path: string) => (files.has(path) ? {} : null)), + }; +}); + +const { + loadReports, + getRuns, + getRun, + upsertRun, + deleteRun, + deleteRunsForRundown, + deleteAllRuns, + deleteReportsForProject, + renameReportsForProject, + resetStore, + getPathToReports, +} = await import('../report.store.js'); + +function makeRun(patch: Partial = {}): ShowRun { + return { + id: 'run-1', + rundownId: 'rundown-1', + rundownTitle: 'My rundown', + label: '2026-08-08', + startedAt: 1000, + endedAt: 2000, + report: {}, + summary: { + eventsRun: 0, + eventsPlanned: 0, + scheduledDuration: 0, + actualDuration: 0, + drift: 0, + eventsOver: 0, + eventsUnder: 0, + eventsOnTime: 0, + worstOverrun: null, + }, + ...patch, + }; +} + +beforeEach(() => { + files.clear(); + resetStore(); +}); + +describe('loadReports()', () => { + it('starts empty for a project with no sidecar', async () => { + const result = await loadReports('project-a'); + expect(result.runs).toEqual([]); + expect(getRuns()).toEqual([]); + }); + + it('loads runs already on disk', async () => { + files.set(getPathToReports('project-a'), { runs: [makeRun()] }); + const result = await loadReports('project-a'); + expect(result.runs).toHaveLength(1); + expect(getRuns()[0].id).toBe('run-1'); + }); + + it('discards a corrupt sidecar rather than throwing', async () => { + files.set(getPathToReports('project-a'), { runs: 'not-an-array' }); + const result = await loadReports('project-a'); + expect(result.runs).toEqual([]); + }); + + it('scopes runs to the loaded project', async () => { + files.set(getPathToReports('project-a'), { runs: [makeRun({ id: 'a' })] }); + files.set(getPathToReports('project-b'), { runs: [makeRun({ id: 'b' })] }); + + await loadReports('project-a'); + expect(getRuns().map((run) => run.id)).toEqual(['a']); + + await loadReports('project-b'); + expect(getRuns().map((run) => run.id)).toEqual(['b']); + }); +}); + +describe('upsertRun() / getRun() / getRuns()', () => { + beforeEach(async () => { + await loadReports('project-a'); + }); + + it('inserts a new run at the front of the list', async () => { + await upsertRun(makeRun({ id: 'first' })); + await upsertRun(makeRun({ id: 'second' })); + expect(getRuns().map((run) => run.id)).toEqual(['second', 'first']); + }); + + it('replaces an existing run in place rather than duplicating it', async () => { + await upsertRun(makeRun({ id: 'run-1', label: 'first pass' })); + await upsertRun(makeRun({ id: 'run-1', label: 'renamed' })); + + expect(getRuns()).toHaveLength(1); + expect(getRun('run-1')?.label).toBe('renamed'); + }); + + it('persists to disk', async () => { + await upsertRun(makeRun()); + const reloaded = await loadReports('project-a'); + expect(reloaded.runs).toHaveLength(1); + }); +}); + +describe('deleteRun()', () => { + beforeEach(async () => { + await loadReports('project-a'); + await upsertRun(makeRun({ id: 'keep' })); + await upsertRun(makeRun({ id: 'discard' })); + }); + + it('removes only the targeted run', async () => { + const didDelete = await deleteRun('discard'); + expect(didDelete).toBe(true); + expect(getRuns().map((run) => run.id)).toEqual(['keep']); + }); + + it('reports false for a run that does not exist', async () => { + expect(await deleteRun('missing')).toBe(false); + expect(getRuns()).toHaveLength(2); + }); +}); + +describe('deleteRunsForRundown()', () => { + it('removes only runs belonging to the given rundown', async () => { + await loadReports('project-a'); + await upsertRun(makeRun({ id: 'a', rundownId: 'rundown-x' })); + await upsertRun(makeRun({ id: 'b', rundownId: 'rundown-y' })); + await upsertRun(makeRun({ id: 'c', rundownId: 'rundown-x' })); + + const removed = await deleteRunsForRundown('rundown-x'); + + expect(removed).toBe(2); + expect(getRuns().map((run) => run.id)).toEqual(['b']); + }); +}); + +describe('deleteAllRuns()', () => { + it('empties the run history', async () => { + await loadReports('project-a'); + await upsertRun(makeRun()); + await deleteAllRuns(); + expect(getRuns()).toEqual([]); + }); +}); + +describe('project lifecycle', () => { + it('deletes the sidecar for a project', async () => { + await loadReports('project-a'); + await upsertRun(makeRun()); + expect(files.has(getPathToReports('project-a'))).toBe(true); + + await deleteReportsForProject('project-a'); + expect(files.has(getPathToReports('project-a'))).toBe(false); + }); + + it('does nothing when the project never had a sidecar', async () => { + await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined(); + }); + + it('moves the sidecar to follow a project rename', async () => { + await loadReports('project-a'); + await upsertRun(makeRun()); + + await renameReportsForProject('project-a', 'project-b'); + + expect(files.has(getPathToReports('project-a'))).toBe(false); + const moved = files.get(getPathToReports('project-b')) as { runs: ShowRun[] }; + expect(moved.runs).toHaveLength(1); + }); + + it('does nothing when renaming a project that never had a sidecar', async () => { + await expect(renameReportsForProject('never-loaded', 'still-never-loaded')).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/report-service/report.parser.ts b/apps/server/src/services/report-service/report.parser.ts new file mode 100644 index 000000000..e8830ebda --- /dev/null +++ b/apps/server/src/services/report-service/report.parser.ts @@ -0,0 +1,130 @@ +import type { OntimeEventReport, ProjectReports, RunSummary, ShowRun } from 'ontime-types'; + +import { is } from '../../utils/is.js'; + +/** + * Validates the contents of a report sidecar file. + * A file which fails validation is discarded rather than repaired: reports are + * a record, and a partially understood record is worse than an empty one. + */ +export function isProjectReports(value: unknown): value is ProjectReports { + if (!is.object(value)) { + return false; + } + + if (!is.objectWithKeys(value, ['runs'])) { + return false; + } + + if (!is.array(value.runs)) { + return false; + } + + return value.runs.every(isShowRun); +} + +function isShowRun(value: unknown): value is ShowRun { + if (!is.object(value)) { + return false; + } + + if ( + !is.objectWithKeys(value, [ + 'id', + 'rundownId', + 'rundownTitle', + 'label', + 'startedAt', + 'endedAt', + 'report', + 'summary', + ]) + ) { + return false; + } + + if (!is.string(value.id) || !is.string(value.rundownId) || !is.string(value.rundownTitle)) { + return false; + } + + if (!is.string(value.label)) { + return false; + } + + if (!is.number(value.startedAt)) { + return false; + } + + if (!is.number(value.endedAt) && value.endedAt !== null) { + return false; + } + + if (!isOntimeReport(value.report)) { + return false; + } + + return isRunSummary(value.summary); +} + +function isOntimeReport(value: unknown): value is Record { + if (!is.object(value)) { + return false; + } + + return Object.values(value).every(isEventReport); +} + +function isEventReport(value: unknown): value is OntimeEventReport { + if (!is.object(value)) { + return false; + } + + if (!is.objectWithKeys(value, ['startedAt', 'endedAt', 'scheduledStart', 'scheduledDuration', 'playCount'])) { + return false; + } + + if (!is.number(value.startedAt) && value.startedAt !== null) { + return false; + } + + if (!is.number(value.endedAt) && value.endedAt !== null) { + return false; + } + + return is.number(value.scheduledStart) && is.number(value.scheduledDuration) && is.number(value.playCount); +} + +function isRunSummary(value: unknown): value is RunSummary { + if (!is.object(value)) { + return false; + } + + const numericKeys = [ + 'eventsRun', + 'eventsPlanned', + 'scheduledDuration', + 'actualDuration', + 'drift', + 'eventsOver', + 'eventsUnder', + 'eventsOnTime', + ] as const; + + if (!is.objectWithKeys(value, [...numericKeys, 'worstOverrun'])) { + return false; + } + + if (!numericKeys.every((key) => is.number(value[key]))) { + return false; + } + + if (value.worstOverrun === null) { + return true; + } + + if (!is.object(value.worstOverrun) || !is.objectWithKeys(value.worstOverrun, ['id', 'delta'])) { + return false; + } + + return is.string(value.worstOverrun.id) && is.number(value.worstOverrun.delta); +} diff --git a/apps/server/src/services/report-service/report.store.ts b/apps/server/src/services/report-service/report.store.ts new file mode 100644 index 000000000..53e633cb0 --- /dev/null +++ b/apps/server/src/services/report-service/report.store.ts @@ -0,0 +1,182 @@ +import { join } from 'path'; + +import { JSONFile } from 'lowdb/node'; +import type { ProjectReports, ShowRun } from 'ontime-types'; + +import { publicDir } from '../../setup/index.js'; +import { deleteFile, dockerSafeRename, ensureJsonExtension, statIfExists } from '../../utils/fileManagement.js'; +import { isProjectReports } from './report.parser.js'; + +/** + * Reports are kept in a sidecar file per project rather than in the project + * itself. This keeps the project file free of mid-show writes and lets the + * run history grow without bloating what the user exports. + * + * Persistence is best effort by design: a failing disk degrades reporting + * but must never interrupt a running show. + */ + +/** + * Returns a fresh empty store. + * Must not be a shared constant: `cache.runs` is mutated in place elsewhere + * in this module, and a shared array would leak state between projects. + */ +function emptyStore(): ProjectReports { + return { runs: [] }; +} + +let fileRef: JSONFile | null = null; +let cache: ProjectReports = emptyStore(); +let failedWriteAttempts = 0; + +/** + * Resolves the sidecar path for a given project file name + */ +export function getPathToReports(projectFilename: string): string { + return join(publicDir.reportsDir, ensureJsonExtension(projectFilename)); +} + +/** + * Points the store at a project's sidecar and loads whatever is on disk. + * Called on every project load, which is the single choke point for + * project changes. + */ +export async function loadReports(projectFilename: string): Promise { + fileRef = new JSONFile(getPathToReports(projectFilename)); + failedWriteAttempts = 0; + + try { + const maybeReports = await fileRef.read(); + cache = isProjectReports(maybeReports) ? maybeReports : emptyStore(); + } catch (_error) { + // a missing or corrupt sidecar is not worth interrupting a project load over + cache = emptyStore(); + } + + return cache; +} + +/** + * Returns the runs held for the current project, newest first + */ +export function getRuns(): ShowRun[] { + return cache.runs; +} + +export function getRun(id: string): ShowRun | undefined { + return cache.runs.find((run) => run.id === id); +} + +/** + * Inserts or replaces a run, keeping the list ordered newest first + */ +export async function upsertRun(run: ShowRun): Promise { + const index = cache.runs.findIndex((candidate) => candidate.id === run.id); + if (index === -1) { + cache.runs.unshift(run); + } else { + cache.runs[index] = run; + } + await persist(); +} + +/** + * Deletes a single run, used to discard a test run from the history + * @returns whether a run was found and removed + */ +export async function deleteRun(id: string): Promise { + const index = cache.runs.findIndex((run) => run.id === id); + if (index === -1) { + return false; + } + + cache.runs.splice(index, 1); + await persist(); + return true; +} + +/** + * Deletes every run belonging to a rundown, cascaded from rundown deletion + * @returns how many runs were removed + */ +export async function deleteRunsForRundown(rundownId: string): Promise { + const before = cache.runs.length; + cache.runs = cache.runs.filter((run) => run.rundownId !== rundownId); + + const removed = before - cache.runs.length; + if (removed > 0) { + await persist(); + } + return removed; +} + +/** + * Clears the run history of the current project + */ +export async function deleteAllRuns(): Promise { + cache.runs = []; + await persist(); +} + +/** + * Removes a project's sidecar from disk. + * Reports are owned by their project and do not outlive it. + */ +export async function deleteReportsForProject(projectFilename: string): Promise { + const path = getPathToReports(projectFilename); + try { + if ((await statIfExists(path)) !== null) { + await deleteFile(path); + } + } catch (_error) { + // a leftover sidecar is harmless, deleting the project must still succeed + } +} + +/** + * Moves a project's sidecar so run history follows a project rename + */ +export async function renameReportsForProject(originalFilename: string, newFilename: string): Promise { + const originalPath = getPathToReports(originalFilename); + const newPath = getPathToReports(newFilename); + + try { + if ((await statIfExists(originalPath)) === null) { + return; + } + await dockerSafeRename(originalPath, newPath); + // keep the reference pointing at the file we just moved + if (fileRef) { + fileRef = new JSONFile(newPath); + } + } catch (_error) { + // losing history on rename is bad but not fatal, the project rename stands + } +} + +/** + * Writes the cache to disk. + * Gives up after repeated failures so a broken disk cannot stall the runtime. + * @private + */ +async function persist(): Promise { + if (fileRef === null || failedWriteAttempts > 3) { + return; + } + + try { + await fileRef.write(cache); + failedWriteAttempts = 0; + } catch (_error) { + failedWriteAttempts += 1; + } +} + +/** + * Resets in-memory state, used when no project is loaded and in tests + */ +export function resetStore(): void { + fileRef = null; + cache = emptyStore(); + failedWriteAttempts = 0; +} diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index ee6d0de58..c03603b26 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -17,7 +17,7 @@ import { import { millisToString, validatePlayback } from 'ontime-utils'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; -import { triggerReportEntry } from '../../api-data/report/report.service.js'; +import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js'; import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { logger } from '../../classes/Logger.js'; @@ -523,6 +523,8 @@ class RuntimeService { logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); + // a full stop unloads the events, which is the operator ending the show + closeRun(); triggerAutomations(TimerLifeCycle.onStop); }); diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 895201a7c..9cf52283e 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -23,6 +23,7 @@ export const config = { external: 'external', demo: 'demo', projects: 'projects', + reports: 'reports', sheets: { directory: 'sheets', }, diff --git a/apps/server/src/setup/index.ts b/apps/server/src/setup/index.ts index b5e44cda3..79e452f2f 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -124,6 +124,8 @@ export const publicDir = { crashDir: join(resolvePublicDirectory, config.crash), /** path to projects folder */ projectsDir: join(resolvePublicDirectory, config.projects), + /** path to show reports folder, one sidecar file per project */ + reportsDir: join(resolvePublicDirectory, config.reports), /** path to corrupt folder */ corruptDir: join(resolvePublicDirectory, config.corrupt), /** path to migrated folder */ diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index c34a0978d..290d742ab 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -1,8 +1,58 @@ import type { MaybeNumber } from '../../utils/utils.type.js'; +import type { EntryId } from './OntimeEntry.js'; export type OntimeEventReport = { startedAt: MaybeNumber; endedAt: MaybeNumber; + /** + * Snapshot of the schedule taken when the event ran. + * Keeping a copy is what makes a report a record: editing the rundown + * afterwards no longer rewrites history. + */ + scheduledStart: number; + scheduledDuration: number; + /** how many times the event was started within this run, >1 means it was re-run */ + playCount: number; }; -export type OntimeReport = Record; +export type OntimeReport = Record; + +export type RunSummary = { + /** events which produced a report entry */ + eventsRun: number; + /** playable events in the rundown at the time the summary was made */ + eventsPlanned: number; + scheduledDuration: number; + actualDuration: number; + /** actualDuration - scheduledDuration, signed */ + drift: number; + eventsOver: number; + eventsUnder: number; + eventsOnTime: number; + /** largest single overrun, answers "what blew the schedule" */ + worstOverrun: { id: EntryId; delta: number } | null; +}; + +export type ShowRun = { + id: string; + rundownId: string; + /** + * Denormalised so a run stays readable after its rundown + * is renamed or deleted. + */ + rundownTitle: string; + /** user editable, defaults to a formatted timestamp */ + label: string; + startedAt: number; + endedAt: MaybeNumber; + report: OntimeReport; + summary: RunSummary; +}; + +/** A run without its per event data, for list views */ +export type ShowRunSummary = Omit; + +/** Contents of a project's report sidecar file */ +export type ProjectReports = { + runs: ShowRun[]; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 77e98fd91..c82d1d124 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,7 +24,14 @@ export { TimerType } from './definitions/TimerType.type.js'; export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js'; // ---> Report -export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js'; +export type { + OntimeReport, + OntimeEventReport, + ProjectReports, + RunSummary, + ShowRun, + ShowRunSummary, +} from './definitions/core/Report.type.js'; // ---> Automations export { ontimeActionKeyValues } from './definitions/core/Automation.type.js'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index eff99e854..e1aae9e41 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -99,6 +99,15 @@ export { export { isPlaybackActive } from './src/playback-utils/playbackstate.js'; +// feature business logic - reports +export { + countPlannedEvents, + getEventVariance, + getRunSummary, + type EventVariance, + type VarianceStatus, +} from './src/report-utils/reportUtils.js'; + //Colour export { colourToHex, diff --git a/packages/utils/src/report-utils/reportUtils.test.ts b/packages/utils/src/report-utils/reportUtils.test.ts new file mode 100644 index 000000000..98ea29f7d --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.test.ts @@ -0,0 +1,112 @@ +import type { OntimeEventReport, OntimeReport } from 'ontime-types'; + +import { getEventVariance, getRunSummary } from './reportUtils.js'; + +function makeEntry(patch: Partial = {}): OntimeEventReport { + return { + startedAt: 0, + endedAt: 10000, + scheduledStart: 0, + scheduledDuration: 10000, + playCount: 1, + ...patch, + }; +} + +describe('getEventVariance()', () => { + it('reports an event which never ran', () => { + expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null, delta: 0 }); + }); + + it('reports an event which started but never finished', () => { + const entry = makeEntry({ startedAt: 1000, endedAt: null }); + expect(getEventVariance(entry)).toMatchObject({ status: 'not-run', actualDuration: null }); + }); + + it('reports an event which never started', () => { + const entry = makeEntry({ startedAt: null, endedAt: 1000 }); + expect(getEventVariance(entry)).toMatchObject({ status: 'not-run' }); + }); + + it('reports an event which matched its schedule', () => { + const entry = makeEntry({ startedAt: 0, endedAt: 10000, scheduledDuration: 10000 }); + expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10000, delta: 0 }); + }); + + it('treats sub-second differences as on time', () => { + const entry = makeEntry({ startedAt: 0, endedAt: 10500, scheduledDuration: 10000 }); + expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', delta: 500 }); + }); + + it('reports an overrun', () => { + const entry = makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }); + expect(getEventVariance(entry)).toMatchObject({ status: 'over', actualDuration: 15000, delta: 5000 }); + }); + + it('reports an underrun', () => { + const entry = makeEntry({ startedAt: 0, endedAt: 6000, scheduledDuration: 10000 }); + expect(getEventVariance(entry)).toMatchObject({ status: 'under', actualDuration: 6000, delta: -4000 }); + }); + + it('measures against the snapshot, not the current rundown', () => { + // the rundown may have been edited after the run, the snapshot is what counts + const entry = makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }); + expect(getEventVariance(entry).delta).toBe(2000); + }); +}); + +describe('getRunSummary()', () => { + it('returns an empty summary for an empty report', () => { + expect(getRunSummary({}, 0)).toMatchObject({ + eventsRun: 0, + eventsPlanned: 0, + scheduledDuration: 0, + actualDuration: 0, + drift: 0, + worstOverrun: null, + }); + }); + + it('aggregates durations and drift across a run', () => { + const report: OntimeReport = { + a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000 + b: makeEntry({ startedAt: 15000, endedAt: 21000, scheduledDuration: 10000 }), // -4000 + c: makeEntry({ startedAt: 21000, endedAt: 31000, scheduledDuration: 10000 }), // 0 + }; + + expect(getRunSummary(report, 4)).toMatchObject({ + eventsRun: 3, + eventsPlanned: 4, + scheduledDuration: 30000, + actualDuration: 31000, + drift: 1000, + eventsOver: 1, + eventsUnder: 1, + eventsOnTime: 1, + }); + }); + + it('identifies the worst overrun', () => { + const report: OntimeReport = { + a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000 + b: makeEntry({ startedAt: 0, endedAt: 30000, scheduledDuration: 10000 }), // +20000 + c: makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }), // +2000 + }; + + expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 }); + }); + + it('ignores events which did not complete', () => { + const report: OntimeReport = { + a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), + b: makeEntry({ startedAt: 15000, endedAt: null, scheduledDuration: 10000 }), + }; + + expect(getRunSummary(report, 2)).toMatchObject({ + eventsRun: 1, + scheduledDuration: 10000, + actualDuration: 15000, + drift: 5000, + }); + }); +}); diff --git a/packages/utils/src/report-utils/reportUtils.ts b/packages/utils/src/report-utils/reportUtils.ts new file mode 100644 index 000000000..0f66fccd0 --- /dev/null +++ b/packages/utils/src/report-utils/reportUtils.ts @@ -0,0 +1,101 @@ +import type { EntryId, OntimeEventReport, OntimeReport, RundownEntries, RunSummary } from 'ontime-types'; +import { isOntimeEvent } from 'ontime-types'; + +import { MILLIS_PER_SECOND } from '../date-utils/conversionUtils.js'; + +export type VarianceStatus = 'ontime' | 'over' | 'under' | 'not-run'; + +export type EventVariance = { + /** how long the event actually took, null if it never completed */ + actualDuration: number | null; + /** actualDuration - scheduledDuration, signed. 0 when the event did not complete */ + delta: number; + status: VarianceStatus; +}; + +const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' }; + +/** + * Calculates how an event performed against its schedule. + * An event is considered on time if it is within a second of its scheduled duration. + */ +export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance { + if (!entry) { + return notRun; + } + + const { startedAt, endedAt, scheduledDuration } = entry; + if (startedAt === null || endedAt === null) { + return notRun; + } + + const actualDuration = endedAt - startedAt; + const delta = actualDuration - scheduledDuration; + + if (Math.abs(delta) < MILLIS_PER_SECOND) { + return { actualDuration, delta, status: 'ontime' }; + } + + return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' }; +} + +/** + * Aggregates a run's per event data into the headline numbers for a show. + * @param report the run's per event data + * @param eventsPlanned how many playable events the rundown held when the run was made + */ +export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary { + const summary: RunSummary = { + eventsRun: 0, + eventsPlanned, + scheduledDuration: 0, + actualDuration: 0, + drift: 0, + eventsOver: 0, + eventsUnder: 0, + eventsOnTime: 0, + worstOverrun: null, + }; + + for (const [id, entry] of Object.entries(report)) { + const variance = getEventVariance(entry); + if (variance.status === 'not-run') { + continue; + } + + summary.eventsRun += 1; + summary.scheduledDuration += entry.scheduledDuration; + summary.actualDuration += variance.actualDuration as number; + + if (variance.status === 'over') { + summary.eventsOver += 1; + if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) { + summary.worstOverrun = { id, delta: variance.delta }; + } + } else if (variance.status === 'under') { + summary.eventsUnder += 1; + } else { + summary.eventsOnTime += 1; + } + } + + summary.drift = summary.actualDuration - summary.scheduledDuration; + + return summary; +} + +/** + * Counts the events a run could have played. + * Skipped events are excluded: they were never meant to run and would + * make the completion figures read as if the show fell short. + */ +export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number { + let count = 0; + for (const id of order) { + const entry = entries[id]; + if (entry && isOntimeEvent(entry) && !entry.skip) { + count += 1; + } + } + return count; +}