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 ab9edcd84..415ee90ee 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 @@ -22,6 +22,16 @@ vi.mock('../../../services/report-service/report.store.js', () => ({ runs[index] = run; } }), + upsertRuns: vi.fn(async (updated: ShowRun[]) => { + for (const run of updated) { + 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; @@ -39,9 +49,21 @@ vi.mock('../../../services/report-service/report.store.js', () => ({ })); 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 { @@ -70,9 +92,13 @@ beforeEach(async () => { order: [eventA.id, eventB.id], flatOrder: [eventA.id, eventB.id], }); + storedRundowns = { 'rundown-1': currentRundown }; await initReports('project-a'); }); +/** 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', () => { const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 }); @@ -118,6 +144,14 @@ describe('triggerReportEntry()', () => { 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({}); + }); + 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); @@ -132,6 +166,67 @@ describe('triggerReportEntry()', () => { }); }); +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 new Promise((resolve) => setImmediate(resolve)); + + 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 new Promise((resolve) => setImmediate(resolve)); + + const run = listRuns()[0]; + expect(run.startedAt).toBeGreaterThanOrEqual(before); + expect(run.startedAt).toBeLessThanOrEqual(Date.now()); + }); + + it('orders runs from different days correctly', async () => { + // a run at 09:30 today must rank above one at 20:00 yesterday, which + // time-of-day ordering would get backwards + const yesterdayEvening = Date.UTC(2026, 7, 7, 20, 0); + await makeClosedRunAt('older', yesterdayEvening); + await makeClosedRunAt('newer', showEpoch); + + expect(getLatestRun()?.id).toBe('newer'); + }); + + async function makeClosedRunAt(id: string, epoch: number) { + // the time of day deliberately disagrees with chronological order here: + // 20:00 yesterday is a larger time of day than 09:30 today + 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); + closeRun(); + await new Promise((resolve) => setImmediate(resolve)); + runs[0] = { ...runs[0], id }; + } +}); + 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 }); @@ -197,6 +292,85 @@ describe('initReports()', () => { const recovered = getRun('dangling'); expect(recovered?.endedAt).toBe(9000); }); + + it('closes every dangling run, not only the first', async () => { + runs = [makeDanglingRun('first', 4000), makeDanglingRun('second', 7000)]; + + await initReports('project-a'); + + expect(getRun('first')?.endedAt).toBe(4000); + expect(getRun('second')?.endedAt).toBe(7000); + expect(listRuns().every((run) => run.endedAt !== null)).toBe(true); + }); + + function makeDanglingRun(id: string, endedAt: number): ShowRun { + return { + id, + rundownId: 'rundown-1', + rundownTitle: 'Test rundown', + label: id, + startedAt: showEpoch, + endedAt: null, + report: { + [eventA.id]: { startedAt: 0, endedAt, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 }, + }, + summary: { + eventsRun: 1, + eventsPlanned: 2, + scheduledDuration: 10000, + actualDuration: endedAt, + drift: endedAt - 10000, + eventsOver: 0, + eventsUnder: 1, + eventsOnTime: 0, + worstOverrun: null, + }, + }; + } +}); + +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']; + + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); + await new Promise((resolve) => setImmediate(resolve)); + + // three, from rundown-2, not two from the now loaded rundown-1 + expect(listRuns()[0].summary.eventsPlanned).toBe(3); + }); + + it('keeps the last known count when the rundown has been deleted', async () => { + 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); + await new Promise((resolve) => setImmediate(resolve)); + + // rundown disappears from both the loaded slot and storage + const runId = listRuns()[0].id; + runs[0] = { ...runs[0], summary: { ...runs[0].summary, eventsPlanned: 7 } }; + currentRundown = storedRundowns['rundown-1']; + + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 20000 } as typeof state); + await new Promise((resolve) => setImmediate(resolve)); + + expect(getRun(runId)?.summary.eventsPlanned).toBe(7); + }); }); describe('run history queries and edits', () => { diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index f0d28f8af..8e6132032 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -3,6 +3,7 @@ import { OntimeEventReport, OntimeReport, RefetchKey, + Rundown, ShowRun, ShowRunSummary, TimerLifeCycle, @@ -11,9 +12,11 @@ import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils'; 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 } from '../rundown/rundown.dao.js'; +import { getCurrentRundown, getCurrentRundownId } from '../rundown/rundown.dao.js'; /** per event data for the run currently in progress */ const report = new Map(); @@ -83,6 +86,13 @@ 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, @@ -109,7 +119,7 @@ export function closeRun() { // 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() }; + const closing = { ...openRun, endedAt: lastEndedAtIn(generate()) }; openRun = null; void persistRun(closing, generate()); @@ -131,18 +141,33 @@ function openRunIfNeeded(state: DeepReadonly) { formattedReport = null; const rundown = getCurrentRundown(); - const startedAt = state.rundown.actualStart ?? state.clock; + // `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: new Date().toISOString(), + label: makeRunLabel(startedAt), startedAt, endedAt: null, }; } +/** + * 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. @@ -160,8 +185,12 @@ async function persistOpenRun(): Promise { * @private */ async function persistRun(run: Omit, currentReport: OntimeReport): Promise { - const rundown = getCurrentRundown(); - const eventsPlanned = countPlannedEvents(rundown.entries, rundown.flatOrder); + const rundown = getRundownForRun(run.rundownId); + // a rundown deleted mid-run leaves nothing to count against, so we keep + // whatever the run was last written with rather than reporting zero + const eventsPlanned = rundown + ? countPlannedEvents(rundown.entries, rundown.flatOrder) + : (reportStore.getRun(run.id)?.summary.eventsPlanned ?? 0); await reportStore.upsertRun({ ...run, @@ -171,17 +200,22 @@ async function persistRun(run: Omit, currentRepor } /** - * Timestamp of the last event to finish in this run + * 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 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; - } +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; } - return latest; } /** @@ -196,14 +230,28 @@ export async function initReports(projectFilename: string): Promise { await reportStore.loadReports(projectFilename); - const dangling = reportStore.getRuns().find((run) => run.endedAt === null); - if (dangling) { - const endedAt = Object.values(dangling.report).reduce((latest, entry) => { - if (entry.endedAt === null) return latest; - return latest === null || entry.endedAt > latest ? entry.endedAt : latest; - }, null); - await reportStore.upsertRun({ ...dangling, endedAt }); + // close every run left open, not just the first: a run stuck with a null + // endedAt reads as ongoing forever and is skipped by getLatestRun + const closed: ShowRun[] = []; + for (const run of reportStore.getRuns()) { + if (run.endedAt !== null) continue; + closed.push({ ...run, endedAt: lastEndedAtIn(run.report) }); } + await reportStore.upsertRuns(closed); +} + +/** + * Timestamp of the last event to finish within a stored report + * @private + */ +function lastEndedAtIn(storedReport: OntimeReport): number | null { + let latest: number | null = null; + for (const entry of Object.values(storedReport)) { + if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) { + latest = entry.endedAt; + } + } + return latest; } /** Run history for the current project, without per event data */ 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 index 65c7a6adc..9adf1dea1 100644 --- a/apps/server/src/services/report-service/__tests__/report.store.test.ts +++ b/apps/server/src/services/report-service/__tests__/report.store.test.ts @@ -44,6 +44,7 @@ const { getRuns, getRun, upsertRun, + upsertRuns, deleteRun, deleteRunsForRundown, deleteAllRuns, @@ -140,6 +141,35 @@ describe('upsertRun() / getRun() / getRuns()', () => { }); }); +describe('upsertRuns()', () => { + beforeEach(async () => { + await loadReports('project-a'); + }); + + it('applies several runs in a single write', async () => { + await upsertRuns([makeRun({ id: 'a' }), makeRun({ id: 'b' })]); + + expect(getRuns().map((run) => run.id)).toEqual(['b', 'a']); + const written = files.get(getPathToReports('project-a')) as { runs: ShowRun[] }; + expect(written.runs).toHaveLength(2); + }); + + it('mixes inserts and replacements', async () => { + await upsertRun(makeRun({ id: 'existing', label: 'before' })); + await upsertRuns([makeRun({ id: 'existing', label: 'after' }), makeRun({ id: 'fresh' })]); + + expect(getRuns()).toHaveLength(2); + expect(getRun('existing')?.label).toBe('after'); + }); + + it('does not write when given nothing to do', async () => { + await upsertRun(makeRun({ id: 'a' })); + await upsertRuns([]); + + expect(getRuns()).toHaveLength(1); + }); +}); + describe('deleteRun()', () => { beforeEach(async () => { await loadReports('project-a'); diff --git a/apps/server/src/services/report-service/report.store.ts b/apps/server/src/services/report-service/report.store.ts index 53e633cb0..8ab409896 100644 --- a/apps/server/src/services/report-service/report.store.ts +++ b/apps/server/src/services/report-service/report.store.ts @@ -71,11 +71,26 @@ export function getRun(id: string): ShowRun | undefined { * Inserts or replaces a run, keeping the list ordered newest first */ export async function upsertRun(run: ShowRun): Promise { - const index = cache.runs.findIndex((candidate) => candidate.id === run.id); - if (index === -1) { - cache.runs.unshift(run); - } else { - cache.runs[index] = run; + return upsertRuns([run]); +} + +/** + * Inserts or replaces several runs in one pass. + * Batching matters: these all live in a single file, so writing per run would + * mean concurrent writes racing on the same path. + */ +export async function upsertRuns(updated: ShowRun[]): Promise { + if (updated.length === 0) { + return; + } + + for (const run of updated) { + const index = cache.runs.findIndex((candidate) => candidate.id === run.id); + if (index === -1) { + cache.runs.unshift(run); + } else { + cache.runs[index] = run; + } } await persist(); } diff --git a/packages/types/src/definitions/core/Report.type.ts b/packages/types/src/definitions/core/Report.type.ts index 290d742ab..ad85b0332 100644 --- a/packages/types/src/definitions/core/Report.type.ts +++ b/packages/types/src/definitions/core/Report.type.ts @@ -41,9 +41,17 @@ export type ShowRun = { * is renamed or deleted. */ rundownTitle: string; - /** user editable, defaults to a formatted timestamp */ + /** 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; + /** + * Time of day the last event in the run finished, or null while the run is + * open. Only ever compared against the per event times in the same run. + */ endedAt: MaybeNumber; report: OntimeReport; summary: RunSummary;