mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+143
@@ -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');
|
||||
});
|
||||
});
|
||||
+32
@@ -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';
|
||||
}
|
||||
+12
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user