mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
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:
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
+34
-31
@@ -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';
|
||||
}
|
||||
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user