fix(report): measure events against the schedule they ran on

The runtime report joined live report data against the current rundown,
so editing an event after the show silently rewrote the report of a show
that had already happened. Durations, and the over/under chip in the
rundown, would change to match the edit.

Each event now records the schedule it actually ran on, and the report
is read from that.

- OntimeEventReport carries scheduledStart and scheduledDuration,
  captured when the event starts, plus a playCount so an event started
  twice no longer overwrites its own record without trace.
- The over/under calculation moves to ontime-utils, where the rundown
  chip and the settings panel share one implementation instead of
  deriving it separately and disagreeing.
- The report panel gains a summary of the show: planned against actual,
  drift, and how many events landed over, under or on time.
- Export CSV was rendering a trash bin icon.

No change to how or where anything is stored: the report stays in memory
exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-09 19:19:02 +00:00
parent a851414d13
commit 05cf7c7c56
29 changed files with 223 additions and 1761 deletions
-2
View File
@@ -20,8 +20,6 @@ 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 REPORT_OPEN_RUN = ['report', 'runs', 'open'];
export const TRANSLATION = ['translation'];
// API URLs
+1 -47
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeReport, OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants';
@@ -24,49 +24,3 @@ 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 run being recorded
* @returns null when no run is in progress
*/
export async function fetchOpenRun(options?: RequestOptions): Promise<OpenRun | null> {
try {
const res = await axios.get(`${reportUrl}/runs/open`, { signal: options?.signal });
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 });
}
@@ -1,50 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT_OPEN_RUN, REPORT_RUNS } from '../api/constants';
import { fetchOpenRun, 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 };
}
/**
* The run currently being recorded, if any.
* A run in progress lives in memory on the server until it is finished, so it
* cannot be found in the run history and needs its own query.
*/
export function useOpenRun(): OpenRun | null {
const { data } = useQuery<OpenRun | null>({
queryKey: REPORT_OPEN_RUN,
queryFn: ({ signal }) => fetchOpenRun({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return data ?? null;
}
@@ -1,57 +1,131 @@
import { useEffect, useState } from 'react';
import { countPlannedEvents, getRunSummary } from 'ontime-utils';
import { useMemo } from 'react';
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
import Select from '../../../../common/components/select/Select';
import { useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import useRuns from '../../../../common/hooks-query/useRuns';
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 { formatDuration, formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
import RunDetail from './composite/RunDetail';
import RunsList from './composite/RunsList';
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
const allRundowns = 'all';
import style from './ReportSettings.module.scss';
export default function ReportSettings() {
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 { data: reportData } = useReport();
const { data } = useRundown();
// 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 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');
};
const rundownOptions = [
{ value: allRundowns, label: 'All rundowns' },
...rundownsList.rundowns.map((rundown) => ({ value: rundown.id, label: rundown.title || 'Untitled rundown' })),
];
const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.entries, data.flatOrder);
}, [reportData, data.entries, data.flatOrder]);
const summary = useMemo(() => {
return getRunSummary(reportData, countPlannedEvents(data.entries, data.flatOrder));
}, [reportData, data.entries, data.flatOrder]);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Show reports
{rundownsList.rundowns.length > 1 && (
<Select
value={rundownFilter}
onValueChange={(value: string | null) => value && setRundownFilter(value)}
options={rundownOptions}
/>
)}
</Panel.SubHeader>
<Panel.SubHeader>Report</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Recording starts with the first event of a show and is saved here when you finish the run. Every report
keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
</Panel.Paragraph>
<RunsList runs={runs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
<Panel.Section>
<Panel.Title>
Manage report
<Panel.InlineElements>
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
<IoDownloadOutline />
Export CSV
</Button>
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
<IoTrashBin />
Clear All
</Button>
</Panel.InlineElements>
</Panel.Title>
</Panel.Section>
{summary.eventsRun > 0 && (
<Panel.Section>
<div className={style.summary}>
<Stat label='Events run' value={`${summary.eventsRun} / ${summary.eventsPlanned}`} />
<Stat label='Scheduled' value={formatDuration(summary.scheduledDuration, false)} />
<Stat label='Actual' value={formatDuration(summary.actualDuration, false)} />
<Stat label='Drift' value={formatDrift(summary.drift, summary.eventsRun)} />
<Stat label='On time' value={String(summary.eventsOnTime)} />
<Stat label='Over' value={String(summary.eventsOver)} />
<Stat label='Under' value={String(summary.eventsUnder)} />
</div>
</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.Card>
{selectedRunId && (
<Panel.Card>
<RunDetail runId={selectedRunId} />
</Panel.Card>
)}
</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>
);
}
@@ -1,4 +1,12 @@
import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import {
EndAction,
OntimeEvent,
OntimeReport,
RundownEntries,
SupportedEntry,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
@@ -34,40 +42,18 @@ function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
}
describe('getCombinedReport()', () => {
it('returns an empty list when there is nothing to show', () => {
it('returns an empty list when nothing has run', () => {
expect(getCombinedReport({}, {}, [])).toEqual([]);
});
it('includes an event which has not run, using the current schedule', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const rundownEntries: RundownEntries = { a: notRun, b: didRun };
const report: OntimeReport = {
b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a', 'b']);
expect(result[0]).toEqual({
id: 'a',
index: 1,
title: 'event title',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
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 };
it('measures a run event against the schedule recorded at the time', () => {
// the rundown was edited after the show, the report must not follow it
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 99999 });
const report: OntimeReport = {
a: { startedAt: 100, endedAt: 10100, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, rundownEntries, ['a']);
const result = getCombinedReport(report, { a: entry }, ['a']);
expect(result[0]).toMatchObject({
scheduledStart: 0,
@@ -77,8 +63,26 @@ describe('getCombinedReport()', () => {
});
});
it('falls back to the rundown for an event which has not run', () => {
const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 });
const report: OntimeReport = {
b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 },
};
const result = getCombinedReport(report, { a: notRun, b: didRun }, ['a', 'b']);
expect(result[0]).toMatchObject({
id: 'a',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
});
});
it('skips entries which are not events', () => {
const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const entry = makeEvent({ id: 'a' });
const rundownEntries: RundownEntries = {
a: entry,
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
@@ -121,7 +125,6 @@ describe('makeReportCSV()', () => {
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(csv.trim().split('\n')).toHaveLength(2);
});
});
@@ -1,113 +0,0 @@
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';
}
@@ -1,12 +0,0 @@
.row {
cursor: pointer;
&:hover {
background-color: $white-3;
}
}
.current {
// marker consumed by the shared Panel.Table active-row treatment
background-color: inherit;
}
@@ -1,148 +0,0 @@
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 { 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[];
selectedRunId: string | null;
onSelect: (id: string) => void;
}
export default function RunsList({ runs, selectedRunId, onSelect }: RunsListProps) {
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);
};
// the api layer invalidates the report query key, which the run list
// hangs off, so these do not need to refetch by hand
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));
}
};
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));
}
};
const handleRenameKeyDown = (event: KeyboardEvent<HTMLInputElement>, id: string) => {
preventEscape(event, () => setRenamingId(null));
if (event.key === 'Enter') {
event.preventDefault();
void submitRename(id);
}
};
return (
<Panel.Section>
<Panel.Table>
<thead>
<tr>
<th>Run</th>
<th>Rundown</th>
<th>Started</th>
<th>Drift</th>
<th />
</tr>
</thead>
<tbody>
{runs.length === 0 && (
<Panel.TableEmpty
title='No reports yet'
description='Recording starts with the first event of a show. Use Finish run in the editor to save the report here.'
/>
)}
{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>
<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>
);
}
@@ -2,6 +2,7 @@ import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } fro
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
@@ -20,8 +21,8 @@ export type CombinedReport = {
*
* Events that ran are measured against the schedule recorded at the time,
* not the rundown's current values, so editing the rundown afterwards does
* not rewrite a past run. Events that never ran have no snapshot and fall
* back to the rundown.
* not change how a show that already happened is reported. Events that never
* ran have no snapshot and fall back to the rundown.
*/
export function getCombinedReport(
report: OntimeReport,
@@ -71,11 +72,11 @@ export function getCombinedReport(
}
/**
* Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed
* events has no meaningful drift to report.
* Signed drift, eg "+4m12s" / "-1m". With nothing completed there is no
* meaningful drift to report.
*/
export function formatDrift(drift: number, eventsRun: number): string {
if (eventsRun === 0) return '';
if (eventsRun === 0) return enDash;
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
@@ -10,7 +10,6 @@ import {
StartTimesPlanning,
StartTimesRuntime,
} from './composite/TimeElements';
import RunIndicator from './composite/RunIndicator';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
@@ -26,8 +25,6 @@ function EditorOverview({ children }: PropsWithChildren) {
{layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
{layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
{layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
{/* renders nothing unless a run is being recorded */}
<RunIndicator />
</OverviewWrapper>
);
}
@@ -1,31 +0,0 @@
.indicator {
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: calc(1rem - 3px);
color: $label-gray;
&:hover {
color: $ui-white;
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-red;
flex-shrink: 0;
}
.since {
color: $ui-white;
font-weight: 600;
}
@@ -1,39 +0,0 @@
import { useNavigate } from 'react-router';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useOpenRun } from '../../../common/hooks-query/useRuns';
import style from './RunIndicator.module.scss';
/**
* Shows that a report is being recorded for the show in progress.
*
* This is how the feature is discovered: it appears with the first event and
* points at the reports panel. Ending the run is the existing Finish action
* on the last event, so there is no separate control here.
*/
export default function RunIndicator() {
const openRun = useOpenRun();
const navigate = useNavigate();
if (!openRun) {
return null;
}
// startedAt is a wall clock instant, not a time of day, so formatTime does not apply
const since = new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' });
return (
<Tooltip text='A report is being recorded. It is saved when you finish the show.' render={<span />}>
<button
type='button'
className={style.indicator}
onClick={() => navigate('/editor?settings=sharing__report')}
aria-label='Show reports'
>
<span className={style.dot} />
Recording since <span className={style.since}>{since}</span>
</button>
</Tooltip>
);
}
@@ -141,7 +141,6 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -1,5 +1,5 @@
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, getEventVariance, isPlaybackActive, millisToString } from 'ontime-utils';
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
@@ -20,7 +20,6 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -33,7 +32,6 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -45,7 +43,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,41 +84,32 @@ 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) {
// measured against the schedule recorded when the event ran, so this
// agrees with the report panel and survives later rundown edits
const variance = getEventVariance(currentReport);
if (variance.status === 'not-run') {
return [null, 'none', ''];
}
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
const actualDuration = endedAt - startedAt;
const difference = actualDuration - duration;
const absDifference = Math.abs(difference);
if (absDifference < MILLIS_PER_SECOND) {
if (variance.status === 'ontime') {
return ['ontime', 'under', 'Event finished on time'];
}
const isOver = difference > 0;
const absDifference = Math.abs(variance.delta);
const isOver = variance.status === 'over';
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 [value, variance.status, tooltip];
}, [currentReport]);
if (!value) {
return null;