Files
ontime/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts
T
Claude 3c2875a956 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
2026-08-08 11:08:28 +00:00

139 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { EntryId, MaybeNumber, OntimeEventReport, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatDuration, formatTime } from '../../../../common/utils/time';
/**
* Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed
* events has no meaningful drift to report.
*/
export function formatDrift(drift: number, eventsRun: number): string {
if (eventsRun === 0) return '';
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
export type CombinedReport = {
id: EntryId;
index: number;
title: string;
cue: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
playCount: number;
};
/**
* Creates a combined report joining a run's per event data with the rundown.
*
* A run's report keeps its own snapshot of the schedule (`scheduledStart` /
* `scheduledDuration`), so an event that ran is shown against the schedule as
* it was at the time, not against whatever the rundown has been edited to since.
*
* Events that ran but no longer exist in the rundown (deleted since the run)
* are still included, from the snapshot alone, so a historical run stays a
* complete record even after the rundown changes.
*/
export function getCombinedReport(
report: OntimeReport,
rundownEntries: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0 && flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
const seen = new Set<EntryId>();
let index = 1;
for (const id of flatOrder) {
const entry = rundownEntries[id];
if (!entry || !isOntimeEvent(entry)) continue;
seen.add(id);
combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd));
index++;
}
for (const [id, reportEntry] of Object.entries(report)) {
if (seen.has(id)) continue;
combinedReport.push(
makeCombinedEntry(
id,
index,
'(deleted event)',
'',
reportEntry,
reportEntry.scheduledStart,
reportEntry.scheduledStart + reportEntry.scheduledDuration,
),
);
index++;
}
return combinedReport;
}
function makeCombinedEntry(
id: EntryId,
index: number,
title: string,
cue: string,
reportEntry: OntimeEventReport | undefined,
fallbackStart: number,
fallbackEnd: number,
): CombinedReport {
if (!reportEntry) {
return {
id,
index,
title,
cue,
scheduledStart: fallbackStart,
scheduledEnd: fallbackEnd,
actualStart: null,
actualEnd: null,
playCount: 0,
};
}
return {
id,
index,
title,
cue,
scheduledStart: reportEntry.scheduledStart,
scheduledEnd: reportEntry.scheduledStart + reportEntry.scheduledDuration,
actualStart: reportEntry.startedAt,
actualEnd: reportEntry.endedAt,
playCount: reportEntry.playCount,
};
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End', 'Play count'];
/**
* Transforms a CombinedReport into a CSV string
*/
export function makeReportCSV(combinedReport: CombinedReport[]) {
const csv: string[][] = [];
csv.push(csvHeader);
for (const entry of combinedReport) {
csv.push([
String(entry.index),
entry.title,
entry.cue,
formatTime(entry.scheduledStart),
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
String(entry.playCount),
]);
}
return makeCSVFromArrayOfArrays(csv);
}