refactor(report): summary

This commit is contained in:
Carlos Valente
2026-08-25 21:57:15 +02:00
parent b9683f00dc
commit c91b6c020e
21 changed files with 1729 additions and 140 deletions
@@ -0,0 +1,176 @@
import { TimerLifeCycle } from 'ontime-types';
import type { PlayableEvent } from 'ontime-types';
import { vi } from 'vitest';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { clear, generate, generateShowReport, triggerReportEntry } from '../report.service.js';
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
sendRefetch: vi.fn(),
}));
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(() => {
clear();
});
describe('triggerReportEntry()', () => {
it('snapshots the schedule when an event starts', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()[eventA.id]).toEqual({
startedAt: 500,
endedAt: null,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
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);
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
expect(generate()[eventA.id]).toMatchObject({
startedAt: 0,
endedAt: 12000,
scheduledStart: eventA.timeStart,
scheduledDuration: eventA.duration,
});
});
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('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,
});
});
it('starts a fresh report when a new show begins', () => {
// a rehearsal ran earlier and left its numbers behind
const rehearsal = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
});
triggerReportEntry(TimerLifeCycle.onStart, rehearsal);
triggerReportEntry(TimerLifeCycle.onStop, { ...rehearsal, clock: 20000 } as typeof rehearsal);
expect(generate()[eventA.id].endedAt).toBe(20000);
// the performance is a different show and must not inherit them
const show = makeRuntimeStateData({
eventNow: eventB,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 9999,
});
triggerReportEntry(TimerLifeCycle.onStart, show);
expect(generate()[eventA.id]).toBeUndefined();
expect(generate()[eventB.id]).toBeDefined();
});
it('keeps accumulating within the same show', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: 1000 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
const next = makeRuntimeStateData({
eventNow: eventB,
timer: { startedAt: 10000 },
clock: 10000,
_startEpoch: 1000,
});
triggerReportEntry(TimerLifeCycle.onStart, next);
// same show, so the earlier event is still part of the report
expect(Object.keys(generate())).toHaveLength(2);
});
it('ignores events without an id', () => {
const state = makeRuntimeStateData({ eventNow: null });
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generate()).toEqual({});
});
});
describe('generateShowReport()', () => {
it('captures the plan the show was measured against', () => {
const state = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
});
triggerReportEntry(TimerLifeCycle.onStart, state);
expect(generateShowReport()).toMatchObject({ plannedStart: 68400000, plannedEnd: 75600000 });
});
it('keeps the plan captured at start when the rundown is edited later', () => {
const start = makeRuntimeStateData({
eventNow: eventA,
timer: { startedAt: 0 },
clock: 0,
_startEpoch: 1000,
rundown: { plannedStart: 68400000, plannedEnd: 75600000 },
});
triggerReportEntry(TimerLifeCycle.onStart, start);
// the rundown is reworked mid show, the plan it started against stands
const edited = { ...start, rundown: { ...start.rundown, plannedEnd: 99999999 } } as typeof start;
triggerReportEntry(TimerLifeCycle.onStop, { ...edited, clock: 10000 } as typeof start);
expect(generateShowReport().plannedEnd).toBe(75600000);
});
it('derives actual times from the events that ran', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500, _startEpoch: 1 });
triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 12000 } as typeof state);
expect(generateShowReport()).toMatchObject({ actualStart: 500, actualEnd: 12000 });
});
it('has no times before anything runs', () => {
expect(generateShowReport()).toEqual({
plannedStart: null,
plannedEnd: null,
actualStart: null,
actualEnd: null,
});
});
});
describe('clear()', () => {
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({});
});
});
@@ -10,6 +10,14 @@ router.get('/', (_req: Request, res: Response) => {
res.status(200).json(report.generate());
});
/**
* Show level times, kept separate so the report payload stays as it was
* for the integrations that already read it.
*/
router.get('/show', (_req: Request, res: Response) => {
res.status(200).json(report.generateShowReport());
});
router.delete('/all', (_req: Request, res: Response) => {
report.clear();
res.status(204).send();
@@ -1,4 +1,5 @@
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
import { OntimeEventReport, OntimeReport, RefetchKey, ShowReport, TimerLifeCycle } from 'ontime-types';
import { getActualShowTimes } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
@@ -8,6 +9,20 @@ const report = new Map<string, OntimeEventReport>();
let formattedReport: OntimeReport | null = null;
/**
* Identifies the show the current report belongs to.
* The report describes one run, so starting a new show begins a fresh one
* rather than mixing a rehearsal into the numbers for the performance.
*/
let currentShowStart: number | null = null;
/**
* The plan the show was measured against, taken when it starts.
* Snapshotted for the same reason the per event schedule is: editing the
* rundown afterwards must not move the target a past show was judged by.
*/
let plannedTimes: Pick<ShowReport, 'plannedStart' | 'plannedEnd'> = { plannedStart: null, plannedEnd: null };
/**
* generates a full report
* @returns full report
@@ -27,9 +42,14 @@ export function clear(id?: string) {
formattedReport = null;
if (id) {
report.delete(id);
} else {
report.clear();
return;
}
// clearing everything also forgets which show the report described, so the
// next event starts a report rather than resuming the one just discarded
report.clear();
currentShowStart = null;
plannedTimes = { plannedStart: null, plannedEnd: null };
}
/**
@@ -49,15 +69,62 @@ export function triggerReportEntry(
const eventId = state.eventNow.id;
if (cycle === TimerLifeCycle.onStart) {
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
startShowIfNew(state);
report.set(eventId, {
startedAt: state.timer.startedAt,
endedAt: null,
// 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,
});
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,
});
formattedReport = null;
sendRefetch(RefetchKey.Report);
}
}
/**
* Clears the report when a new show begins.
*
* The runtime stamps a show when its first event starts, so a change of stamp
* means the previous report described a different run. Without this the report
* would accumulate across rehearsals and performances with no way to tell
* which numbers belonged to which.
* @private
*/
function startShowIfNew(state: DeepReadonly<RuntimeState>) {
const showStart = state._startEpoch ?? state.rundown.actualStart;
if (showStart === null || showStart === currentShowStart) {
return;
}
report.clear();
formattedReport = null;
currentShowStart = showStart;
plannedTimes = {
plannedStart: state.rundown.plannedStart,
plannedEnd: state.rundown.plannedEnd,
};
}
/**
* Show level times for the report.
* Planned times are the ones captured when the show started, actual times are
* derived from the events that ran.
*/
export function generateShowReport(): ShowReport {
return { ...plannedTimes, ...getActualShowTimes(generate()) };
}