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:
Claude
2026-08-08 11:08:28 +00:00
parent fb559ed9da
commit 3c2875a956
32 changed files with 2144 additions and 172 deletions
+2
View File
@@ -20,6 +20,8 @@ export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const REPORT_RUNS = ['report', 'runs'];
export const getLatestRunQueryKey = (rundownId?: string) => ['report', 'runs', 'latest', rundownId ?? null];
export const TRANSLATION = ['translation'];
// API URLs
+50 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { OntimeReport, ShowRun, ShowRunSummary } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -24,3 +24,52 @@ export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
/**
* HTTP request to fetch the run history, optionally scoped to a rundown
*/
export async function fetchRuns(rundownId?: string, options?: RequestOptions): Promise<ShowRunSummary[]> {
const res = await axios.get(`${reportUrl}/runs`, {
signal: options?.signal,
params: rundownId ? { rundownId } : undefined,
});
return res.data;
}
/**
* HTTP request to fetch a single run, including its per event data
*/
export async function fetchRun(id: string, options?: RequestOptions): Promise<ShowRun> {
const res = await axios.get(`${reportUrl}/runs/${id}`, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to fetch the most recently closed run, optionally scoped to a rundown
* @returns null if there is no closed run yet
*/
export async function fetchLatestRun(rundownId?: string, options?: RequestOptions): Promise<ShowRun | null> {
try {
const res = await axios.get(`${reportUrl}/runs/latest`, {
signal: options?.signal,
params: rundownId ? { rundownId } : undefined,
});
return res.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
export async function renameRun(id: string, label: string): Promise<ShowRun> {
const res = await axios.patch(`${reportUrl}/runs/${id}`, { label });
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
return res.data;
}
export async function deleteRun(id: string) {
await axios.delete(`${reportUrl}/runs/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
@@ -26,6 +26,16 @@ export function useProjectRundowns() {
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
}
/**
* The id of the currently loaded rundown, or null if none is loaded yet.
* Reads from the same lightweight summary query as `useProjectRundowns`, so
* consumers that only need the id are not subscribed to full rundown entries.
*/
export function useLoadedRundownId(): string | null {
const { data } = useProjectRundowns();
return data.loaded || null;
}
export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { ShowRun, ShowRunSummary } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getLatestRunQueryKey, REPORT_RUNS } from '../api/constants';
import { fetchLatestRun, fetchRun, fetchRuns } from '../api/report';
/**
* Run history for the current project, optionally scoped to a rundown
*/
export default function useRuns(rundownId?: string) {
const { data, status, refetch } = useQuery<ShowRunSummary[]>({
queryKey: rundownId ? [...REPORT_RUNS, rundownId] : REPORT_RUNS,
queryFn: ({ signal }) => fetchRuns(rundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? [], status, refetch };
}
/**
* A single run with its per event data, fetched on demand when a run is selected
*/
export function useRun(id: string | null) {
const { data, status } = useQuery<ShowRun>({
queryKey: [...REPORT_RUNS, id],
queryFn: ({ signal }) => fetchRun(id as string, { signal }),
enabled: id !== null,
staleTime: MILLIS_PER_HOUR,
});
return { data, status };
}
/**
* Most recently closed run, used to compare a rundown against its last outing.
* Returns null while there is no run to compare against.
*/
export function useLatestRun(rundownId?: string) {
const { data, status } = useQuery<ShowRun | null>({
queryKey: getLatestRunQueryKey(rundownId),
queryFn: ({ signal }) => fetchLatestRun(rundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? null, status };
}
@@ -1,7 +0,0 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
@@ -1,104 +1,57 @@
import { useMemo } from 'react';
import { IoTrashBin } from 'react-icons/io5';
import { useEffect, useState } from 'react';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import Select from '../../../../common/components/select/Select';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import useRuns from '../../../../common/hooks-query/useRuns';
import * as Panel from '../../panel-utils/PanelUtils';
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
import RunDetail from './composite/RunDetail';
import RunsList from './composite/RunsList';
import style from './ReportSettings.module.scss';
const allRundowns = 'all';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useRundown();
const { data: rundownsList } = useProjectRundowns();
const [rundownFilter, setRundownFilter] = useState<string>(allRundowns);
const { data: runs } = useRuns(rundownFilter === allRundowns ? undefined : rundownFilter);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
if (!combinedReport) {
return;
}
const csv = makeReportCSV(combinedReport);
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, 'ontime-report.csv');
};
// keep a valid selection: default to the most recent run, and fall off the
// current one when it drops out of the filtered list
useEffect(() => {
if (selectedRunId && runs.some((run) => run.id === selectedRunId)) return;
setSelectedRunId(runs[0]?.id ?? null);
}, [runs, selectedRunId]);
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
const rundownOptions = [
{ value: allRundowns, label: 'All rundowns' },
...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })),
];
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.SubHeader>
Show reports
{rundownsList.rundowns.length > 1 && (
<Select
value={rundownFilter}
onValueChange={(value: string | null) => value && setRundownFilter(value)}
options={rundownOptions}
/>
)}
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoTrashBin />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)}
{combinedReport.map((entry) => {
const start = (() => {
if (entry.actualStart === null) return null;
if (entry.actualStart <= entry.scheduledStart) return 'under';
return 'over';
})();
const end = (() => {
if (entry.actualEnd === null) return null;
if (entry.actualEnd <= entry.scheduledEnd) return 'under';
return 'over';
})();
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
<Panel.Paragraph>
A run is created the first time an event is started, and is added to history once playback stops. Every
run keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
</Panel.Paragraph>
<RunsList runs={runs} rundownId={rundownFilter === allRundowns ? undefined : rundownFilter} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
</Panel.Card>
{selectedRunId && (
<Panel.Card>
<RunDetail runId={selectedRunId} />
</Panel.Card>
)}
</Panel.Section>
);
}
@@ -0,0 +1,143 @@
import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { getCombinedReport, makeReportCSV } from '../reportSettings.utils';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
flag: false,
cue: '1',
title: 'event title',
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
linkStart: false,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 0,
timeEnd: 10000,
duration: 10000,
skip: false,
colour: '',
timeWarning: 0,
timeDanger: 0,
custom: {},
triggers: [],
parent: null,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
...patch,
} as OntimeEvent;
}
describe('getCombinedReport()', () => {
it('returns an empty list when there is nothing to show', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('includes an event which has not run, using the current schedule', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const rundownEntries: RundownEntries = { a: entry };
const result = getCombinedReport({}, rundownEntries, ['a']);
expect(result).toEqual([
{
id: 'a',
index: 1,
title: 'event title',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
playCount: 0,
},
]);
});
it('uses the snapshot taken when the event ran, not the current rundown', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 }); // edited after the run
const rundownEntries: RundownEntries = { a: entry };
const report: OntimeReport = {
a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999
actualStart: 100,
actualEnd: 10100,
playCount: 1,
});
});
it('keeps an event which ran but has since been removed from the rundown', () => {
const report: OntimeReport = {
deleted: { startedAt: 0, endedAt: 5000, scheduledStart: 0, scheduledDuration: 5000, playCount: 1 },
};
const result = getCombinedReport(report, {}, []);
expect(result).toEqual([
{
id: 'deleted',
index: 1,
title: '(deleted event)',
cue: '',
scheduledStart: 0,
scheduledEnd: 5000,
actualStart: 0,
actualEnd: 5000,
playCount: 1,
},
]);
});
it('orders rundown events first, deleted events after', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const report: OntimeReport = {
a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
deleted: { startedAt: 10000, endedAt: 15000, scheduledStart: 10000, scheduledDuration: 5000, playCount: 1 },
};
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result.map((entry) => entry.id)).toEqual(['a', 'deleted']);
});
it('skips entries which are not events', () => {
const rundownEntries: RundownEntries = {
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
};
expect(getCombinedReport({}, rundownEntries, ['delay'])).toEqual([]);
});
});
describe('makeReportCSV()', () => {
it('produces a header row and one row per entry', () => {
const csv = makeReportCSV([
{
id: 'a',
index: 1,
title: 'Welcome',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: 0,
actualEnd: 12000,
playCount: 1,
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(rows[0]).toContain('Play count');
});
});
@@ -0,0 +1,32 @@
th.over {
color: $playback-over;
}
th.under {
color: $playback-under;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 0 var(--panel-card-padding, 2rem);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.statLabel {
font-size: calc(1rem - 3px);
text-transform: uppercase;
letter-spacing: 0.02em;
color: $gray-300;
}
.statValue {
font-size: 1rem;
font-weight: 600;
}
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { IoDownload } from 'react-icons/io5';
import { createBlob, downloadBlob } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import { useRundownById } from '../../../../../common/hooks-query/useRundown';
import { useRun } from '../../../../../common/hooks-query/useRuns';
import { cx } from '../../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../../common/utils/time';
import * as Panel from '../../../panel-utils/PanelUtils';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
import style from './RunDetail.module.scss';
interface RunDetailProps {
runId: string;
}
export default function RunDetail({ runId }: RunDetailProps) {
const { data: run, status } = useRun(runId);
// resolves the rundown the run belongs to, independent of what is currently loaded,
// so titles and cues are correct even when browsing an older run
const { data: rundown } = useRundownById(run?.rundownId);
const combinedReport = useMemo(() => {
if (!run) return [];
return getCombinedReport(run.report, rundown.entries, rundown.flatOrder);
}, [run, rundown]);
const downloadCSV = (report: CombinedReport[]) => {
if (!run || report.length === 0) return;
const csv = makeReportCSV(report);
const filename = `ontime-report-${run.label.replace(/\s+/g, '-').toLowerCase()}.csv`;
const blob = createBlob(csv, 'text/csv;charset=utf-8;');
downloadBlob(blob, filename);
};
if (status === 'pending' || !run) {
return null;
}
return (
<Panel.Section>
<Panel.SubHeader>
{run.label}
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownload />
Export CSV
</Button>
</Panel.SubHeader>
<Panel.Divider />
<div className={style.summary}>
<Stat label='Events run' value={`${run.summary.eventsRun} / ${run.summary.eventsPlanned}`} />
<Stat label='Scheduled' value={formatDuration(run.summary.scheduledDuration, false)} />
<Stat label='Actual' value={formatDuration(run.summary.actualDuration, false)} />
<Stat label='Drift' value={formatDrift(run.summary.drift, run.summary.eventsRun)} />
<Stat label='On time' value={`${run.summary.eventsOnTime}`} />
<Stat label='Over' value={`${run.summary.eventsOver}`} />
<Stat label='Under' value={`${run.summary.eventsUnder}`} />
</div>
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>#</th>
<th>Cue</th>
<th>Title</th>
<th>Scheduled Start</th>
<th>Actual Start</th>
<th>Scheduled End</th>
<th>Actual End</th>
</tr>
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty title='No events in this run' description='This run has no recorded events yet.' />
)}
{combinedReport.map((entry) => {
const start = punctuality(entry.actualStart, entry.scheduledStart);
const end = punctuality(entry.actualEnd, entry.scheduledEnd);
return (
<tr key={entry.id}>
<th>{entry.index}</th>
<th>{entry.cue}</th>
<th>{entry.title}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.scheduledStart)}</th>
<th className={cx([start && style[start]])}>{formatTime(entry.actualStart)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.scheduledEnd)}</th>
<th className={cx([end && style[end]])}>{formatTime(entry.actualEnd)}</th>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className={style.stat}>
<span className={style.statLabel}>{label}</span>
<span className={style.statValue}>{value}</span>
</div>
);
}
/** Whether an actual time landed before (under) or after (over) its schedule */
function punctuality(actual: number | null, scheduled: number): 'under' | 'over' | null {
if (actual === null) return null;
return actual <= scheduled ? 'under' : 'over';
}
@@ -0,0 +1,12 @@
.row {
cursor: pointer;
&:hover {
background-color: $white-3;
}
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
@@ -0,0 +1,156 @@
import { ShowRunSummary } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import { IoCheckmark, IoClose, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteRun, renameRun } from '../../../../../common/api/report';
import { maybeAxiosError } from '../../../../../common/api/utils';
import IconButton from '../../../../../common/components/buttons/IconButton';
import Input from '../../../../../common/components/input/input/Input';
import Tag from '../../../../../common/components/tag/Tag';
import useRuns from '../../../../../common/hooks-query/useRuns';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import { formatDrift } from '../reportSettings.utils';
import style from './RunsList.module.scss';
interface RunsListProps {
runs: ShowRunSummary[];
rundownId?: string;
selectedRunId: string | null;
onSelect: (id: string) => void;
}
export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: RunsListProps) {
const { refetch } = useRuns(rundownId);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [error, setError] = useState<string | null>(null);
const startRename = (run: ShowRunSummary) => {
setRenamingId(run.id);
setRenameValue(run.label);
};
const submitRename = async (id: string) => {
const label = renameValue.trim();
setRenamingId(null);
if (label.length === 0) {
return;
}
try {
setError(null);
await renameRun(id, label);
} catch (renameError) {
setError(maybeAxiosError(renameError));
} finally {
refetch();
}
};
const handleDelete = async (id: string) => {
try {
setError(null);
await deleteRun(id);
if (id === selectedRunId) {
const next = runs.find((run) => run.id !== id);
if (next) onSelect(next.id);
}
} catch (deleteError) {
setError(maybeAxiosError(deleteError));
} finally {
refetch();
}
};
const handleRenameKeyDown = (event: KeyboardEvent<HTMLInputElement>, id: string) => {
preventEscape(event, () => setRenamingId(null));
if (event.key === 'Enter') {
event.preventDefault();
submitRename(id);
}
};
return (
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>Run</th>
<th>Rundown</th>
<th>Started</th>
<th>Drift</th>
<th />
<th />
</tr>
</thead>
<tbody>
{runs.length === 0 && (
<Panel.TableEmpty
title='No runs yet'
description='A run is created the first time an event is started, and is added to history once playback stops.'
/>
)}
{runs.map((run) => {
const isSelected = run.id === selectedRunId;
const isRenaming = renamingId === run.id;
return (
<tr
key={run.id}
className={cx([style.row, isSelected && style.current])}
onClick={() => onSelect(run.id)}
>
<td>
{isRenaming ? (
<Panel.InlineElements relation='inner' onClick={(event) => event.stopPropagation()}>
<Input
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={(event) => handleRenameKeyDown(event, run.id)}
/>
<IconButton aria-label='Save name' variant='ghosted-white' onClick={() => submitRename(run.id)}>
<IoCheckmark />
</IconButton>
<IconButton
aria-label='Cancel rename'
variant='ghosted-white'
onClick={() => setRenamingId(null)}
>
<IoClose />
</IconButton>
</Panel.InlineElements>
) : (
run.label
)}
</td>
<td>{run.rundownTitle}</td>
<td>{new Date(run.startedAt).toLocaleString()}</td>
<td>{formatDrift(run.summary.drift, run.summary.eventsRun)}</td>
<td>{run.endedAt === null && <Tag variant='active'>Ongoing</Tag>}</td>
<Panel.InlineElements align='end' relation='inner' as='td' onClick={(event) => event.stopPropagation()}>
{!isRenaming && (
<>
<IconButton aria-label='Rename run' variant='ghosted-white' onClick={() => startRename(run)}>
<IoPencil />
</IconButton>
<IconButton
aria-label='Delete run'
variant='ghosted-destructive'
onClick={() => handleDelete(run.id)}
>
<IoTrash />
</IconButton>
</>
)}
</Panel.InlineElements>
</tr>
);
})}
</tbody>
</Panel.Table>
{error && <Panel.Error>{error}</Panel.Error>}
</Panel.Section>
);
}
@@ -1,7 +1,18 @@
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { EntryId, MaybeNumber, OntimeEventReport, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time';
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;
@@ -12,59 +23,96 @@ export type CombinedReport = {
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
playCount: number;
};
/**
* Creates a combined report with the rundown data
* 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,
rundown: RundownEntries,
rundownEntries: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
if (Object.keys(report).length === 0 && flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
const seen = new Set<EntryId>();
let index = 1;
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id];
for (const id of flatOrder) {
const entry = rundownEntries[id];
if (!entry || !isOntimeEvent(entry)) continue;
if (!(id in report)) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
}
seen.add(id);
combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd));
index++;
}
if (id in report) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: report[id].endedAt,
scheduledEnd: entry.timeEnd,
actualStart: report[id].startedAt,
});
}
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;
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
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
@@ -82,6 +130,7 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
String(entry.playCount),
]);
}
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -16,4 +16,10 @@
&.due {
color: $warning-orange;
}
// preview of how this event went last time it ran, shown before it plays again
&.muted {
color: $label-gray;
opacity: 0.7;
}
}
@@ -1,10 +1,19 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import {
EventVariance,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
getEventVariance,
isPlaybackActive,
millisToString,
} from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useLoadedRundownId } from '../../../../common/hooks-query/useProjectRundowns';
import useReport from '../../../../common/hooks-query/useReport';
import { useLatestRun } from '../../../../common/hooks-query/useRuns';
import { usePlayback } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time';
@@ -20,7 +29,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +41,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +52,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} duration={duration} />;
return <EventReport className={className} id={id} />;
}
if (playbackActive) {
@@ -86,49 +93,60 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id, duration } = props;
const { className, id } = props;
const { data } = useReport();
const currentReport = data[id];
const [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
return [null, 'none', ''];
// an event with nothing in the current run's report yet can still show how
// it went last time, so a repeat show previews its schedule before it plays
const loadedRundownId = useLoadedRundownId();
const { data: lastRun } = useLatestRun(loadedRundownId ?? undefined);
const [value, chipStyle, tooltip] = useMemo(() => {
// compares against the schedule as it was when the event ran, not the
// rundown's current values, so an edit made afterwards cannot change this
const variance = getEventVariance(currentReport);
if (variance.status !== 'not-run') {
return describeVariance(variance, false);
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
const lastRunVariance = getEventVariance(lastRun?.report[id]);
if (lastRunVariance.status !== 'not-run') {
return describeVariance(lastRunVariance, true);
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const fullTimeValue = millisToString(absDifference);
const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, isOver ? 'over' : 'under', tooltip];
}, [currentReport, duration]);
return [null, 'none', ''];
}, [currentReport, id, lastRun]);
if (!value) {
return null;
}
return (
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[overUnderStyle], className])}>
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[chipStyle], className])}>
{value === 'ontime' ? <IoCheckmarkCircle size='1.1rem' /> : value}
</Tooltip>
);
}
/**
* Formats a variance into the chip's value, style and tooltip.
* `muted` marks a preview of a past run rather than a live status.
*/
function describeVariance(variance: EventVariance, muted: boolean): [string, string, string] {
const prefix = muted ? 'Last run: ' : '';
if (variance.status === 'ontime') {
return ['ontime', muted ? 'muted' : 'under', `${prefix}Event finished on time`];
}
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
const fullTimeValue = millisToString(absDifference);
const tooltip = `${prefix}Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`;
const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`;
return [value, muted ? 'muted' : isOver ? 'over' : 'under', tooltip];
}