mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 20:39:18 +00:00
refactor(report): improve report summary
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { PlayableEvent } from 'ontime-types';
|
||||
import { RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { sendRefetch } from '../../../adapters/WebsocketAdapter.js';
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { clear, generate, generateLastRunReport, triggerReportEntry } from '../report.service.js';
|
||||
|
||||
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({ sendRefetch: vi.fn() }));
|
||||
|
||||
const eventA = makeOntimeEvent({
|
||||
id: 'event-a',
|
||||
dayOffset: 0,
|
||||
timeStart: 0,
|
||||
timeEnd: MILLIS_PER_MINUTE,
|
||||
duration: MILLIS_PER_MINUTE,
|
||||
}) as PlayableEvent;
|
||||
const eventB = makeOntimeEvent({
|
||||
id: 'event-b',
|
||||
dayOffset: 0,
|
||||
timeStart: MILLIS_PER_MINUTE,
|
||||
timeEnd: 2 * MILLIS_PER_MINUTE,
|
||||
duration: MILLIS_PER_MINUTE,
|
||||
}) as PlayableEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clear();
|
||||
});
|
||||
|
||||
it('records lifecycle times while keeping the schedule captured at start', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, _startEpoch: 1 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
const edited = { ...eventA, timeStart: 999, duration: 999 } as PlayableEvent;
|
||||
const stop = makeRuntimeStateData({ eventNow: edited, clock: 2 * MILLIS_PER_MINUTE, rundown: { currentDay: 1 } });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toEqual({
|
||||
startedAt: 500,
|
||||
startedAtDay: 0,
|
||||
endedAt: 2 * MILLIS_PER_MINUTE,
|
||||
endedAtDay: 1,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDay: eventA.dayOffset,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
expect(sendRefetch).toHaveBeenCalledTimes(2);
|
||||
expect(sendRefetch).toHaveBeenLastCalledWith(RefetchKey.Report);
|
||||
});
|
||||
|
||||
it('falls back to the current event when a stop arrives without a start', () => {
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, clock: MILLIS_PER_MINUTE });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: null,
|
||||
endedAt: MILLIS_PER_MINUTE,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('accumulates one run and replaces it when the next run starts', () => {
|
||||
const firstRun = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, firstRun);
|
||||
triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, eventNow: eventB });
|
||||
expect(Object.keys(generate())).toHaveLength(2);
|
||||
|
||||
triggerReportEntry(TimerLifeCycle.onStart, { ...firstRun, _startEpoch: 2 });
|
||||
expect(Object.keys(generate())).toEqual([eventA.id]);
|
||||
});
|
||||
|
||||
it('returns the latest report with the rundown plan captured at run start', () => {
|
||||
const rundown = makeRundown({
|
||||
id: 'run-1',
|
||||
title: 'Original title',
|
||||
order: [eventA.id, eventB.id],
|
||||
entries: { [eventA.id]: eventA, [eventB.id]: eventB },
|
||||
});
|
||||
const start = makeRuntimeStateData({
|
||||
eventNow: eventA,
|
||||
timer: { startedAt: 500 },
|
||||
_startEpoch: 1,
|
||||
rundown: { plannedStart: 0, plannedEnd: 2 * MILLIS_PER_MINUTE },
|
||||
});
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start, rundown);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: MILLIS_PER_MINUTE });
|
||||
rundown.title = 'Edited later';
|
||||
|
||||
expect(generateLastRunReport()).toMatchObject({
|
||||
rundown: { id: 'run-1', title: 'Original title' },
|
||||
eventReports: { [eventA.id]: { scheduledDuration: MILLIS_PER_MINUTE } },
|
||||
show: {
|
||||
plannedStart: 0,
|
||||
plannedEnd: 2 * MILLIS_PER_MINUTE,
|
||||
plannedDuration: 2 * MILLIS_PER_MINUTE,
|
||||
actualStart: 500,
|
||||
actualEnd: MILLIS_PER_MINUTE,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the retained report and rundown snapshot together', () => {
|
||||
const rundown = makeRundown({ order: [eventA.id], entries: { [eventA.id]: eventA } });
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, _startEpoch: 1 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state, rundown);
|
||||
clear();
|
||||
|
||||
expect(generateLastRunReport()).toMatchObject({ eventReports: {}, rundown: null });
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { OntimeEventReport, OntimeReport, PlayableEvent } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { getActualShowTimes, getPlannedShowDuration } from '../report.utils.js';
|
||||
|
||||
function makeReport(patch: Partial<OntimeEventReport>): OntimeEventReport {
|
||||
return {
|
||||
startedAt: 0,
|
||||
startedAtDay: 0,
|
||||
endedAt: 0,
|
||||
endedAtDay: 0,
|
||||
scheduledStart: 0,
|
||||
scheduledDay: 0,
|
||||
scheduledDuration: 0,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getActualShowTimes()', () => {
|
||||
it('preserves long gaps within the same day', () => {
|
||||
const report: OntimeReport = {
|
||||
morning: makeReport({ startedAt: 6 * MILLIS_PER_HOUR, endedAt: 7 * MILLIS_PER_HOUR }),
|
||||
evening: makeReport({ startedAt: 20 * MILLIS_PER_HOUR, endedAt: 21 * MILLIS_PER_HOUR }),
|
||||
};
|
||||
|
||||
expect(getActualShowTimes(report)).toEqual({
|
||||
actualStart: 6 * MILLIS_PER_HOUR,
|
||||
actualEnd: 21 * MILLIS_PER_HOUR,
|
||||
actualDuration: 15 * MILLIS_PER_HOUR,
|
||||
});
|
||||
});
|
||||
|
||||
it('orders events by their captured day across midnight', () => {
|
||||
const report: OntimeReport = {
|
||||
beforeMidnight: makeReport({
|
||||
startedAt: dayInMs - 10 * MILLIS_PER_MINUTE,
|
||||
endedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
|
||||
}),
|
||||
afterMidnight: makeReport({
|
||||
startedAt: 0,
|
||||
startedAtDay: 1,
|
||||
endedAt: 10 * MILLIS_PER_MINUTE,
|
||||
endedAtDay: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
expect(getActualShowTimes(report).actualDuration).toBe(20 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
it('derives planned duration from playable, non-skipped events', () => {
|
||||
const first = makeOntimeEvent({
|
||||
id: 'first',
|
||||
dayOffset: 0,
|
||||
timeStart: 23 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
}) as PlayableEvent;
|
||||
const last = makeOntimeEvent({
|
||||
id: 'last',
|
||||
dayOffset: 1,
|
||||
timeStart: MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
}) as PlayableEvent;
|
||||
const skipped = makeOntimeEvent({ id: 'skipped', dayOffset: 2, timeStart: 0, duration: MILLIS_PER_HOUR, skip: true });
|
||||
const rundown = makeRundown({
|
||||
order: [first.id, last.id, skipped.id],
|
||||
entries: { [first.id]: first, [last.id]: last, [skipped.id]: skipped },
|
||||
});
|
||||
|
||||
expect(getPlannedShowDuration(rundown)).toBe(3 * MILLIS_PER_HOUR);
|
||||
});
|
||||
@@ -10,6 +10,10 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generate());
|
||||
});
|
||||
|
||||
router.get('/last-run', (_req: Request, res: Response) => {
|
||||
res.status(200).json(report.generateLastRunReport());
|
||||
});
|
||||
|
||||
router.delete('/all', (_req: Request, res: Response) => {
|
||||
report.clear();
|
||||
res.status(204).send();
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { OntimeEventReport, OntimeReport, RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
import type { LastRunReport, OntimeEventReport, OntimeReport, Rundown, ShowReport } from 'ontime-types';
|
||||
import { RefetchKey, TimerLifeCycle } from 'ontime-types';
|
||||
import type { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { getCurrentRundown } from '../rundown/rundown.dao.js';
|
||||
import { getActualShowTimes, getPlannedShowDuration } from './report.utils.js';
|
||||
|
||||
const report = new Map<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' | 'plannedDuration'> = {
|
||||
plannedStart: null,
|
||||
plannedEnd: null,
|
||||
plannedDuration: null,
|
||||
};
|
||||
let rundownSnapshot: Rundown | null = null;
|
||||
|
||||
/**
|
||||
* generates a full report
|
||||
* @returns full report
|
||||
@@ -27,9 +49,15 @@ 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, plannedDuration: null };
|
||||
rundownSnapshot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +69,7 @@ export function clear(id?: string) {
|
||||
export function triggerReportEntry(
|
||||
cycle: TimerLifeCycle.onStart | TimerLifeCycle.onStop,
|
||||
state: DeepReadonly<RuntimeState>,
|
||||
rundown: Readonly<Rundown> = getCurrentRundown(),
|
||||
) {
|
||||
if (!state.eventNow?.id) {
|
||||
return;
|
||||
@@ -49,15 +78,80 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
startShowIfNew(state, rundown);
|
||||
|
||||
report.set(eventId, {
|
||||
startedAt: state.timer.startedAt,
|
||||
startedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset,
|
||||
endedAt: null,
|
||||
endedAtDay: null,
|
||||
// snapshot the schedule so later rundown edits cannot change how a show
|
||||
// that already happened is reported
|
||||
scheduledStart: state.eventNow.timeStart,
|
||||
scheduledDay: state.eventNow.dayOffset,
|
||||
scheduledDuration: state.eventNow.duration,
|
||||
});
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
||||
const previous = report.get(eventId);
|
||||
report.set(eventId, {
|
||||
startedAt: previous?.startedAt ?? null,
|
||||
startedAtDay: previous?.startedAtDay ?? null,
|
||||
endedAt: state.clock,
|
||||
endedAtDay: state.rundown.currentDay ?? state.eventNow.dayOffset,
|
||||
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||
scheduledDay: previous?.scheduledDay ?? state.eventNow.dayOffset,
|
||||
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>, rundown: Readonly<Rundown>) {
|
||||
const showStart = state._startEpoch ?? state.rundown.actualStart;
|
||||
if (showStart === null || showStart === currentShowStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
report.clear();
|
||||
formattedReport = null;
|
||||
currentShowStart = showStart;
|
||||
rundownSnapshot = structuredClone(rundown);
|
||||
plannedTimes = {
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
plannedDuration: getPlannedShowDuration(rundownSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show level times for the report.
|
||||
* Planned times are the ones captured when the show started, actual times are
|
||||
* derived from the events that ran.
|
||||
*/
|
||||
function generateShowReport(): ShowReport {
|
||||
return { ...plannedTimes, ...getActualShowTimes(generate()) };
|
||||
}
|
||||
|
||||
/** The single report retained for the UI: the latest run and its captured plan. */
|
||||
export function generateLastRunReport(): LastRunReport {
|
||||
return {
|
||||
eventReports: generate(),
|
||||
rundown: rundownSnapshot,
|
||||
show: generateShowReport(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { OntimeReport, Rundown, ShowReport } from 'ontime-types';
|
||||
import { isOntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
export function getActualShowTimes(
|
||||
report: OntimeReport,
|
||||
): Pick<ShowReport, 'actualStart' | 'actualEnd' | 'actualDuration'> {
|
||||
let firstStart = Number.POSITIVE_INFINITY;
|
||||
let lastEnd = Number.NEGATIVE_INFINITY;
|
||||
let actualStart: number | null = null;
|
||||
let actualEnd: number | null = null;
|
||||
|
||||
for (const entry of Object.values(report)) {
|
||||
if (entry.startedAt !== null && entry.startedAtDay !== null) {
|
||||
const start = entry.startedAtDay * dayInMs + entry.startedAt;
|
||||
if (start < firstStart) {
|
||||
firstStart = start;
|
||||
actualStart = entry.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.endedAt !== null && entry.endedAtDay !== null) {
|
||||
const end = entry.endedAtDay * dayInMs + entry.endedAt;
|
||||
if (end > lastEnd) {
|
||||
lastEnd = end;
|
||||
actualEnd = entry.endedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actualStart,
|
||||
actualEnd,
|
||||
actualDuration: actualStart === null || actualEnd === null ? null : lastEnd - firstStart,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlannedShowDuration(rundown: Rundown): number | null {
|
||||
let firstStart = Number.POSITIVE_INFINITY;
|
||||
let lastEnd = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const id of rundown.flatOrder) {
|
||||
const entry = rundown.entries[id];
|
||||
if (!entry || !isOntimeEvent(entry) || entry.skip) continue;
|
||||
|
||||
const start = entry.dayOffset * dayInMs + entry.timeStart;
|
||||
firstStart = Math.min(firstStart, start);
|
||||
lastEnd = Math.max(lastEnd, start + entry.duration);
|
||||
}
|
||||
|
||||
return Number.isFinite(firstStart) ? lastEnd - firstStart : null;
|
||||
}
|
||||
Reference in New Issue
Block a user