mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
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:
@@ -99,6 +99,15 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// feature business logic - reports
|
||||
export {
|
||||
countPlannedEvents,
|
||||
getEventVariance,
|
||||
getRunSummary,
|
||||
type EventVariance,
|
||||
type VarianceStatus,
|
||||
} from './src/report-utils/reportUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
|
||||
|
||||
import { getEventVariance, getRunSummary } from './reportUtils.js';
|
||||
|
||||
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
|
||||
return {
|
||||
startedAt: 0,
|
||||
endedAt: 10000,
|
||||
scheduledStart: 0,
|
||||
scheduledDuration: 10000,
|
||||
playCount: 1,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getEventVariance()', () => {
|
||||
it('reports an event which never ran', () => {
|
||||
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null, delta: 0 });
|
||||
});
|
||||
|
||||
it('reports an event which started but never finished', () => {
|
||||
const entry = makeEntry({ startedAt: 1000, endedAt: null });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run', actualDuration: null });
|
||||
});
|
||||
|
||||
it('reports an event which never started', () => {
|
||||
const entry = makeEntry({ startedAt: null, endedAt: 1000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run' });
|
||||
});
|
||||
|
||||
it('reports an event which matched its schedule', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10000, delta: 0 });
|
||||
});
|
||||
|
||||
it('treats sub-second differences as on time', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10500, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', delta: 500 });
|
||||
});
|
||||
|
||||
it('reports an overrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'over', actualDuration: 15000, delta: 5000 });
|
||||
});
|
||||
|
||||
it('reports an underrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 6000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'under', actualDuration: 6000, delta: -4000 });
|
||||
});
|
||||
|
||||
it('measures against the snapshot, not the current rundown', () => {
|
||||
// the rundown may have been edited after the run, the snapshot is what counts
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry).delta).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunSummary()', () => {
|
||||
it('returns an empty summary for an empty report', () => {
|
||||
expect(getRunSummary({}, 0)).toMatchObject({
|
||||
eventsRun: 0,
|
||||
eventsPlanned: 0,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
worstOverrun: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates durations and drift across a run', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 15000, endedAt: 21000, scheduledDuration: 10000 }), // -4000
|
||||
c: makeEntry({ startedAt: 21000, endedAt: 31000, scheduledDuration: 10000 }), // 0
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 4)).toMatchObject({
|
||||
eventsRun: 3,
|
||||
eventsPlanned: 4,
|
||||
scheduledDuration: 30000,
|
||||
actualDuration: 31000,
|
||||
drift: 1000,
|
||||
eventsOver: 1,
|
||||
eventsUnder: 1,
|
||||
eventsOnTime: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies the worst overrun', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 0, endedAt: 30000, scheduledDuration: 10000 }), // +20000
|
||||
c: makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }), // +2000
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
|
||||
});
|
||||
|
||||
it('ignores events which did not complete', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }),
|
||||
b: makeEntry({ startedAt: 15000, endedAt: null, scheduledDuration: 10000 }),
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 2)).toMatchObject({
|
||||
eventsRun: 1,
|
||||
scheduledDuration: 10000,
|
||||
actualDuration: 15000,
|
||||
drift: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { EntryId, OntimeEventReport, OntimeReport, RundownEntries, RunSummary } from 'ontime-types';
|
||||
import { isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import { MILLIS_PER_SECOND } from '../date-utils/conversionUtils.js';
|
||||
|
||||
export type VarianceStatus = 'ontime' | 'over' | 'under' | 'not-run';
|
||||
|
||||
export type EventVariance = {
|
||||
/** how long the event actually took, null if it never completed */
|
||||
actualDuration: number | null;
|
||||
/** actualDuration - scheduledDuration, signed. 0 when the event did not complete */
|
||||
delta: number;
|
||||
status: VarianceStatus;
|
||||
};
|
||||
|
||||
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
|
||||
|
||||
/**
|
||||
* Calculates how an event performed against its schedule.
|
||||
* An event is considered on time if it is within a second of its scheduled duration.
|
||||
*/
|
||||
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
|
||||
if (!entry) {
|
||||
return notRun;
|
||||
}
|
||||
|
||||
const { startedAt, endedAt, scheduledDuration } = entry;
|
||||
if (startedAt === null || endedAt === null) {
|
||||
return notRun;
|
||||
}
|
||||
|
||||
const actualDuration = endedAt - startedAt;
|
||||
const delta = actualDuration - scheduledDuration;
|
||||
|
||||
if (Math.abs(delta) < MILLIS_PER_SECOND) {
|
||||
return { actualDuration, delta, status: 'ontime' };
|
||||
}
|
||||
|
||||
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates a run's per event data into the headline numbers for a show.
|
||||
* @param report the run's per event data
|
||||
* @param eventsPlanned how many playable events the rundown held when the run was made
|
||||
*/
|
||||
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
|
||||
const summary: RunSummary = {
|
||||
eventsRun: 0,
|
||||
eventsPlanned,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
eventsOver: 0,
|
||||
eventsUnder: 0,
|
||||
eventsOnTime: 0,
|
||||
worstOverrun: null,
|
||||
};
|
||||
|
||||
for (const [id, entry] of Object.entries(report)) {
|
||||
const variance = getEventVariance(entry);
|
||||
if (variance.status === 'not-run') {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.eventsRun += 1;
|
||||
summary.scheduledDuration += entry.scheduledDuration;
|
||||
summary.actualDuration += variance.actualDuration as number;
|
||||
|
||||
if (variance.status === 'over') {
|
||||
summary.eventsOver += 1;
|
||||
if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) {
|
||||
summary.worstOverrun = { id, delta: variance.delta };
|
||||
}
|
||||
} else if (variance.status === 'under') {
|
||||
summary.eventsUnder += 1;
|
||||
} else {
|
||||
summary.eventsOnTime += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary.drift = summary.actualDuration - summary.scheduledDuration;
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the events a run could have played.
|
||||
* Skipped events are excluded: they were never meant to run and would
|
||||
* make the completion figures read as if the show fell short.
|
||||
*/
|
||||
export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number {
|
||||
let count = 0;
|
||||
for (const id of order) {
|
||||
const entry = entries[id];
|
||||
if (entry && isOntimeEvent(entry) && !entry.skip) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user