mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 08:39:34 +00:00
fix(report): correct run timestamps, scoping and recovery
Review of the run history feature turned up four defects, all in the code that feature introduced. - Runs were dated with a time of day rather than a wall clock instant. `clock` and `rundown.actualStart` are millis since midnight, so every run in the history list rendered as 1 January 1970, and getLatestRun ordered runs incorrectly across days: a run at 09:00 today sorted below one at 20:00 last week, so the "last run" chip and /runs/latest could return an older run. Runs now take `_startEpoch`, the instant the show began, and the type documents the invariant. - A stop arriving with no run open is ignored. Loading a project stops playback and reinitialises reporting in the same tick, and the trailing stop was attributing the previous project's event to the new one. - Run summaries counted planned events from the loaded rundown rather than the rundown the run belongs to, so switching rundowns mid-run gave the summary a denominator from a different rundown. A run whose rundown has since been deleted now keeps its last known count instead of dropping to zero. - Recovery of runs left open by a crash only closed the first one. Any others stayed dangling forever: permanently shown as ongoing and never eligible as the latest run. Also replaces the raw ISO default run label with a readable local date and time, and adds a batching upsertRuns to the store so recovery writes the sidecar once rather than racing one write per run. Each fix has a regression test, verified to fail without the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
@@ -22,6 +22,16 @@ vi.mock('../../../services/report-service/report.store.js', () => ({
|
|||||||
runs[index] = run;
|
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) => {
|
deleteRun: vi.fn(async (id: string) => {
|
||||||
const index = runs.findIndex((run) => run.id === id);
|
const index = runs.findIndex((run) => run.id === id);
|
||||||
if (index === -1) return false;
|
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' });
|
let currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown' });
|
||||||
|
/** rundowns reachable by id, standing in for what is on disk */
|
||||||
|
let storedRundowns: Record<string, ReturnType<typeof makeRundown>> = {};
|
||||||
|
|
||||||
vi.mock('../../rundown/rundown.dao.js', () => ({
|
vi.mock('../../rundown/rundown.dao.js', () => ({
|
||||||
getCurrentRundown: vi.fn(() => currentRundown),
|
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 {
|
const {
|
||||||
@@ -70,9 +92,13 @@ beforeEach(async () => {
|
|||||||
order: [eventA.id, eventB.id],
|
order: [eventA.id, eventB.id],
|
||||||
flatOrder: [eventA.id, eventB.id],
|
flatOrder: [eventA.id, eventB.id],
|
||||||
});
|
});
|
||||||
|
storedRundowns = { 'rundown-1': currentRundown };
|
||||||
await initReports('project-a');
|
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()', () => {
|
describe('triggerReportEntry()', () => {
|
||||||
it('captures a snapshot of the schedule when an event starts', () => {
|
it('captures a snapshot of the schedule when an event starts', () => {
|
||||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
||||||
@@ -118,6 +144,14 @@ describe('triggerReportEntry()', () => {
|
|||||||
expect(generate()).toEqual({});
|
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 () => {
|
it('persists a run to history on the first event stop', async () => {
|
||||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
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()', () => {
|
describe('closeRun()', () => {
|
||||||
it('closes the open run and starts a fresh one on the next event', async () => {
|
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 });
|
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||||
@@ -197,6 +292,85 @@ describe('initReports()', () => {
|
|||||||
const recovered = getRun('dangling');
|
const recovered = getRun('dangling');
|
||||||
expect(recovered?.endedAt).toBe(9000);
|
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', () => {
|
describe('run history queries and edits', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
OntimeEventReport,
|
OntimeEventReport,
|
||||||
OntimeReport,
|
OntimeReport,
|
||||||
RefetchKey,
|
RefetchKey,
|
||||||
|
Rundown,
|
||||||
ShowRun,
|
ShowRun,
|
||||||
ShowRunSummary,
|
ShowRunSummary,
|
||||||
TimerLifeCycle,
|
TimerLifeCycle,
|
||||||
@@ -11,9 +12,11 @@ import { countPlannedEvents, generateId, getRunSummary } from 'ontime-utils';
|
|||||||
import { DeepReadonly } from 'ts-essentials';
|
import { DeepReadonly } from 'ts-essentials';
|
||||||
|
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
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 * as reportStore from '../../services/report-service/report.store.js';
|
||||||
import { RuntimeState } from '../../stores/runtimeState.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 */
|
/** per event data for the run currently in progress */
|
||||||
const report = new Map<EntryId, OntimeEventReport>();
|
const report = new Map<EntryId, OntimeEventReport>();
|
||||||
@@ -83,6 +86,13 @@ export function triggerReportEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cycle === TimerLifeCycle.onStop) {
|
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);
|
const previous = report.get(eventId);
|
||||||
report.set(eventId, {
|
report.set(eventId, {
|
||||||
startedAt: previous?.startedAt ?? null,
|
startedAt: previous?.startedAt ?? null,
|
||||||
@@ -109,7 +119,7 @@ export function closeRun() {
|
|||||||
|
|
||||||
// detach the run before the async write so a start arriving in between
|
// 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
|
// 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;
|
openRun = null;
|
||||||
|
|
||||||
void persistRun(closing, generate());
|
void persistRun(closing, generate());
|
||||||
@@ -131,18 +141,33 @@ function openRunIfNeeded(state: DeepReadonly<RuntimeState>) {
|
|||||||
formattedReport = null;
|
formattedReport = null;
|
||||||
|
|
||||||
const rundown = getCurrentRundown();
|
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 = {
|
openRun = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
rundownId: rundown.id,
|
rundownId: rundown.id,
|
||||||
rundownTitle: rundown.title,
|
rundownTitle: rundown.title,
|
||||||
label: new Date().toISOString(),
|
label: makeRunLabel(startedAt),
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: null,
|
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.
|
* Writes the run in progress to the sidecar.
|
||||||
* Persisting on every event stop means an interrupted show still leaves a record.
|
* Persisting on every event stop means an interrupted show still leaves a record.
|
||||||
@@ -160,8 +185,12 @@ async function persistOpenRun(): Promise<void> {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, currentReport: OntimeReport): Promise<void> {
|
async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, currentReport: OntimeReport): Promise<void> {
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getRundownForRun(run.rundownId);
|
||||||
const eventsPlanned = countPlannedEvents(rundown.entries, rundown.flatOrder);
|
// 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({
|
await reportStore.upsertRun({
|
||||||
...run,
|
...run,
|
||||||
@@ -171,17 +200,22 @@ async function persistRun(run: Omit<ShowRun, 'report' | 'summary'>, 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
|
* @private
|
||||||
*/
|
*/
|
||||||
function lastEndedAt(): number | null {
|
function getRundownForRun(rundownId: string): Readonly<Rundown> | null {
|
||||||
let latest: number | null = null;
|
if (rundownId === getCurrentRundownId()) {
|
||||||
for (const entry of report.values()) {
|
return getCurrentRundown();
|
||||||
if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) {
|
}
|
||||||
latest = entry.endedAt;
|
|
||||||
}
|
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<void> {
|
|||||||
|
|
||||||
await reportStore.loadReports(projectFilename);
|
await reportStore.loadReports(projectFilename);
|
||||||
|
|
||||||
const dangling = reportStore.getRuns().find((run) => run.endedAt === null);
|
// close every run left open, not just the first: a run stuck with a null
|
||||||
if (dangling) {
|
// endedAt reads as ongoing forever and is skipped by getLatestRun
|
||||||
const endedAt = Object.values(dangling.report).reduce<number | null>((latest, entry) => {
|
const closed: ShowRun[] = [];
|
||||||
if (entry.endedAt === null) return latest;
|
for (const run of reportStore.getRuns()) {
|
||||||
return latest === null || entry.endedAt > latest ? entry.endedAt : latest;
|
if (run.endedAt !== null) continue;
|
||||||
}, null);
|
closed.push({ ...run, endedAt: lastEndedAtIn(run.report) });
|
||||||
await reportStore.upsertRun({ ...dangling, endedAt });
|
|
||||||
}
|
}
|
||||||
|
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 */
|
/** Run history for the current project, without per event data */
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const {
|
|||||||
getRuns,
|
getRuns,
|
||||||
getRun,
|
getRun,
|
||||||
upsertRun,
|
upsertRun,
|
||||||
|
upsertRuns,
|
||||||
deleteRun,
|
deleteRun,
|
||||||
deleteRunsForRundown,
|
deleteRunsForRundown,
|
||||||
deleteAllRuns,
|
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()', () => {
|
describe('deleteRun()', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await loadReports('project-a');
|
await loadReports('project-a');
|
||||||
|
|||||||
@@ -71,11 +71,26 @@ export function getRun(id: string): ShowRun | undefined {
|
|||||||
* Inserts or replaces a run, keeping the list ordered newest first
|
* Inserts or replaces a run, keeping the list ordered newest first
|
||||||
*/
|
*/
|
||||||
export async function upsertRun(run: ShowRun): Promise<void> {
|
export async function upsertRun(run: ShowRun): Promise<void> {
|
||||||
const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
|
return upsertRuns([run]);
|
||||||
if (index === -1) {
|
}
|
||||||
cache.runs.unshift(run);
|
|
||||||
} else {
|
/**
|
||||||
cache.runs[index] = 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<void> {
|
||||||
|
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();
|
await persist();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,17 @@ export type ShowRun = {
|
|||||||
* is renamed or deleted.
|
* is renamed or deleted.
|
||||||
*/
|
*/
|
||||||
rundownTitle: string;
|
rundownTitle: string;
|
||||||
/** user editable, defaults to a formatted timestamp */
|
/** user editable, defaults to a formatted local date and time */
|
||||||
label: string;
|
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;
|
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;
|
endedAt: MaybeNumber;
|
||||||
report: OntimeReport;
|
report: OntimeReport;
|
||||||
summary: RunSummary;
|
summary: RunSummary;
|
||||||
|
|||||||
Reference in New Issue
Block a user