mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 01:43:43 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05cf7c7c56 | |||
| a851414d13 | |||
| b21385ec4a | |||
| faca1a1c76 | |||
| 8551c161f1 | |||
| 3c2875a956 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -176,13 +176,6 @@ export async function postCloneEntry(
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request events duration to fit inside the group target
|
||||
*/
|
||||
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
requestFitGroupTarget,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
@@ -467,27 +466,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates time of existing event so it satisfies the group target duration
|
||||
* @param eventId {EntryId} - id of the event
|
||||
*/
|
||||
const matchGroupDuration = useCallback(
|
||||
async (eventId: EntryId) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
try {
|
||||
await requestFitGroupTarget(rundownId, eventId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1030,7 +1009,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
}),
|
||||
[
|
||||
addEntry,
|
||||
@@ -1048,7 +1026,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,7 +200,8 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -20,12 +18,7 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
title={`Ontime ${appVersion}`}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -33,7 +26,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
@@ -5,3 +5,28 @@ th.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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { countPlannedEvents, getRunSummary } from 'ontime-utils';
|
||||
import { useMemo } from 'react';
|
||||
import { IoTrashBin } from 'react-icons/io5';
|
||||
import { IoDownloadOutline, IoTrashBin } from 'react-icons/io5';
|
||||
|
||||
import { deleteAllReport } from '../../../../common/api/report';
|
||||
import { createBlob, downloadBlob } from '../../../../common/api/utils';
|
||||
@@ -7,9 +8,9 @@ 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 { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { CombinedReport, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
import { CombinedReport, formatDrift, getCombinedReport, makeReportCSV } from './reportSettings.utils';
|
||||
|
||||
import style from './ReportSettings.module.scss';
|
||||
|
||||
@@ -31,6 +32,10 @@ export default function ReportSettings() {
|
||||
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>
|
||||
@@ -41,7 +46,7 @@ export default function ReportSettings() {
|
||||
Manage report
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => downloadCSV(combinedReport)} disabled={combinedReport.length === 0}>
|
||||
<IoTrashBin />
|
||||
<IoDownloadOutline />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant='subtle-destructive' onClick={clearReport} disabled={combinedReport.length === 0}>
|
||||
@@ -51,6 +56,19 @@ export default function ReportSettings() {
|
||||
</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>
|
||||
@@ -102,3 +120,12 @@ export default function ReportSettings() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeReport,
|
||||
RundownEntries,
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { formatDrift, 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 nothing has run', () => {
|
||||
expect(getCombinedReport({}, {}, [])).toEqual([]);
|
||||
});
|
||||
|
||||
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, { a: entry }, ['a']);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
scheduledStart: 0,
|
||||
scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999
|
||||
actualStart: 100,
|
||||
actualEnd: 10100,
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
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(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');
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(csv.trim().split('\n')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { EntryId, MaybeNumber, 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 { enDash } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
|
||||
export type CombinedReport = {
|
||||
id: EntryId;
|
||||
@@ -15,7 +17,12 @@ export type CombinedReport = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a combined report with the rundown data
|
||||
* 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 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,
|
||||
@@ -33,7 +40,9 @@ export function getCombinedReport(
|
||||
const entry = rundown[id];
|
||||
if (!entry || !isOntimeEvent(entry)) continue;
|
||||
|
||||
if (!(id in report)) {
|
||||
const reported = report[id];
|
||||
|
||||
if (!reported) {
|
||||
combinedReport.push({
|
||||
id: id,
|
||||
index: index,
|
||||
@@ -44,18 +53,16 @@ export function getCombinedReport(
|
||||
scheduledEnd: entry.timeEnd,
|
||||
actualStart: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (id in report) {
|
||||
} else {
|
||||
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,
|
||||
scheduledStart: reported.scheduledStart,
|
||||
actualEnd: reported.endedAt,
|
||||
scheduledEnd: reported.scheduledStart + reported.scheduledDuration,
|
||||
actualStart: reported.startedAt,
|
||||
});
|
||||
}
|
||||
index++;
|
||||
@@ -64,6 +71,16 @@ export function getCombinedReport(
|
||||
return combinedReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 enDash;
|
||||
if (Math.abs(drift) < MILLIS_PER_SECOND) return 'On time';
|
||||
return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`;
|
||||
}
|
||||
|
||||
const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<div>
|
||||
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
|
||||
<TextLikeInput
|
||||
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
|
||||
offset={planOffsetLabel}
|
||||
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
||||
disabled
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
import { MouseEvent, useEffect, useRef } from 'react';
|
||||
import {
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
@@ -103,10 +102,7 @@ export default function RundownEvent({
|
||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||
|
||||
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
|
||||
useEntryActionsContext();
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||
|
||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||
const unselect = useEventSelection((state) => state.unselect);
|
||||
@@ -118,15 +114,6 @@ export default function RundownEvent({
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
|
||||
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
|
||||
return [false, ''];
|
||||
const { targetDuration, duration } = parentGroup;
|
||||
return targetDuration > duration
|
||||
? [true, 'Increase event duration to fit the group target']
|
||||
: [true, 'Decrease event duration to fit the group target'];
|
||||
})();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
|
||||
selectedEvents.size > 1
|
||||
? [
|
||||
@@ -185,17 +172,6 @@ export default function RundownEvent({
|
||||
updateEntry({ id: eventId, flag: !flag });
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Group Target Duration',
|
||||
description: groupTargetDurationDescription,
|
||||
icon: TbClockPin,
|
||||
onClick: () => {
|
||||
if (!parent) return;
|
||||
matchGroupDuration(eventId);
|
||||
},
|
||||
disabled: !enableMatchDuration,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,36 +74,42 @@
|
||||
.metaLabel {
|
||||
color: $muted-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.strike {
|
||||
text-decoration: wavy underline;
|
||||
margin-right: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: $ui-white;
|
||||
|
||||
&.warning {
|
||||
.strike {
|
||||
// color: $playback-over;
|
||||
text-decoration: wavy underline;
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
color: $muted-gray;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.target {
|
||||
display: contents;
|
||||
.over {
|
||||
color: $playback-over;
|
||||
.strike {
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
.under {
|
||||
color: $playback-under;
|
||||
.strike {
|
||||
text-decoration-color: $playback-under;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-under;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
|
||||
@@ -2,25 +2,24 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import { MouseEvent, useRef } from 'react';
|
||||
import {
|
||||
IoChevronDown,
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoLockClosed,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
IoLockClosed,
|
||||
} from 'react-icons/io5';
|
||||
import { TbClockPin } from 'react-icons/tb';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
@@ -41,31 +40,12 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
'use memo';
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
|
||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
||||
|
||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||
|
||||
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
|
||||
|
||||
const [planOffset, offset] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 0];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
|
||||
})();
|
||||
|
||||
const matchDuration = useCallback(() => {
|
||||
updateEntry({ id: data.id, targetDuration: data.duration });
|
||||
}, [data.duration, data.id, updateEntry]);
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||
{
|
||||
type: 'item',
|
||||
@@ -82,18 +62,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
disabled: data.entries.length === 0,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Content Duration',
|
||||
icon: TbClockPin,
|
||||
onClick: matchDuration,
|
||||
disabled: isDurationMatching,
|
||||
description:
|
||||
offset > 0
|
||||
? "Increase group target duration to match it's contents"
|
||||
: "Decrease group target duration to match it's contents",
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
@@ -137,6 +105,22 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const [planOffset, planOffsetLabel] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 'under'];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [
|
||||
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
|
||||
getOffsetState(offset),
|
||||
];
|
||||
})();
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -191,18 +175,20 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.metaLabel}>End</div>
|
||||
<div>{formatTime(data.timeEnd)}</div>
|
||||
</div>
|
||||
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>
|
||||
Duration
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
<div className={cx([style.duration, planOffset && style.warning])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>Duration</div>
|
||||
<div className={style.duration}>
|
||||
{planOffset === null ? (
|
||||
formatDuration(data.duration)
|
||||
) : (
|
||||
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,18 +202,6 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import type { PlayableEvent } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { clear, generate, triggerReportEntry } from '../report.service.js';
|
||||
|
||||
vi.mock('../../../adapters/WebsocketAdapter.js', () => ({
|
||||
sendRefetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const eventA = makeOntimeEvent({ id: 'event-a', timeStart: 0, timeEnd: 10000, duration: 10000 }) as PlayableEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
describe('triggerReportEntry()', () => {
|
||||
it('snapshots the schedule when an event starts', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 500 }, clock: 500 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
|
||||
expect(generate()[eventA.id]).toEqual({
|
||||
startedAt: 500,
|
||||
endedAt: null,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
playCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the snapshot taken at start when the event stops', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 12000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: 0,
|
||||
endedAt: 12000,
|
||||
scheduledStart: eventA.timeStart,
|
||||
scheduledDuration: eventA.duration,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the schedule as it was, not as it later becomes', () => {
|
||||
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, start);
|
||||
|
||||
// the event is edited to a different duration, then stopped
|
||||
const edited = { ...eventA, duration: 99999, timeEnd: 99999 } as PlayableEvent;
|
||||
const stop = makeRuntimeStateData({ eventNow: edited, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id].scheduledDuration).toBe(10000);
|
||||
});
|
||||
|
||||
it('counts a re-run rather than losing the previous one', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 5000 } as typeof state);
|
||||
triggerReportEntry(TimerLifeCycle.onStart, { ...state, clock: 5000 } as typeof state);
|
||||
|
||||
expect(generate()[eventA.id].playCount).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to the current event when a stop arrives with no start', () => {
|
||||
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
|
||||
triggerReportEntry(TimerLifeCycle.onStop, stop);
|
||||
|
||||
expect(generate()[eventA.id]).toMatchObject({
|
||||
startedAt: null,
|
||||
endedAt: 10000,
|
||||
scheduledDuration: eventA.duration,
|
||||
playCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores events without an id', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: null });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('clears a single event', () => {
|
||||
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
|
||||
triggerReportEntry(TimerLifeCycle.onStart, state);
|
||||
clear(eventA.id);
|
||||
expect(generate()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -49,14 +49,31 @@ export function triggerReportEntry(
|
||||
const eventId = state.eventNow.id;
|
||||
|
||||
if (cycle === TimerLifeCycle.onStart) {
|
||||
report.set(eventId, { startedAt: state.timer.startedAt, endedAt: null });
|
||||
// an event started twice is a re-run, not a new record
|
||||
const playCount = (report.get(eventId)?.playCount ?? 0) + 1;
|
||||
|
||||
report.set(eventId, {
|
||||
startedAt: state.timer.startedAt,
|
||||
endedAt: null,
|
||||
// snapshot the schedule so later rundown edits cannot change how a show
|
||||
// that already happened is reported
|
||||
scheduledStart: state.eventNow.timeStart,
|
||||
scheduledDuration: state.eventNow.duration,
|
||||
playCount,
|
||||
});
|
||||
formattedReport = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
const startedAt = report.get(eventId)?.startedAt ?? null;
|
||||
report.set(eventId, { startedAt, endedAt: state.clock });
|
||||
const previous = report.get(eventId);
|
||||
report.set(eventId, {
|
||||
startedAt: previous?.startedAt ?? null,
|
||||
endedAt: state.clock,
|
||||
scheduledStart: previous?.scheduledStart ?? state.eventNow.timeStart,
|
||||
scheduledDuration: previous?.scheduledDuration ?? state.eventNow.duration,
|
||||
playCount: previous?.playCount ?? 1,
|
||||
});
|
||||
formattedReport = null;
|
||||
sendRefetch(RefetchKey.Report);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
makeDeepClone,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
@@ -611,107 +610,3 @@ describe('isLoadedPlayable()', () => {
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDurationMatchGroupTarget()', () => {
|
||||
it('returns unchanged duration when group already matches target', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('increases event duration when group is shorter than target', () => {
|
||||
// Group is 1h short of target, so event duration increases by 1h
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
||||
groupDuration: MILLIS_PER_HOUR, // 1h
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
||||
});
|
||||
|
||||
it('decreases event duration when group is longer than target', () => {
|
||||
// Group is 30m over target, so event duration decreases by 30m
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR, // 1h
|
||||
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero target duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero group duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: 0,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles zero event duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_MINUTE * 30,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles all zero values', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: 0,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when result would be negative', () => {
|
||||
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_MINUTE * 30,
|
||||
groupDuration: MILLIS_PER_HOUR * 2,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('handles large durations', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
||||
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
||||
eventDuration: MILLIS_PER_HOUR, // 1h
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
||||
});
|
||||
|
||||
it('returns null when targetDuration is null', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: null,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when duration would be over 24h', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 30 * MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
entryFitGroupDuration,
|
||||
} from './rundown.service.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
@@ -338,23 +337,6 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/:id/fit-group-duration',
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
hasChanges,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from './rundown.utils.js';
|
||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||
|
||||
@@ -448,69 +447,6 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
const entry = rundown.entries[entryId];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (!isOntimeEvent(entry)) {
|
||||
throw new Error('Entry must be an event');
|
||||
}
|
||||
|
||||
const { parent } = entry;
|
||||
if (!parent) {
|
||||
throw new Error('Entry must be in a group');
|
||||
}
|
||||
|
||||
const group = rundown.entries[parent];
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
if (!isOntimeGroup(group)) {
|
||||
throw new Error('Group is not a group');
|
||||
}
|
||||
|
||||
const newDuration = eventDurationMatchGroupTarget({
|
||||
targetDuration: group.targetDuration,
|
||||
groupDuration: group.duration,
|
||||
eventDuration: entry.duration,
|
||||
});
|
||||
|
||||
if (newDuration === null) {
|
||||
throw new Error('Unable to fit a duration');
|
||||
}
|
||||
|
||||
const newEnd = entry.timeStart + newDuration;
|
||||
|
||||
rundownMutation.edit(rundown, {
|
||||
id: entryId,
|
||||
duration: newDuration,
|
||||
timeEnd: newEnd,
|
||||
timeStrategy: entry.timeStrategy,
|
||||
});
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be changing a running event
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a list of entries into a new group
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
EntryCustomFields,
|
||||
EntryId,
|
||||
ImportedFields,
|
||||
Maybe,
|
||||
OntimeBaseEvent,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
generateId,
|
||||
getCueCandidate,
|
||||
makeString,
|
||||
maxDuration,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
@@ -603,27 +601,3 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
|
||||
precision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts an event's duration to fit inside the group target
|
||||
* @param targetDuration - The desired total duration for the group, or null
|
||||
* @param groupDuration - The current total duration of all events in the group
|
||||
* @param eventDuration - The current duration of the event being adjusted
|
||||
* @returns The adjusted event duration, or null if targetDuration is null or
|
||||
* the result would be negative
|
||||
*/
|
||||
export function eventDurationMatchGroupTarget({
|
||||
targetDuration,
|
||||
groupDuration,
|
||||
eventDuration,
|
||||
}: {
|
||||
targetDuration: Maybe<number>;
|
||||
groupDuration: number;
|
||||
eventDuration: number;
|
||||
}): Maybe<number> {
|
||||
if (targetDuration === null) return null;
|
||||
if (targetDuration === groupDuration) return null;
|
||||
const durationDiff = targetDuration - groupDuration;
|
||||
const newDuration = eventDuration + durationDiff;
|
||||
return newDuration < 0 || newDuration > maxDuration ? null : newDuration;
|
||||
}
|
||||
|
||||
@@ -71,15 +71,11 @@ test('Move', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
// create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
||||
|
||||
// copy move down
|
||||
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
||||
@@ -90,16 +86,15 @@ test('Move', async ({ page }) => {
|
||||
.press('Alt+Control+ArrowDown');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
|
||||
|
||||
// move entry three up twice, waiting for each reorder before targeting its new row
|
||||
// copy move up
|
||||
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
|
||||
await page
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
|
||||
await page
|
||||
.getByTestId('entry-2')
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
|
||||
@@ -13,27 +13,23 @@ test('time until absolute', async ({ context }) => {
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||
const entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
const ids = new Array<string>();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
|
||||
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
|
||||
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
|
||||
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
|
||||
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
|
||||
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
|
||||
|
||||
// Create reusable locator references for different elements
|
||||
const entry2 = {
|
||||
@@ -61,10 +57,8 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
await editor.getByRole('button', { name: 'Absolute' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
await editor.getByLabel('Pause event').click();
|
||||
|
||||
// 1. initial check
|
||||
await expect(entry2.editorEvent).toContainText('9m');
|
||||
@@ -135,29 +129,22 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
test('time until relative', async ({ context }) => {
|
||||
const editor = await context.newPage();
|
||||
await editor.goto('/editor');
|
||||
editor.goto('/editor');
|
||||
|
||||
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByRole('button', { name: 'Relative' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
await editor.getByLabel('Pause event').click();
|
||||
|
||||
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
|
||||
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
import type { EntryId } from './OntimeEntry.js';
|
||||
|
||||
export type OntimeEventReport = {
|
||||
startedAt: MaybeNumber;
|
||||
endedAt: MaybeNumber;
|
||||
/**
|
||||
* Snapshot of the schedule taken when the event ran.
|
||||
* Keeping a copy is what makes a report a record: editing the rundown
|
||||
* afterwards no longer changes how a show that already happened is reported.
|
||||
*/
|
||||
scheduledStart: number;
|
||||
scheduledDuration: number;
|
||||
/** how many times the event was started, >1 means it was re-run */
|
||||
playCount: number;
|
||||
};
|
||||
|
||||
export type OntimeReport = Record<string, OntimeEventReport>;
|
||||
export type OntimeReport = Record<EntryId, OntimeEventReport>;
|
||||
|
||||
/** Headline numbers for everything in the current report */
|
||||
export type RunSummary = {
|
||||
/** events which produced a report entry */
|
||||
eventsRun: number;
|
||||
/** playable events in the rundown */
|
||||
eventsPlanned: number;
|
||||
scheduledDuration: number;
|
||||
actualDuration: number;
|
||||
/** actualDuration - scheduledDuration, signed */
|
||||
drift: number;
|
||||
eventsOver: number;
|
||||
eventsUnder: number;
|
||||
eventsOnTime: number;
|
||||
/** largest single overrun, answers "what blew the schedule" */
|
||||
worstOverrun: { id: EntryId; delta: number } | null;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export { TimerType } from './definitions/TimerType.type.js';
|
||||
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
|
||||
|
||||
// ---> Report
|
||||
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
||||
export type { OntimeReport, OntimeEventReport, RunSummary } from './definitions/core/Report.type.js';
|
||||
|
||||
// ---> Automations
|
||||
export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
||||
|
||||
@@ -99,6 +99,15 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// feature business logic - reports
|
||||
export {
|
||||
countPlannedEvents,
|
||||
getEventVariance,
|
||||
getRunSummary,
|
||||
type EventVariance,
|
||||
type VarianceStatus,
|
||||
} from './src/report-utils/reportUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { OntimeEventReport, OntimeReport } from 'ontime-types';
|
||||
|
||||
import { getEventVariance, getRunSummary } from './reportUtils.js';
|
||||
|
||||
function makeEntry(patch: Partial<OntimeEventReport> = {}): OntimeEventReport {
|
||||
return {
|
||||
startedAt: 0,
|
||||
endedAt: 10000,
|
||||
scheduledStart: 0,
|
||||
scheduledDuration: 10000,
|
||||
playCount: 1,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getEventVariance()', () => {
|
||||
it('reports an event which never ran', () => {
|
||||
expect(getEventVariance(undefined)).toMatchObject({ status: 'not-run', actualDuration: null, delta: 0 });
|
||||
});
|
||||
|
||||
it('reports an event which started but never finished', () => {
|
||||
const entry = makeEntry({ startedAt: 1000, endedAt: null });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run', actualDuration: null });
|
||||
});
|
||||
|
||||
it('reports an event which never started', () => {
|
||||
const entry = makeEntry({ startedAt: null, endedAt: 1000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'not-run' });
|
||||
});
|
||||
|
||||
it('reports an event which matched its schedule', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', actualDuration: 10000, delta: 0 });
|
||||
});
|
||||
|
||||
it('treats sub-second differences as on time', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 10500, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'ontime', delta: 500 });
|
||||
});
|
||||
|
||||
it('reports an overrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'over', actualDuration: 15000, delta: 5000 });
|
||||
});
|
||||
|
||||
it('reports an underrun', () => {
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 6000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry)).toMatchObject({ status: 'under', actualDuration: 6000, delta: -4000 });
|
||||
});
|
||||
|
||||
it('measures against the snapshot, not the current rundown', () => {
|
||||
// the rundown may have been edited after the run, the snapshot is what counts
|
||||
const entry = makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 });
|
||||
expect(getEventVariance(entry).delta).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunSummary()', () => {
|
||||
it('returns an empty summary for an empty report', () => {
|
||||
expect(getRunSummary({}, 0)).toMatchObject({
|
||||
eventsRun: 0,
|
||||
eventsPlanned: 0,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
worstOverrun: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates durations and drift across a run', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 15000, endedAt: 21000, scheduledDuration: 10000 }), // -4000
|
||||
c: makeEntry({ startedAt: 21000, endedAt: 31000, scheduledDuration: 10000 }), // 0
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 4)).toMatchObject({
|
||||
eventsRun: 3,
|
||||
eventsPlanned: 4,
|
||||
scheduledDuration: 30000,
|
||||
actualDuration: 31000,
|
||||
drift: 1000,
|
||||
eventsOver: 1,
|
||||
eventsUnder: 1,
|
||||
eventsOnTime: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies the worst overrun', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }), // +5000
|
||||
b: makeEntry({ startedAt: 0, endedAt: 30000, scheduledDuration: 10000 }), // +20000
|
||||
c: makeEntry({ startedAt: 0, endedAt: 12000, scheduledDuration: 10000 }), // +2000
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 3).worstOverrun).toEqual({ id: 'b', delta: 20000 });
|
||||
});
|
||||
|
||||
it('ignores events which did not complete', () => {
|
||||
const report: OntimeReport = {
|
||||
a: makeEntry({ startedAt: 0, endedAt: 15000, scheduledDuration: 10000 }),
|
||||
b: makeEntry({ startedAt: 15000, endedAt: null, scheduledDuration: 10000 }),
|
||||
};
|
||||
|
||||
expect(getRunSummary(report, 2)).toMatchObject({
|
||||
eventsRun: 1,
|
||||
scheduledDuration: 10000,
|
||||
actualDuration: 15000,
|
||||
drift: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { EntryId, OntimeEventReport, OntimeReport, RundownEntries, RunSummary } from 'ontime-types';
|
||||
import { isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import { MILLIS_PER_SECOND } from '../date-utils/conversionUtils.js';
|
||||
|
||||
export type VarianceStatus = 'ontime' | 'over' | 'under' | 'not-run';
|
||||
|
||||
export type EventVariance = {
|
||||
/** how long the event actually took, null if it never completed */
|
||||
actualDuration: number | null;
|
||||
/** actualDuration - scheduledDuration, signed. 0 when the event did not complete */
|
||||
delta: number;
|
||||
status: VarianceStatus;
|
||||
};
|
||||
|
||||
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
|
||||
|
||||
/**
|
||||
* Calculates how an event performed against its schedule.
|
||||
* An event is considered on time if it is within a second of its scheduled duration.
|
||||
*/
|
||||
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
|
||||
if (!entry) {
|
||||
return notRun;
|
||||
}
|
||||
|
||||
const { startedAt, endedAt, scheduledDuration } = entry;
|
||||
if (startedAt === null || endedAt === null) {
|
||||
return notRun;
|
||||
}
|
||||
|
||||
const actualDuration = endedAt - startedAt;
|
||||
const delta = actualDuration - scheduledDuration;
|
||||
|
||||
if (Math.abs(delta) < MILLIS_PER_SECOND) {
|
||||
return { actualDuration, delta, status: 'ontime' };
|
||||
}
|
||||
|
||||
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates a run's per event data into the headline numbers for a show.
|
||||
* @param report the run's per event data
|
||||
* @param eventsPlanned how many playable events the rundown held when the run was made
|
||||
*/
|
||||
export function getRunSummary(report: OntimeReport, eventsPlanned: number): RunSummary {
|
||||
const summary: RunSummary = {
|
||||
eventsRun: 0,
|
||||
eventsPlanned,
|
||||
scheduledDuration: 0,
|
||||
actualDuration: 0,
|
||||
drift: 0,
|
||||
eventsOver: 0,
|
||||
eventsUnder: 0,
|
||||
eventsOnTime: 0,
|
||||
worstOverrun: null,
|
||||
};
|
||||
|
||||
for (const [id, entry] of Object.entries(report)) {
|
||||
const variance = getEventVariance(entry);
|
||||
if (variance.status === 'not-run') {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.eventsRun += 1;
|
||||
summary.scheduledDuration += entry.scheduledDuration;
|
||||
summary.actualDuration += variance.actualDuration as number;
|
||||
|
||||
if (variance.status === 'over') {
|
||||
summary.eventsOver += 1;
|
||||
if (summary.worstOverrun === null || variance.delta > summary.worstOverrun.delta) {
|
||||
summary.worstOverrun = { id, delta: variance.delta };
|
||||
}
|
||||
} else if (variance.status === 'under') {
|
||||
summary.eventsUnder += 1;
|
||||
} else {
|
||||
summary.eventsOnTime += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary.drift = summary.actualDuration - summary.scheduledDuration;
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the events a run could have played.
|
||||
* Skipped events are excluded: they were never meant to run and would
|
||||
* make the completion figures read as if the show fell short.
|
||||
*/
|
||||
export function countPlannedEvents(entries: RundownEntries, order: EntryId[]): number {
|
||||
let count = 0;
|
||||
for (const id of order) {
|
||||
const entry = entries[id];
|
||||
if (entry && isOntimeEvent(entry) && !entry.skip) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user