refactor(report): reduce footprint and take writes off the cue path

Cuts the risk surface of the run history feature while keeping what it
delivers.

Stability:
- Sidecar writes were happening on every event stop, and each write
  serialised the project's entire history. That put work proportional to
  everything that ever ran onto the show critical path, growing without
  bound. Writes during a run are now coalesced, with immediate writes for
  anything a user did and a flush on finish, project change and shutdown.
- The editor no longer carries any of this feature's code. The per event
  last run chip is gone, so RundownEventChip, RundownEventInner and
  useProjectRundowns are back to their previous state. Each rundown row
  had gained two extra query subscriptions, one of them polling.
- closeRun is no longer called from runtime.service, so the timer path is
  untouched. Ending a run is now explicit, which also means a mid show
  stop and restart no longer splits one show across two runs.

Discovery:
- A run indicator in the editor overview appears only while a run is
  being recorded. It shows when recording started and carries the Finish
  action, then links to the report. One component with one subscription,
  rather than anything per row.

Smaller:
- report.parser drops from exhaustive validation of a file we write
  ourselves to a shallow shape check; the failure mode is unchanged.
- getCombinedReport returns to its original shape, keeping only the
  snapshot read that report accuracy depends on.
- Removes getLatestRun and GET /runs/latest, which only existed for the
  chip, and hand rolled refetches the api layer already covers.

Production diff is down from ~1460 to ~1290 lines, and the four floating
promises the previous revision introduced are gone.

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-08 21:25:03 +00:00
parent 8551c161f1
commit faca1a1c76
22 changed files with 474 additions and 438 deletions
-1
View File
@@ -21,7 +21,6 @@ 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
+4 -15
View File
@@ -45,22 +45,11 @@ export async function fetchRun(id: string, options?: RequestOptions): Promise<Sh
}
/**
* HTTP request to fetch the most recently closed run, optionally scoped to a rundown
* @returns null if there is no closed run yet
* HTTP request to close the run in progress, writing it to history immediately
*/
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 finishRun(): Promise<void> {
await axios.post(`${reportUrl}/runs/finish`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function renameRun(id: string, label: string): Promise<ShowRun> {
@@ -26,16 +26,6 @@ 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();
+6 -15
View File
@@ -2,8 +2,8 @@ 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';
import { REPORT_RUNS } from '../api/constants';
import { fetchRun, fetchRuns } from '../api/report';
/**
* Run history for the current project, optionally scoped to a rundown
@@ -33,17 +33,8 @@ export function useRun(id: string | null) {
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 };
/** The run currently in progress, if any */
export function useOpenRun(): ShowRunSummary | null {
const { data } = useRuns();
return data.find((run) => run.endedAt === null) ?? null;
}
@@ -45,7 +45,7 @@ export default function ReportSettings() {
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} />
<RunsList runs={runs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
</Panel.Card>
{selectedRunId && (
<Panel.Card>
@@ -1,6 +1,6 @@
import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { getCombinedReport, makeReportCSV } from '../reportSettings.utils';
import { formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
@@ -39,24 +39,25 @@ describe('getCombinedReport()', () => {
});
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 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({}, rundownEntries, ['a']);
const result = getCombinedReport(report, rundownEntries, ['a', 'b']);
expect(result).toEqual([
{
id: 'a',
index: 1,
title: 'event title',
cue: '1',
scheduledStart: 0,
scheduledEnd: 10000,
actualStart: null,
actualEnd: null,
playCount: 0,
},
]);
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', () => {
@@ -73,50 +74,35 @@ describe('getCombinedReport()', () => {
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 entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 });
const rundownEntries: RundownEntries = {
a: entry,
delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null },
};
const report: OntimeReport = {
a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
};
expect(getCombinedReport({}, rundownEntries, ['delay'])).toEqual([]);
expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']);
});
});
describe('formatDrift()', () => {
it('has nothing to report when no event completed', () => {
expect(formatDrift(0, 0)).toBe('');
});
it('treats sub-second drift as on time', () => {
expect(formatDrift(500, 3)).toBe('On time');
});
it('signs the drift in both directions', () => {
expect(formatDrift(252000, 3)).toBe('+4m12s');
expect(formatDrift(-60000, 3)).toBe('-1m');
});
});
@@ -132,12 +118,10 @@ describe('makeReportCSV()', () => {
scheduledEnd: 10000,
actualStart: 0,
actualEnd: 12000,
playCount: 1,
},
]);
const rows = csv.trim().split('\n');
expect(rows).toHaveLength(2);
expect(rows[0]).toContain('Play count');
});
});
@@ -7,7 +7,6 @@ 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';
@@ -17,13 +16,11 @@ 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);
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);
@@ -33,6 +30,8 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R
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);
@@ -44,8 +43,6 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R
await renameRun(id, label);
} catch (renameError) {
setError(maybeAxiosError(renameError));
} finally {
refetch();
}
};
@@ -59,8 +56,6 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R
}
} catch (deleteError) {
setError(maybeAxiosError(deleteError));
} finally {
refetch();
}
};
@@ -68,7 +63,7 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R
preventEscape(event, () => setRenamingId(null));
if (event.key === 'Enter') {
event.preventDefault();
submitRename(id);
void submitRename(id);
}
};
@@ -1,9 +1,75 @@
import { EntryId, MaybeNumber, OntimeEventReport, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND } from 'ontime-utils';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatDuration, formatTime } from '../../../../common/utils/time';
export type CombinedReport = {
id: EntryId;
index: number;
title: string;
cue: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
};
/**
* Creates a combined report with the rundown data.
*
* 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.
*/
export function getCombinedReport(
report: OntimeReport,
rundown: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0) return [];
if (flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
let index = 1;
for (let i = 0; i < flatOrder.length; i++) {
const id = flatOrder[i];
const entry = rundown[id];
if (!entry || !isOntimeEvent(entry)) continue;
const reported = report[id];
if (!reported) {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: entry.timeStart,
actualEnd: null,
scheduledEnd: entry.timeEnd,
actualStart: null,
});
} else {
combinedReport.push({
id: id,
index: index,
title: entry.title,
cue: entry.cue,
scheduledStart: reported.scheduledStart,
actualEnd: reported.endedAt,
scheduledEnd: reported.scheduledStart + reported.scheduledDuration,
actualStart: reported.startedAt,
});
}
index++;
}
return combinedReport;
}
/**
* Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed
* events has no meaningful drift to report.
@@ -14,105 +80,7 @@ export function formatDrift(drift: number, eventsRun: number): string {
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
}
export type CombinedReport = {
id: EntryId;
index: number;
title: string;
cue: string;
scheduledStart: number;
actualStart: MaybeNumber;
scheduledEnd: number;
actualEnd: MaybeNumber;
playCount: number;
};
/**
* Creates a combined report joining a run's per event data with the rundown.
*
* A run's report keeps its own snapshot of the schedule (`scheduledStart` /
* `scheduledDuration`), so an event that ran is shown against the schedule as
* it was at the time, not against whatever the rundown has been edited to since.
*
* Events that ran but no longer exist in the rundown (deleted since the run)
* are still included, from the snapshot alone, so a historical run stays a
* complete record even after the rundown changes.
*/
export function getCombinedReport(
report: OntimeReport,
rundownEntries: RundownEntries,
flatOrder: EntryId[],
): CombinedReport[] {
if (Object.keys(report).length === 0 && flatOrder.length === 0) return [];
const combinedReport: CombinedReport[] = [];
const seen = new Set<EntryId>();
let index = 1;
for (const id of flatOrder) {
const entry = rundownEntries[id];
if (!entry || !isOntimeEvent(entry)) continue;
seen.add(id);
combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd));
index++;
}
for (const [id, reportEntry] of Object.entries(report)) {
if (seen.has(id)) continue;
combinedReport.push(
makeCombinedEntry(
id,
index,
'(deleted event)',
'',
reportEntry,
reportEntry.scheduledStart,
reportEntry.scheduledStart + reportEntry.scheduledDuration,
),
);
index++;
}
return combinedReport;
}
function makeCombinedEntry(
id: EntryId,
index: number,
title: string,
cue: string,
reportEntry: OntimeEventReport | undefined,
fallbackStart: number,
fallbackEnd: number,
): CombinedReport {
if (!reportEntry) {
return {
id,
index,
title,
cue,
scheduledStart: fallbackStart,
scheduledEnd: fallbackEnd,
actualStart: null,
actualEnd: null,
playCount: 0,
};
}
return {
id,
index,
title,
cue,
scheduledStart: reportEntry.scheduledStart,
scheduledEnd: reportEntry.scheduledStart + reportEntry.scheduledDuration,
actualStart: reportEntry.startedAt,
actualEnd: reportEntry.endedAt,
playCount: reportEntry.playCount,
};
}
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End', 'Play count'];
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
/**
* Transforms a CombinedReport into a CSV string
@@ -130,7 +98,6 @@ export function makeReportCSV(combinedReport: CombinedReport[]) {
formatTime(entry.actualStart),
formatTime(entry.scheduledEnd),
formatTime(entry.actualEnd),
String(entry.playCount),
]);
}
@@ -10,6 +10,7 @@ import {
StartTimesPlanning,
StartTimesRuntime,
} from './composite/TimeElements';
import RunIndicator from './composite/RunIndicator';
import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper';
@@ -25,6 +26,8 @@ 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>
);
}
@@ -0,0 +1,27 @@
.indicator {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: calc(1rem - 3px);
color: $label-gray;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-red;
flex-shrink: 0;
}
.since {
color: $ui-white;
font-weight: 600;
}
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { finishRun } from '../../../common/api/report';
import Button from '../../../common/components/buttons/Button';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useOpenRun } from '../../../common/hooks-query/useRuns';
import style from './RunIndicator.module.scss';
/**
* Shows that a show report is being recorded, and lets the operator close it.
*
* This is the feature's home in the editor: it appears only while a run is
* open, so it stays out of the way until it is relevant, and finishing here
* is what writes the run to history.
*/
export default function RunIndicator() {
const openRun = useOpenRun();
const [isFinishing, setIsFinishing] = useState(false);
const navigate = useNavigate();
if (!openRun) {
return null;
}
const handleFinish = async () => {
setIsFinishing(true);
try {
await finishRun();
// take the user to the report they just made, which is also how most
// people will discover that the history exists
void navigate('/editor?settings=sharing__report');
} catch (_error) {
/** the run stays open, the user can try again */
} finally {
setIsFinishing(false);
}
};
return (
<div className={style.indicator}>
<Tooltip text='A report is being recorded for this show' render={<span />}>
<span className={style.label}>
<span className={style.dot} />
{/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */}
Recording since{' '}
<span className={style.since}>
{new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })}
</span>
</span>
</Tooltip>
<Button size='small' variant='subtle' onClick={handleFinish} disabled={isFinishing}>
Finish run
</Button>
</div>
);
}
@@ -141,6 +141,7 @@ function RundownEventInner({
isPast={isPast}
isLoaded={loaded}
totalGap={totalGap}
duration={duration}
/>
)}
<div className={style.statusElements} id='entry-status' data-timertype={timerType}>
@@ -16,10 +16,4 @@
&.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,19 +1,10 @@
import { Day } from 'ontime-types';
import {
EventVariance,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
getEventVariance,
isPlaybackActive,
millisToString,
} from 'ontime-utils';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, 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';
@@ -29,6 +20,7 @@ interface RundownEventChipProps {
isLoaded: boolean;
className: string;
totalGap: number;
duration: number;
isLinkedToLoaded: boolean;
}
@@ -41,6 +33,7 @@ export default function RundownEventChip({
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const playback = usePlayback();
@@ -52,7 +45,7 @@ export default function RundownEventChip({
const playbackActive = isPlaybackActive(playback);
if (!playbackActive || isPast) {
return <EventReport className={className} id={id} />;
return <EventReport className={className} id={id} duration={duration} />;
}
if (playbackActive) {
@@ -93,60 +86,49 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
interface EventReportProps {
className: string;
id: string;
duration: number;
}
function EventReport(props: EventReportProps) {
const { className, id } = props;
const { className, id, duration } = props;
const { data } = useReport();
const currentReport = data[id];
// 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 [value, overUnderStyle, tooltip] = useMemo(() => {
if (!currentReport) {
return [null, 'none', ''];
}
const lastRunVariance = getEventVariance(lastRun?.report[id]);
if (lastRunVariance.status !== 'not-run') {
return describeVariance(lastRunVariance, true);
const { startedAt, endedAt } = currentReport;
if (!startedAt || !endedAt) {
return [null, 'none', ''];
}
return [null, 'none', ''];
}, [currentReport, id, lastRun]);
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]);
if (!value) {
return null;
}
return (
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[chipStyle], className])}>
<Tooltip text={tooltip} render={<span />} className={cx([style.chip, style[overUnderStyle], 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];
}