diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index cd6bccd22..aa0551931 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -20,8 +20,6 @@ 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 REPORT_OPEN_RUN = ['report', 'runs', 'open']; 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 0fa8d71aa..778545257 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, OpenRun, ShowRun, ShowRunSummary } from 'ontime-types'; +import { OntimeReport } from 'ontime-types'; import { ontimeQueryClient } from '../../common/queryClient'; import { REPORT, apiEntryUrl } from './constants'; @@ -24,49 +24,3 @@ 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 run being recorded - * @returns null when no run is in progress - */ -export async function fetchOpenRun(options?: RequestOptions): Promise { - try { - const res = await axios.get(`${reportUrl}/runs/open`, { signal: options?.signal }); - 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/useRuns.ts b/apps/client/src/common/hooks-query/useRuns.ts deleted file mode 100644 index 387d1e875..000000000 --- a/apps/client/src/common/hooks-query/useRuns.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { OpenRun, ShowRun, ShowRunSummary } from 'ontime-types'; -import { MILLIS_PER_HOUR } from 'ontime-utils'; - -import { REPORT_OPEN_RUN, REPORT_RUNS } from '../api/constants'; -import { fetchOpenRun, 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 }; -} - -/** - * The run currently being recorded, if any. - * A run in progress lives in memory on the server until it is finished, so it - * cannot be found in the run history and needs its own query. - */ -export function useOpenRun(): OpenRun | null { - const { data } = useQuery({ - queryKey: REPORT_OPEN_RUN, - queryFn: ({ signal }) => fetchOpenRun({ signal }), - placeholderData: (previousData, _previousQuery) => previousData, - staleTime: MILLIS_PER_HOUR, - }); - - return data ?? null; -} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/RunDetail.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss similarity index 100% rename from apps/client/src/features/app-settings/panel/feature-panel/composite/RunDetail.module.scss rename to apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.module.scss 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 df43d70ac..88b5da61f 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,57 +1,131 @@ -import { useEffect, useState } from 'react'; +import { countPlannedEvents, getRunSummary } from 'ontime-utils'; +import { useMemo } from 'react'; +import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5'; -import Select from '../../../../common/components/select/Select'; -import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; -import useRuns from '../../../../common/hooks-query/useRuns'; +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 { formatDuration, formatTime } from '../../../../common/utils/time'; import * as Panel from '../../panel-utils/PanelUtils'; -import RunDetail from './composite/RunDetail'; -import RunsList from './composite/RunsList'; +import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils'; -const allRundowns = 'all'; +import style from './ReportSettings.module.scss'; export default function ReportSettings() { - const { data: rundownsList } = useProjectRundowns(); - const [rundownFilter, setRundownFilter] = useState(allRundowns); - const { data: runs } = useRuns(rundownFilter === allRundowns ? undefined : rundownFilter); - const [selectedRunId, setSelectedRunId] = useState(null); + const { data: reportData } = useReport(); + const { data } = useRundown(); - // 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 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 rundownOptions = [ - { value: allRundowns, label: 'All rundowns' }, - ...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })), - ]; + const combinedReport = useMemo(() => { + return getCombinedReport(reportData, data.entries, data.flatOrder); + }, [reportData, data.entries, data.flatOrder]); + + const summary = useMemo(() => { + return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder)); + }, [reportData, data.entries, data.flatOrder]); return ( - - 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)} - 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 301469302..3f1d7aeb1 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 @@ -2,6 +2,7 @@ import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } fro import { MILLIS_PER_SECOND } from 'ontime-utils'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; +import { enDash } from '../../../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../../common/utils/time'; export type CombinedReport = { @@ -20,8 +21,8 @@ export type CombinedReport = { * * 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 rewrite a past run. Events that never ran have no snapshot and fall - * back to the rundown. + * 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, @@ -71,11 +72,11 @@ export function getCombinedReport( } /** - * Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed - * events has no meaningful drift to report. + * Signed drift, eg "+4m12s" / "-1m". With nothing completed there is no + * meaningful drift to report. */ export function formatDrift(drift: number, eventsRun: number): string { - if (eventsRun === 0) return '–'; + if (eventsRun === 0) return enDash; if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time'; return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`; } diff --git a/apps/client/src/features/overview/EditorOverview.tsx b/apps/client/src/features/overview/EditorOverview.tsx index 9f23b66c9..ef05a8455 100644 --- a/apps/client/src/features/overview/EditorOverview.tsx +++ b/apps/client/src/features/overview/EditorOverview.tsx @@ -10,7 +10,6 @@ import { StartTimesPlanning, StartTimesRuntime, } from './composite/TimeElements'; -import RunIndicator from './composite/RunIndicator'; import TitleOverview from './composite/TitleOverview'; import { OverviewWrapper } from './OverviewWrapper'; @@ -26,8 +25,6 @@ function EditorOverview({ children }: PropsWithChildren) { {layoutMode === EditorLayoutMode.PLANNING && } {layoutMode === EditorLayoutMode.TRACKING && } {layoutMode === EditorLayoutMode.CONTROL && } - {/* renders nothing unless a run is being recorded */} - ); } diff --git a/apps/client/src/features/overview/composite/RunIndicator.module.scss b/apps/client/src/features/overview/composite/RunIndicator.module.scss deleted file mode 100644 index eb1e6828e..000000000 --- a/apps/client/src/features/overview/composite/RunIndicator.module.scss +++ /dev/null @@ -1,31 +0,0 @@ -.indicator { - display: flex; - align-items: center; - gap: 0.375rem; - white-space: nowrap; - - background: none; - border: none; - padding: 0; - cursor: pointer; - - font-size: calc(1rem - 3px); - color: $label-gray; - - &:hover { - color: $ui-white; - } -} - -.dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background-color: $active-red; - flex-shrink: 0; -} - -.since { - color: $ui-white; - font-weight: 600; -} diff --git a/apps/client/src/features/overview/composite/RunIndicator.tsx b/apps/client/src/features/overview/composite/RunIndicator.tsx deleted file mode 100644 index 38bbeee1f..000000000 --- a/apps/client/src/features/overview/composite/RunIndicator.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useNavigate } from 'react-router'; - -import Tooltip from '../../../common/components/tooltip/Tooltip'; -import { useOpenRun } from '../../../common/hooks-query/useRuns'; - -import style from './RunIndicator.module.scss'; - -/** - * Shows that a report is being recorded for the show in progress. - * - * This is how the feature is discovered: it appears with the first event and - * points at the reports panel. Ending the run is the existing Finish action - * on the last event, so there is no separate control here. - */ -export default function RunIndicator() { - const openRun = useOpenRun(); - const navigate = useNavigate(); - - if (!openRun) { - return null; - } - - // startedAt is a wall clock instant, not a time of day, so formatTime does not apply - const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' }); - - return ( - }> - - - ); -} diff --git a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx index a0b53e715..192431633 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx @@ -141,7 +141,6 @@ function RundownEventInner({ isPast={isPast} isLoaded={loaded} totalGap={totalGap} - duration={duration} /> )}
diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx index 55cde6fcd..d7c0e6ddb 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -1,5 +1,5 @@ import { Day } from 'ontime-types'; -import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils'; +import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils'; import { useMemo } from 'react'; import { IoCheckmarkCircle } from 'react-icons/io5'; @@ -20,7 +20,6 @@ interface RundownEventChipProps { isLoaded: boolean; className: string; totalGap: number; - duration: number; isLinkedToLoaded: boolean; } @@ -33,7 +32,6 @@ export default function RundownEventChip({ className, totalGap, id, - duration, isLinkedToLoaded, }: RundownEventChipProps) { const playback = usePlayback(); @@ -45,7 +43,7 @@ export default function RundownEventChip({ const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return ; + return ; } if (playbackActive) { @@ -86,41 +84,32 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: interface EventReportProps { className: string; id: string; - duration: number; } function EventReport(props: EventReportProps) { - const { className, id, duration } = props; + const { className, id } = props; const { data } = useReport(); const currentReport = data[id]; const [value, overUnderStyle, tooltip] = useMemo(() => { - if (!currentReport) { + // measured against the schedule recorded when the event ran, so this + // agrees with the report panel and survives later rundown edits + const variance = getEventVariance(currentReport); + if (variance.status === 'not-run') { return [null, 'none', '']; } - const { startedAt, endedAt } = currentReport; - if (!startedAt || !endedAt) { - return [null, 'none', '']; - } - - const actualDuration = endedAt - startedAt; - const difference = actualDuration - duration; - const absDifference = Math.abs(difference); - - if (absDifference < MILLIS_PER_SECOND) { + if (variance.status === 'ontime') { return ['ontime', 'under', 'Event finished on time']; } - const isOver = difference > 0; - + const absDifference = Math.abs(variance.delta); + const isOver = variance.status === 'over'; const fullTimeValue = millisToString(absDifference); - const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; - const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; - return [value, isOver ? 'over' : 'under', tooltip]; - }, [currentReport, duration]); + return [value, variance.status, tooltip]; + }, [currentReport]); if (!value) { return null; diff --git a/apps/server/src/api-data/report/__tests__/report.service.test.ts b/apps/server/src/api-data/report/__tests__/report.service.test.ts index 5d905765b..460cde2e6 100644 --- a/apps/server/src/api-data/report/__tests__/report.service.test.ts +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -1,115 +1,36 @@ import { TimerLifeCycle } from 'ontime-types'; -import type { PlayableEvent, ShowRun } from 'ontime-types'; +import type { PlayableEvent } from 'ontime-types'; import { vi } from 'vitest'; -import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js'; import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; +import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; +import { clear, generate, triggerReportEntry } from '../report.service.js'; -// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts -let runs: ShowRun[] = []; -/** how many times the store was asked to write, to prove the cue path stays clean */ -let writes = 0; - -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) => { - writes += 1; - 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 = []; - }), +vi.mock('../../../adapters/WebsocketAdapter.js', () => ({ + sendRefetch: vi.fn(), })); -let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' }); -/** rundowns reachable by id, standing in for what is on disk */ -let storedRundowns: Record> = {}; - -vi.mock('../../rundown/rundown.dao.js', () => ({ - getCurrentRundown: vi.fn(() => currentRundown), - getCurrentRundownId: vi.fn(() => currentRundown.id), -})); - -vi.mock('../../../classes/data-provider/DataProvider.js', () => ({ - getDataProvider: vi.fn(() => ({ - getRundown: vi.fn((id: string) => { - if (!(id in storedRundowns)) throw new Error(`Rundown with id: ${id} not found`); - return storedRundowns[id]; - }), - })), -})); - -const { - generate, - clear, - triggerReportEntry, - closeRun, - initReports, - listRuns, - getRun, - getOpenRun, - 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 = []; - writes = 0; - 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], - }); - storedRundowns = { 'rundown-1': currentRundown }; - await initReports('project-a'); +beforeEach(() => { + clear(); }); -/** an epoch instant, as the runtime would supply on the first event start */ -const showEpoch = Date.UTC(2026, 7, 8, 9, 30); - describe('triggerReportEntry()', () => { - it('captures a snapshot of the schedule when an event starts', () => { + it('snapshots 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, - }, + expect(generate()[eventA.id]).toEqual({ + 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', () => { + it('keeps the snapshot taken at start when the event stops', () => { const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); triggerReportEntry(TimerLifeCycle.onStart, start); @@ -119,11 +40,24 @@ describe('triggerReportEntry()', () => { expect(generate()[eventA.id]).toMatchObject({ startedAt: 0, endedAt: 12000, + scheduledStart: eventA.timeStart, scheduledDuration: eventA.duration, }); }); - it('increments playCount when an event is re-run within the same show', () => { + it('records the schedule as it was, not as it later becomes', () => { + const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); + triggerReportEntry(TimerLifeCycle.onStart, start); + + // the event is edited to a different duration, then stopped + const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent; + const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id].scheduledDuration).toBe(10000); + }); + + it('counts a re-run rather than losing the previous one', () => { const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state); @@ -132,262 +66,30 @@ describe('triggerReportEntry()', () => { expect(generate()[eventA.id].playCount).toBe(2); }); + it('falls back to the current event when a stop arrives with no start', () => { + const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 }); + triggerReportEntry(TimerLifeCycle.onStop, stop); + + expect(generate()[eventA.id]).toMatchObject({ + startedAt: null, + endedAt: 10000, + scheduledDuration: eventA.duration, + playCount: 1, + }); + }); + it('ignores events without an id', () => { const state = makeRuntimeStateData({ eventNow: null }); triggerReportEntry(TimerLifeCycle.onStart, state); expect(generate()).toEqual({}); }); - - it('ignores a stop arriving when no run is open', () => { - // a project load stops playback and reinitialises reporting, the trailing - // stop must not attribute the old project's event to the new one - const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 }); - triggerReportEntry(TimerLifeCycle.onStop, state); - expect(generate()).toEqual({}); - }); - -}); - -describe('nothing is written on the cue path', () => { - function runEvent(clock: number) { - const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock, _startEpoch: showEpoch }); - triggerReportEntry(TimerLifeCycle.onStart, state); - triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: clock + 1000 } as typeof state); - } - - it('keeps a run in progress out of history until it is finished', async () => { - runEvent(0); - runEvent(2000); - runEvent(4000); - await new Promise((resolve) => setImmediate(resolve)); - - // the run exists and is accumulating, but is not a report yet - expect(getOpenRun()).not.toBeNull(); - expect(Object.keys(generate())).toHaveLength(1); - expect(listRuns()).toHaveLength(0); - expect(writes).toBe(0); - }); - - it('writes exactly once, when the run is finished', async () => { - runEvent(0); - runEvent(2000); - expect(writes).toBe(0); - - await closeRun(); - - expect(writes).toBe(1); - expect(listRuns()).toHaveLength(1); - expect(getOpenRun()).toBeNull(); - }); - - it('stamps the finished run with an end', async () => { - const before = Date.now(); - runEvent(0); - await closeRun(); - - const run = listRuns()[0]; - expect(run.endedAt).toBeGreaterThanOrEqual(before); - expect(run.endedAt).toBeGreaterThanOrEqual(run.startedAt); - }); - - it('discards an unfinished run when the project changes', async () => { - runEvent(0); - expect(getOpenRun()).not.toBeNull(); - - await initReports('project-b'); - - expect(getOpenRun()).toBeNull(); - expect(listRuns()).toHaveLength(0); - expect(writes).toBe(0); - }); -}); - -describe('run timestamps', () => { - it('dates a run with the wall clock epoch, not the time of day', async () => { - // clock/actualStart are millis since midnight, which cannot date a run. - // The run must take _startEpoch so it is not stamped 1 Jan 1970. - const state = makeRuntimeStateData({ - eventNow: eventA, - timer: { startedAt: 0 }, - clock: 34200000, // 09:30 as a time of day - rundown: { actualStart: 34200000 }, - _startEpoch: showEpoch, - }); - triggerReportEntry(TimerLifeCycle.onStart, state); - triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 44200000 } as typeof state); - await closeRun(); - - const run = listRuns()[0]; - expect(run.startedAt).toBe(showEpoch); - expect(new Date(run.startedAt).getUTCFullYear()).toBe(2026); - }); - - it('falls back to the current instant when no start epoch is available', async () => { - const before = Date.now(); - const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: null }); - triggerReportEntry(TimerLifeCycle.onStart, state); - triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); - await closeRun(); - - const run = listRuns()[0]; - expect(run.startedAt).toBeGreaterThanOrEqual(before); - expect(run.startedAt).toBeLessThanOrEqual(Date.now()); - }); - - it('dates runs from different days apart', async () => { - // the times of day here disagree with chronological order: 20:00 yesterday - // is a larger time of day than 09:30 today - const yesterdayEvening = Date.UTC(2026, 7, 7, 20, 0); - await makeClosedRunAt(yesterdayEvening); - const older = listRuns()[0].startedAt; - await makeClosedRunAt(showEpoch); - const newer = listRuns()[0].startedAt; - - expect(newer).toBeGreaterThan(older); - }); - - async function makeClosedRunAt(epoch: number) { - const timeOfDay = epoch % 86400000; - const state = makeRuntimeStateData({ - eventNow: eventA, - timer: { startedAt: 0 }, - clock: timeOfDay, - rundown: { actualStart: timeOfDay }, - _startEpoch: epoch, - }); - triggerReportEntry(TimerLifeCycle.onStart, state); - triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: timeOfDay + 10000 } as typeof state); - await closeRun(); - } -}); - -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); - - await closeRun(); - - expect(listRuns()).toHaveLength(1); - - // a new start after finishing 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 closeRun(); - - expect(listRuns()).toHaveLength(2); - }); - - it('does nothing when no run is open', async () => { - await expect(closeRun()).resolves.toBeNull(); - expect(listRuns()).toHaveLength(0); - }); -}); - -describe('summary is measured against the run\'s own rundown', () => { - it('counts planned events from the rundown the run belongs to', async () => { - // a three event rundown that is not the loaded one - const otherEvent = makeOntimeEvent({ id: 'event-c', timeStart: 0, timeEnd: 1000, duration: 1000 }); - storedRundowns['rundown-2'] = makeRundown({ - id: 'rundown-2', - title: 'Other rundown', - entries: { [eventA.id]: eventA, [eventB.id]: eventB, [otherEvent.id]: otherEvent }, - order: [eventA.id, eventB.id, otherEvent.id], - flatOrder: [eventA.id, eventB.id, otherEvent.id], - }); - - // open a run against rundown-2, then switch the loaded rundown away - currentRundown = storedRundowns['rundown-2']; - const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch }); - triggerReportEntry(TimerLifeCycle.onStart, state); - currentRundown = storedRundowns['rundown-1']; - - await closeRun(); - - // three, from rundown-2, not two from the now loaded rundown-1 - expect(listRuns()[0].summary.eventsPlanned).toBe(3); - }); - - it('falls back to what ran when the rundown has been deleted', async () => { - // the run belongs to a rundown that is neither loaded nor in storage - currentRundown = makeRundown({ id: 'gone', title: 'Deleted rundown', entries: {}, order: [], flatOrder: [] }); - const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch }); - triggerReportEntry(TimerLifeCycle.onStart, state); - triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); - currentRundown = storedRundowns['rundown-1']; - - await closeRun(); - - // one event ran, so planned reads as one rather than zero of one - const summary = listRuns()[0].summary; - expect(summary.eventsPlanned).toBe(1); - expect(summary.eventsRun).toBe(1); - }); -}); - -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); - await 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('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('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', () => { + it('clears a single event', () => { const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 }); triggerReportEntry(TimerLifeCycle.onStart, state); clear(eventA.id); expect(generate()).toEqual({}); }); - - 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 402056c2f..94b35dc42 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -3,81 +3,15 @@ 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()); }); -/** - * Finished reports, most recent first. `?rundownId=` scopes the list to one - * rundown. A run in progress is not a report and does not appear here. - */ -router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => { - const { rundownId } = req.query as { rundownId?: string }; - res.status(200).json(report.listRuns(rundownId)); -}); - -/** - * The run currently being recorded, which exists in memory only. - * Registered ahead of /runs/:id so "open" is not read as an id. - */ -router.get('/runs/open', (_req: Request, res: Response) => { - const run = report.getOpenRun(); - 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(); +router.delete('/all', (_req: Request, res: Response) => { + report.clear(); res.status(204).send(); }); diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index fa7ffae62..85151a2d7 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,32 +1,13 @@ -import { - EntryId, - OntimeEventReport, - OntimeReport, - OpenRun, - RefetchKey, - Rundown, - ShowRun, - ShowRunSummary, - TimerLifeCycle, -} from 'ontime-types'; -import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils'; +import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types'; import { DeepReadonly } from 'ts-essentials'; import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; -import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import * as timeCore from '../../lib/time-core/timeCore.js'; -import * as reportStore from '../../services/report-service/report.store.js'; import { RuntimeState } from '../../stores/runtimeState.js'; -import { getCurrentRundown, getCurrentRundownId } from '../rundown/rundown.dao.js'; -/** per event data for the run currently in progress */ -const report = new Map(); +const report = new Map(); let formattedReport: OntimeReport | null = null; -/** the run being recorded, held in memory only until it is finished */ -let openRun: OpenRun | null = null; - /** * generates a full report * @returns full report @@ -68,15 +49,14 @@ export function triggerReportEntry( const eventId = state.eventNow.id; if (cycle === TimerLifeCycle.onStart) { - openRunIfNeeded(state); - - // an event started twice in the same run is a re-run, not a new record + // an event started twice 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 + // snapshot the schedule so later rundown edits cannot change how a show + // that already happened is reported scheduledStart: state.eventNow.timeStart, scheduledDuration: state.eventNow.duration, playCount, @@ -86,13 +66,6 @@ export function triggerReportEntry( } if (cycle === TimerLifeCycle.onStop) { - // With no run open there is nothing this event can belong to. A stop can - // still arrive here after a project load, and recording it would attribute - // the previous project's event to the newly loaded one. - if (openRun === null) { - return; - } - const previous = report.get(eventId); report.set(eventId, { startedAt: previous?.startedAt ?? null, @@ -102,188 +75,6 @@ export function triggerReportEntry( playCount: previous?.playCount ?? 1, }); formattedReport = null; - // deliberately not written here: a run is kept in memory until it is - // finished, so nothing touches the disk on the cue path sendRefetch(RefetchKey.Report); } } - -/** - * Closes the run in progress and writes it to history immediately. - * - * Ending a run is an explicit act by the operator, not a side effect of - * playback. Stopping and starting again mid show is ordinary, and would - * otherwise split one show across several runs. - * @returns the closed run, or null if none was open - */ -export async function closeRun(): Promise { - if (openRun === null) { - return null; - } - - // detach the run before the write so a start arriving in between opens a - // new run instead of appending to the one we are closing - const closing = { ...openRun, endedAt: timeCore.now() }; - openRun = null; - - const closed = await persistRun(closing, generate()); - sendRefetch(RefetchKey.Report); - return closed; -} - -/** The run being recorded, if any. Held in memory until it is finished. */ -export function getOpenRun(): OpenRun | null { - return openRun; -} - -/** - * 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(); - // `clock` and `rundown.actualStart` are times of day, which cannot date or - // order a run across days. `_startEpoch` is the wall clock instant the show - // began, which is what a run needs to be a dated record. - const startedAt = state._startEpoch ?? timeCore.now(); - - openRun = { - id: generateId(), - rundownId: rundown.id, - rundownTitle: rundown.title, - label: makeRunLabel(startedAt), - startedAt, - }; - - // let the editor show that a run is being recorded without waiting - // for the first event to finish - sendRefetch(RefetchKey.Report); -} - -/** - * Default name for a run, a readable local date and time rather than the - * raw timestamp the user would otherwise have to decipher. - * @private - */ -function makeRunLabel(startedAt: number): string { - return new Date(startedAt).toLocaleString(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }); -} - -/** - * Writes the run in progress to the sidecar. - * Persisting on every event stop means an interrupted show still leaves a record. - * @private - */ -/** - * Writes a finished run and its derived summary to the sidecar - * @private - */ -async function persistRun( - run: Omit, - currentReport: OntimeReport, -): Promise { - const rundown = getRundownForRun(run.rundownId); - // a rundown deleted mid-run leaves nothing to count against. Falling back to - // what actually ran keeps the report honest, where zero would read as if the - // whole show had been missed - const eventsPlanned = rundown - ? countPlannedEvents(rundown.entries, rundown.flatOrder) - : Object.keys(currentReport).length; - - const persisted: ShowRun = { - ...run, - report: structuredClone(currentReport), - summary: getRunSummary(currentReport, eventsPlanned), - }; - - await reportStore.upsertRun(persisted); - return persisted; -} - -/** - * Resolves the rundown a run belongs to. - * The loaded rundown can be switched while a run is open, so the run's own - * id is the only reliable way to count the events it was measured against. - * @private - */ -function getRundownForRun(rundownId: string): Readonly | null { - if (rundownId === getCurrentRundownId()) { - return getCurrentRundown(); - } - - try { - return getDataProvider().getRundown(rundownId); - } catch (_error) { - // getRundown throws when the rundown no longer exists - return null; - } -} - -/** - * Prepares reporting for a newly loaded project. - * - * There is nothing to recover here: a run only reaches the sidecar once it - * has been finished, so the stored history never contains a partial run. An - * unfinished run is discarded along with the project it belonged to. - */ -export async function initReports(projectFilename: string): Promise { - report.clear(); - formattedReport = null; - openRun = null; - - await reportStore.loadReports(projectFilename); -} - -/** 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); -} - -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 { - // only finished reports are in the store, so this can never target the - // run in progress - const didDelete = await reportStore.deleteRun(id); - if (didDelete) { - 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 deleted file mode 100644 index 85400244e..000000000 --- a/apps/server/src/api-data/report/report.validation.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 b5830461d..44c2f1637 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -25,7 +25,6 @@ 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'; @@ -893,9 +892,6 @@ 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 e6da398f2..3cad948c3 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -6,8 +6,6 @@ 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'; @@ -65,7 +63,6 @@ function init() { ensureDirectory(publicDir.corruptDir); ensureDirectory(publicDir.logoDir); ensureDirectory(publicDir.migrateDir); - ensureDirectory(publicDir.reportsDir); } export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { @@ -91,9 +88,6 @@ 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 @@ -269,8 +263,6 @@ 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; } @@ -292,9 +284,6 @@ 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) { @@ -343,8 +332,6 @@ 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 deleted file mode 100644 index b645aadfb..000000000 --- a/apps/server/src/services/report-service/__tests__/report.store.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { join } from 'path'; - -import type { ShowRun } from 'ontime-types'; -import { vi } from 'vitest'; - -// in-memory stand-in for the filesystem, keyed by absolute 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', - ); - - const childrenOf = (dir: string) => [...files.keys()].filter((path) => path.startsWith(`${dir}/`)); - - return { - ...actual, - ensureDirectory: vi.fn(), - readDirectoryEntries: vi.fn(async (dir: string) => { - const children = childrenOf(dir); - if (children.length === 0) throw new Error('ENOENT'); - return children.map((path) => ({ - name: path.slice(dir.length + 1), - isFile: () => true, - })); - }), - deleteFile: vi.fn(async (path: string) => { - files.delete(path); - }), - deleteDirectory: vi.fn(async (dir: string) => { - for (const path of childrenOf(dir)) files.delete(path); - }), - dockerSafeRename: vi.fn(async (oldDir: string, newDir: string) => { - for (const path of childrenOf(oldDir)) { - files.set(join(newDir, path.slice(oldDir.length + 1)), files.get(path)); - files.delete(path); - } - }), - statIfExists: vi.fn(async (path: string) => - files.has(path) || childrenOf(path).length > 0 ? {} : 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: '8 Aug 2026, 09:30', - 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, - }; -} - -/** where a run's file lands for a given project */ -const runPath = (project: string, id: string) => join(getPathToReports(project), `${id}.json`); - -beforeEach(() => { - files.clear(); - resetStore(); -}); - -describe('loadReports()', () => { - it('starts empty for a project with no reports', async () => { - expect(await loadReports('project-a')).toEqual([]); - expect(getRuns()).toEqual([]); - }); - - it('resolves a directory per project, ignoring the file extension', async () => { - expect(getPathToReports('my show.json')).toBe(getPathToReports('my show')); - }); - - it('loads every run in the project directory', async () => { - files.set(runPath('project-a', 'a'), makeRun({ id: 'a' })); - files.set(runPath('project-a', 'b'), makeRun({ id: 'b' })); - - const runs = await loadReports('project-a'); - - expect(runs).toHaveLength(2); - }); - - it('presents runs newest first regardless of read order', async () => { - files.set(runPath('project-a', 'old'), makeRun({ id: 'old', startedAt: 1000 })); - files.set(runPath('project-a', 'new'), makeRun({ id: 'new', startedAt: 9000 })); - files.set(runPath('project-a', 'mid'), makeRun({ id: 'mid', startedAt: 5000 })); - - await loadReports('project-a'); - - expect(getRuns().map((run) => run.id)).toEqual(['new', 'mid', 'old']); - }); - - it('skips an unreadable report without losing the rest', async () => { - files.set(runPath('project-a', 'good'), makeRun({ id: 'good' })); - files.set(runPath('project-a', 'broken'), { nonsense: true }); - - await loadReports('project-a'); - - expect(getRuns().map((run) => run.id)).toEqual(['good']); - }); - - it('scopes runs to the loaded project', async () => { - files.set(runPath('project-a', 'a'), makeRun({ id: 'a' })); - files.set(runPath('project-b', 'b'), 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()', () => { - beforeEach(async () => { - await loadReports('project-a'); - }); - - it('writes only the run given, not the whole history', async () => { - await upsertRun(makeRun({ id: 'first' })); - await upsertRun(makeRun({ id: 'second' })); - - // one file each, so the cost of finishing a show does not grow with history - expect(files.has(runPath('project-a', 'first'))).toBe(true); - expect(files.has(runPath('project-a', 'second'))).toBe(true); - 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('survives a reload', async () => { - await upsertRun(makeRun()); - expect(await loadReports('project-a')).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 and its file', async () => { - expect(await deleteRun('discard')).toBe(true); - - expect(getRuns().map((run) => run.id)).toEqual(['keep']); - expect(files.has(runPath('project-a', 'discard'))).toBe(false); - expect(files.has(runPath('project-a', 'keep'))).toBe(true); - }); - - 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' })); - - expect(await deleteRunsForRundown('rundown-x')).toBe(2); - - expect(getRuns().map((run) => run.id)).toEqual(['b']); - expect(files.has(runPath('project-a', 'a'))).toBe(false); - expect(files.has(runPath('project-a', 'c'))).toBe(false); - }); -}); - -describe('deleteAllRuns()', () => { - it('empties the project directory', async () => { - await loadReports('project-a'); - await upsertRun(makeRun({ id: 'a' })); - await upsertRun(makeRun({ id: 'b' })); - - await deleteAllRuns(); - - expect(getRuns()).toEqual([]); - expect(files.has(runPath('project-a', 'a'))).toBe(false); - }); -}); - -describe('project lifecycle', () => { - it('removes the whole directory with the project', async () => { - await loadReports('project-a'); - await upsertRun(makeRun({ id: 'a' })); - await upsertRun(makeRun({ id: 'b' })); - - await deleteReportsForProject('project-a'); - - expect(files.has(runPath('project-a', 'a'))).toBe(false); - expect(files.has(runPath('project-a', 'b'))).toBe(false); - }); - - it('does nothing when the project never had reports', async () => { - await expect(deleteReportsForProject('never-loaded')).resolves.toBeUndefined(); - }); - - it('moves the directory to follow a project rename', async () => { - await loadReports('project-a'); - await upsertRun(makeRun({ id: 'a' })); - - await renameReportsForProject('project-a', 'project-b'); - - expect(files.has(runPath('project-a', 'a'))).toBe(false); - expect(files.has(runPath('project-b', 'a'))).toBe(true); - }); - - it('keeps writing to the new location after a rename', async () => { - await loadReports('project-a'); - await upsertRun(makeRun({ id: 'a' })); - await renameReportsForProject('project-a', 'project-b'); - - await upsertRun(makeRun({ id: 'later' })); - - expect(files.has(runPath('project-b', 'later'))).toBe(true); - expect(files.has(runPath('project-a', 'later'))).toBe(false); - }); - - it('does nothing when renaming a project that never had reports', 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 deleted file mode 100644 index b850e56d9..000000000 --- a/apps/server/src/services/report-service/report.parser.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ShowRun } from 'ontime-types'; - -import { is } from '../../utils/is.js'; - -/** - * Shallow check on the contents of a report file. - * - * We are the only writer of these files, so this guards against one being - * empty, truncated or hand edited rather than against arbitrary payloads. - * A file that fails is skipped, leaving the rest of the history readable. - */ -export function isShowRun(value: unknown): value is ShowRun { - if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'endedAt', 'report', 'summary'])) { - return false; - } - - return ( - is.string(value.id) && - is.number(value.startedAt) && - is.number(value.endedAt) && - is.object(value.report) && - is.object(value.summary) - ); -} diff --git a/apps/server/src/services/report-service/report.store.ts b/apps/server/src/services/report-service/report.store.ts deleted file mode 100644 index 4ac9b8998..000000000 --- a/apps/server/src/services/report-service/report.store.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { join } from 'path'; - -import { JSONFile } from 'lowdb/node'; -import type { ShowRun } from 'ontime-types'; - -import { publicDir } from '../../setup/index.js'; -import { - deleteDirectory, - deleteFile, - dockerSafeRename, - ensureDirectory, - readDirectoryEntries, - removeFileExtension, - statIfExists, -} from '../../utils/fileManagement.js'; -import { isShowRun } from './report.parser.js'; - -/** - * Reports live in a directory per project, one file per run. - * - * Keeping them out of the project file leaves it free of show time writes and - * lets history grow without bloating what the user exports. Keeping each run - * in its own file means finishing a show writes only that show, rather than - * rewriting everything the project has ever recorded. - * - * Persistence is best effort by design: a failing disk degrades reporting - * but must never interrupt a running show. - */ - -/** runs for the loaded project, newest first */ -let cache: ShowRun[] = []; -let projectDir: string | null = null; -let failedWriteAttempts = 0; - -/** Directory holding a project's reports */ -export function getPathToReports(projectFilename: string): string { - // the project name without its extension, so "show.json" does not read as a file - return join(publicDir.reportsDir, removeFileExtension(projectFilename)); -} - -function getPathToRun(id: string): string | null { - return projectDir === null ? null : join(projectDir, `${id}.json`); -} - -/** - * Points the store at a project's report directory and loads what is there. - * Called on every project load, which is the single choke point for - * project changes. - */ -export async function loadReports(projectFilename: string): Promise { - const dir = getPathToReports(projectFilename); - projectDir = dir; - failedWriteAttempts = 0; - cache = []; - - try { - const entries = await readDirectoryEntries(dir); - const reports = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')); - - const contents = await Promise.all( - reports.map(async (entry) => { - try { - return await new JSONFile(join(dir, entry.name)).read(); - } catch (_error) { - // a single unreadable report is skipped rather than losing the rest - return null; - } - }), - ); - - // files come off disk in arbitrary order, the list is presented newest first - cache = contents.filter(isShowRun).sort((a, b) => b.startedAt - a.startedAt); - } catch (_error) { - // a missing directory is the normal case for a project with no history - } - - return cache; -} - -/** Runs held for the current project, newest first */ -export function getRuns(): ShowRun[] { - return cache; -} - -export function getRun(id: string): ShowRun | undefined { - return cache.find((run) => run.id === id); -} - -/** - * Writes a single finished report. - * Only this run's file is touched, so the cost does not grow with history. - */ -export async function upsertRun(run: ShowRun): Promise { - const index = cache.findIndex((candidate) => candidate.id === run.id); - if (index === -1) { - cache.unshift(run); - } else { - cache[index] = run; - } - - const dir = projectDir; - if (dir === null || failedWriteAttempts > 3) { - return; - } - - try { - ensureDirectory(dir); - await new JSONFile(join(dir, `${run.id}.json`)).write(run); - failedWriteAttempts = 0; - } catch (_error) { - failedWriteAttempts += 1; - } -} - -/** - * 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.findIndex((run) => run.id === id); - if (index === -1) { - return false; - } - - cache.splice(index, 1); - await removeRunFile(id); - 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 doomed = cache.filter((run) => run.rundownId === rundownId); - cache = cache.filter((run) => run.rundownId !== rundownId); - - // separate files, so these can go at once - await Promise.all(doomed.map((run) => removeRunFile(run.id))); - return doomed.length; -} - -/** Clears the run history of the current project */ -export async function deleteAllRuns(): Promise { - cache = []; - if (projectDir === null) { - return; - } - - try { - await deleteDirectory(projectDir); - } catch (_error) { - // leftovers are harmless, they are filtered on next load - } -} - -/** - * Removes a project's reports. - * They are owned by the project and do not outlive it, so this is a single - * recursive delete of its directory. - */ -export async function deleteReportsForProject(projectFilename: string): Promise { - try { - await deleteDirectory(getPathToReports(projectFilename)); - } catch (_error) { - // a leftover directory is harmless, deleting the project must still succeed - } -} - -/** Moves a project's reports so 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); - if (projectDir === originalPath) { - projectDir = newPath; - } - } catch (_error) { - // losing history on rename is bad but not fatal, the project rename stands - } -} - -/** @private */ -async function removeRunFile(id: string): Promise { - const path = getPathToRun(id); - if (path === null) { - return; - } - - try { - if ((await statIfExists(path)) !== null) { - await deleteFile(path); - } - } catch (_error) { - // the run is already out of the cache, a stray file is filtered on load - } -} - -/** Resets in-memory state, used when no project is loaded and in tests */ -export function resetStore(): void { - projectDir = null; - cache = []; - 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 8418c233e..ee6d0de58 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 { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js'; +import { 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,10 +523,6 @@ class RuntimeService { logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); - // stop is what the Go button does on the last event, where it reads - // "Finish". Ending playback is the operator ending the show, and that - // is what turns the run in progress into a report. - void closeRun(); triggerAutomations(TimerLifeCycle.onStop); }); diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 9cf52283e..895201a7c 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -23,7 +23,6 @@ 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 79e452f2f..b5e44cda3 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -124,8 +124,6 @@ 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 4b0e00b73..583937e5b 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -7,20 +7,21 @@ export type OntimeEventReport = { /** * 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. + * afterwards no longer changes how a show that already happened is reported. */ scheduledStart: number; scheduledDuration: number; - /** how many times the event was started within this run, >1 means it was re-run */ + /** how many times the event was started, >1 means it was re-run */ playCount: number; }; export type OntimeReport = Record; +/** Headline numbers for everything in the current report */ export type RunSummary = { /** events which produced a report entry */ eventsRun: number; - /** playable events in the rundown at the time the summary was made */ + /** playable events in the rundown */ eventsPlanned: number; scheduledDuration: number; actualDuration: number; @@ -32,38 +33,3 @@ export type RunSummary = { /** 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 local date and time */ - label: string; - /** - * Wall clock instant (milliseconds from epoch) the run began. - * Not a time of day: runs must be datable and orderable across days. - */ - startedAt: number; - /** - * Wall clock instant the run was finished. - * Always set: a run only becomes a report once it has been finished, so - * there is no such thing as a stored run still in progress. - */ - endedAt: number; - report: OntimeReport; - summary: RunSummary; -}; - -/** A run without its per event data, for list views */ -export type ShowRunSummary = Omit; - -/** - * The run currently being recorded. - * Lives in memory only until it is finished, so it carries no report data - * and has no end. - */ -export type OpenRun = Omit; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3cad96107..77fab533e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,14 +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, - OpenRun, - RunSummary, - ShowRun, - ShowRunSummary, -} from './definitions/core/Report.type.js'; +export type { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js'; // ---> Automations export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';