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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-08 11:08:28 +00:00
parent fb559ed9da
commit 3c2875a956
32 changed files with 2144 additions and 172 deletions
@@ -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({});
});
});
@@ -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();
});
@@ -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<string, OntimeEventReport>();
/** per event data for the run currently in progress */
const report = new Map<EntryId, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/** metadata for the run in progress, null when no run is open */
let openRun: Omit<ShowRun, 'report' | 'summary'> | 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<RuntimeState>) {
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<void> {
if (openRun === null) {
return;
}
await persistRun(openRun, generate());
}
/**
* Writes a run and its derived summary to the sidecar
* @private
*/
async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, currentReport: OntimeReport): Promise<void> {
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<void> {
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<number | null>((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<ShowRun | undefined> {
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<boolean> {
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<void> {
await reportStore.deleteAllRuns();
openRun = null;
report.clear();
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
@@ -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,
];
@@ -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);
});
@@ -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);
}
/**
@@ -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<string, unknown>();
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<typeof import('../../../utils/fileManagement.js')>(
'../../../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> = {}): 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();
});
});
@@ -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<string, OntimeEventReport> {
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);
}
@@ -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<ProjectReports> | 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<ProjectReports> {
fileRef = new JSONFile<ProjectReports>(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<void> {
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<boolean> {
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<number> {
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<void> {
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<void> {
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<void> {
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<ProjectReports>(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<void> {
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;
}
@@ -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);
});
+1
View File
@@ -23,6 +23,7 @@ export const config = {
external: 'external',
demo: 'demo',
projects: 'projects',
reports: 'reports',
sheets: {
directory: 'sheets',
},
+2
View File
@@ -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 */