Compare commits

...

8 Commits

Author SHA1 Message Date
Claude fec1651e39 fix(timer): address review findings on the group timer
- keep the pending and none phases when showing the group timer. These describe the
  playback state rather than a threshold, so collapsing them made roll standby render
  as a running countdown and silently dropped user styling keyed on the phase
- only show the group indicator for timer types which render the running timer. It was
  labelling the wall clock, and the invisible display of the none timer type, as a group
- accept useGroupTimer in the MCP group patch. It was documented in the group shape but
  dropped on write, which reported success without applying the change
- narrow the timer preview tooltip, which claimed all views follow the group timer when
  only the timer views do

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-08-07 19:32:16 +00:00
Claude 1fad99f505 refactor(runtime): derive the group timer on the server
The group timer was derived in the client, which meant every view that wanted it
had to depend on the rundown query. That coupling was already awkward for the PiP
timer, whose separate react root needed its own query client, and it would have
spread to every remaining view.

Moving the derivation to the server removes the coupling. The value is still the
running event timer offset by the content scheduled around it, so the group keeps
behaving as if it were a single event containing all its children.

Because the group timer is the event timer offset by a constant, it changes exactly
when the event timer does and can share its broadcast throttling.

- add RuntimeStore.groupTimer, null unless the running group opted in
- split the group around the loaded event when the group is loaded, so the per tick
  cost is an addition rather than a walk of the rundown
- derive on the getState() projection so it cannot drift from the timer it is built on
- reduce the client to a plain selector, dropping the PiP query client workaround

Also fixes elapsed time, which was calculated from the group duration and so clamped
to zero for as long as time added to an event kept the group in credit. It is now
derived symmetrically with the remaining time, and the two always add up to the total.

Timer and PiP shared eight identical branches for choosing between the two timers.
These now go through a single resolver. A group has no warning or danger thresholds,
so it only reports as running or overtime, and feeding that phase through the existing
modifiers makes the suppression of warning and danger a consequence of what a group is
rather than something each view has to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 15:46:58 +00:00
Claude 636b2e7c3e feat(rundown): add opt-in shared group timer
Groups can now opt into a shared timer, so stage outputs show how much time
the whole block has left instead of only the running event.

The value is derived in the client from the running event timer plus the
content still scheduled after it, which makes the group behave as if it were
a single event containing all its children. Keeping it relative to the event
timer (rather than recalculating from the clock) means pause, added time,
overtime, roll and midnight rollovers are all inherited, and moving between
events inside a group cannot introduce a discontinuity.

The remainder aggregation mirrors the server side group duration calculation:
non playable entries are skipped and gaps are accounted for.

Warning and danger thresholds belong to the event and carry no meaning against
a group duration, so they are suppressed while the group timer is showing.
Overtime is kept, but only once the group itself has run out of time.

- add OntimeGroup.useGroupTimer, defaulting to false
- show the group timer in the timer view and the PiP timer
- indicate the override in the timer view, PiP, group header and timer preview
- add a switch to the group editor

The PiP window mounts its own react root, which had no query client and could
therefore not read the rundown. It is now wrapped in a provider, which also
means its view settings are no longer frozen at the moment the window opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 06:24:15 +00:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
Carlos Valente a8c611911d refactor(rundown): new entries are appended to rundown 2026-07-22 07:17:39 +02:00
Carlos Valente 8363f06a5c chore(rundown): improve documentation of insert logic 2026-07-22 07:17:39 +02:00
63 changed files with 1830 additions and 341 deletions
@@ -20,7 +20,7 @@
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
max-width: min(1800px, 98vw);
height: 88vh;
max-height: 88vh;
display: flex;
+13 -7
View File
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -60,9 +59,15 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -120,7 +125,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -149,8 +154,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -193,7 +199,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
@@ -205,8 +211,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -284,7 +290,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => {
async (entryId: EntryId, options?: ClientInsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -1057,7 +1063,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
@@ -154,6 +154,9 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
/** Shared timer for the running group, null unless the group opted in */
export const useGroupTimer = createSelector((state: RuntimeStore) => state.groupTimer);
export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null,
expectedStart: state.offset.expectedFlagStart,
+3 -4
View File
@@ -5,6 +5,7 @@ import {
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -186,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
expectedEnd,
plannedEnd,
};
}
@@ -25,6 +25,13 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -8,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
handleFinished(title);
return;
}
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
customFields: preview.customFields,
providedFields,
});
handleFinished();
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div>
)}
{isGSheetFlow && (
@@ -92,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -1,27 +1,35 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +42,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: $white-1;
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
}
@@ -1,7 +1,11 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +99,57 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
</div>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr>
);
})}
@@ -5,7 +5,8 @@ import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -32,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -40,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -221,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -297,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})();
return {
@@ -1,11 +1,11 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { IoArrowDown, IoArrowUp, IoBan, IoFolderOutline, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { useGroupTimer, useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
@@ -22,6 +22,7 @@ const secondarySourceLabels: Record<string, string> = {
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings();
const isGroupTimerActive = useGroupTimer() !== null;
const main = (() => {
if (showTimerMessage) return 'Message';
@@ -105,6 +106,14 @@ export default function TimerPreview() {
>
<LuArrowDownToLine />
</Tooltip>
<Tooltip
text={`Timer views are showing the ${isGroupTimerActive ? 'shared group timer' : 'event timer'}`}
render={<span />}
className={style.statusIcon}
data-active={isGroupTimerActive}
>
<IoFolderOutline />
</Tooltip>
</div>
</div>
);
+3 -1
View File
@@ -339,7 +339,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }}
components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
Header: isEditMode
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => (
<>
{isEditMode && (
@@ -5,6 +5,8 @@ import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset';
@@ -95,6 +97,22 @@ export default function GroupEditor({ group }: GroupEditorProps) {
submitHandler={handleSubmit}
/>
</div>
<div>
<Tooltip
text='Timer views count the scheduled duration of the whole group instead of the running event'
render={<Editor.Label htmlFor='useGroupTimer' />}
>
Group timer
</Tooltip>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(value) => updateEntry({ id: group.id, useGroupTimer: value })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
@@ -11,13 +11,17 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps {
previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString;
backgroundColor?: string;
}
export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => {
addEntry(
@@ -25,44 +29,23 @@ function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: Quic
type: SupportedEntry.Event,
parent: parentGroup,
},
{
after: previousEventId,
lastEventId: previousEventId,
},
insertionOptions,
);
};
const addDelay = () => {
addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
};
const addMilestone = () => {
addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
};
const addGroup = () => {
if (parentGroup !== null) {
return;
}
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
addEntry({ type: SupportedEntry.Group }, insertionOptions);
};
/**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
lastEventId: id && !above ? id : undefined,
});
},
[addEntry],
@@ -15,6 +15,7 @@ import {
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';
@@ -189,6 +190,13 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
{data.useGroupTimer && (
<div className={style.metaEntry}>
<Tooltip text='Views show a shared timer for this group instead of the event timer'>
<Tag>Group timer</Tag>
</Tooltip>
</div>
)}
</div>
</div>
</div>
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -164,7 +164,6 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,17 +1,15 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -38,8 +36,10 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
event: CountdownEvent;
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -271,6 +271,7 @@ export function extendEventData(
offset,
mode,
});
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
return { ...event, expectedStart, endedAt, expectedEnd };
}
@@ -68,6 +68,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 2.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, $ui-white);
}
}
.secondary {
@@ -2,7 +2,7 @@ import { ViewSettings } from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimerSocket } from '../../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../../common/hooks/useSocket';
import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import {
@@ -10,9 +10,8 @@ import {
getIsPlaying,
getSecondaryDisplay,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
getShowsTimerValue,
resolveTimerDisplay,
} from '../../timer/timer.utils';
import { getTimerColour } from '../../utils/presentation.utils';
@@ -24,23 +23,28 @@ interface PipTimerProps {
export function PipTimer({ viewSettings }: PipTimerProps) {
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const groupTimer = useGroupTimer();
// gather modifiers
const showOverlay = getShowMessage(message.timer);
const { showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
false,
'',
false,
);
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
const { showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showProgressBar = getShowProgressBar(timerTypeNow);
const showsTimerValue = getShowsTimerValue(timerTypeNow);
const showProgressBar = showsTimerValue;
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const stageTimer = getTimerByType(false, timerTypeNow, clock, timerDisplay.source, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
@@ -80,10 +84,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
{timerDisplay.isGroup && showsTimerValue && <div className='group-indicator'>group</div>}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
@@ -94,12 +99,12 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
+12
View File
@@ -131,6 +131,18 @@
opacity: 0;
}
}
// signals that the displayed timer belongs to the group, not to the running event
.group-indicator {
text-align: center;
text-transform: uppercase;
letter-spacing: 0.25em;
font-weight: 600;
font-size: 1.5vw;
line-height: 1;
opacity: 0.7;
color: var(--timer-colour, var(--timer-color-override, $ui-white));
}
}
.secondary {
+24 -17
View File
@@ -8,7 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useGroupTimer, useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
@@ -25,9 +25,8 @@ import {
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTotalTime,
getShowsTimerValue,
resolveTimerDisplay,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
@@ -72,23 +71,29 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
timeformat,
} = useTimerOptions();
const groupTimer = useGroupTimer();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
timerTypeNow,
countToEndNow,
time.phase,
const timerDisplay = resolveTimerDisplay({
time,
groupTimer,
event: eventNow,
timerType: timerTypeNow,
countToEnd: countToEndNow,
freezeOvertime,
freezeMessage,
hidePhase,
);
});
const { showEndMessage, showFinished, showWarning, showDanger } = timerDisplay;
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
const showsTimerValue = getShowsTimerValue(viewTimerType);
const showProgressBar = !hideProgress && showsTimerValue;
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
@@ -102,8 +107,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, timerDisplay.source, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
@@ -178,11 +182,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-type={viewTimerType}
data-phase={time.phase}
data-phase={timerDisplay.phase}
>
{display}
</div>
)}
{timerDisplay.isGroup && showsTimerValue && !showEndMessage && (
<div className='group-indicator'>group</div>
)}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondaryContent}
@@ -193,12 +200,12 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
now={timerDisplay.source.current}
complete={timerDisplay.total}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warning={timerDisplay.warning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
danger={timerDisplay.danger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
@@ -0,0 +1,110 @@
import { GroupTimerState, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { getShowsTimerValue, resolveTimerDisplay } from '../timer.utils';
const makeTime = (patch: Partial<TimerState> = {}): TimerState => ({
addedTime: 0,
current: 10000,
duration: 60000,
elapsed: 50000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: null,
...patch,
});
const makeGroupTimer = (patch: Partial<GroupTimerState> = {}): GroupTimerState => ({
current: 100000,
elapsed: 80000,
duration: 180000,
...patch,
});
const resolve = (time: TimerState, groupTimer: GroupTimerState | null) =>
resolveTimerDisplay({
time,
groupTimer,
event: { timeWarning: 120000, timeDanger: 60000 },
timerType: TimerType.CountDown,
countToEnd: false,
freezeOvertime: false,
freezeMessage: '',
hidePhase: false,
});
describe('resolveTimerDisplay()', () => {
it('uses the event timer when there is no group timer', () => {
const time = makeTime();
const result = resolve(time, null);
expect(result.isGroup).toBe(false);
expect(result.source).toBe(time);
expect(result.total).toBe(60000);
expect(result.warning).toBe(120000);
expect(result.danger).toBe(60000);
});
it('adds the time added to an event into the event total', () => {
expect(resolve(makeTime({ addedTime: 30000 }), null).total).toBe(90000);
});
it('uses the group timer when one is present', () => {
const groupTimer = makeGroupTimer();
const result = resolve(makeTime(), groupTimer);
expect(result.isGroup).toBe(true);
expect(result.source).toBe(groupTimer);
expect(result.total).toBe(180000);
});
it('drops the event thresholds while showing the group', () => {
const result = resolve(makeTime({ phase: TimerPhase.Danger }), makeGroupTimer());
expect(result.warning).toBeUndefined();
expect(result.danger).toBeUndefined();
});
it.each([TimerPhase.Warning, TimerPhase.Danger])(
'reports the group as running while the event is in %s',
(phase) => {
const result = resolve(makeTime({ phase }), makeGroupTimer());
expect(result.phase).toBe(TimerPhase.Default);
expect(result.showWarning).toBe(false);
expect(result.showDanger).toBe(false);
expect(result.showFinished).toBe(false);
},
);
it('reports overtime only once the group itself has run out of time', () => {
const overrunningEvent = makeTime({ current: -5000, phase: TimerPhase.Overtime });
// the event is over, but the group still has time left
const stillRunning = resolve(overrunningEvent, makeGroupTimer({ current: 60000 }));
expect(stillRunning.phase).toBe(TimerPhase.Default);
expect(stillRunning.showFinished).toBe(false);
const overtime = resolve(overrunningEvent, makeGroupTimer({ current: -1000 }));
expect(overtime.phase).toBe(TimerPhase.Overtime);
expect(overtime.showFinished).toBe(true);
});
it.each([TimerPhase.Pending, TimerPhase.None])('keeps the %s playback phase on the group', (phase) => {
// these describe the playback state rather than a threshold, so they are true of the group too
expect(resolve(makeTime({ phase }), makeGroupTimer()).phase).toBe(phase);
});
});
describe('getShowsTimerValue()', () => {
it('is true for timer types which render the running timer', () => {
expect(getShowsTimerValue(TimerType.CountDown)).toBe(true);
expect(getShowsTimerValue(TimerType.CountUp)).toBe(true);
});
it('is false for timer types which do not reflect what is loaded', () => {
expect(getShowsTimerValue(TimerType.Clock)).toBe(false);
expect(getShowsTimerValue(TimerType.None)).toBe(false);
});
});
+59 -2
View File
@@ -1,4 +1,5 @@
import {
GroupTimerState,
MaybeNumber,
MessageState,
OntimeEvent,
@@ -6,6 +7,7 @@ import {
RundownEntries,
TimerMessage,
TimerPhase,
TimerState,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
@@ -34,9 +36,11 @@ export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): num
}
/**
* Whether the progress bar should be shown for this timer type
* Whether this timer type renders a value derived from the running timer.
* Clock shows the time of day and none shows nothing, so neither reflects what is loaded,
* which also means neither can carry a progress bar or a group timer indicator
*/
export function getShowProgressBar(timerType: TimerType) {
export function getShowsTimerValue(timerType: TimerType) {
return timerType !== TimerType.None && timerType !== TimerType.Clock;
}
@@ -114,6 +118,59 @@ export function getShowModifiers(
};
}
interface ResolveTimerDisplayOptions {
time: TimerState;
/** when present, views show the group instead of the running event */
groupTimer: GroupTimerState | null;
event: Pick<OntimeEvent, 'timeWarning' | 'timeDanger'> | null;
timerType: TimerType;
countToEnd: boolean;
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
}
/**
* Resolves which timer a view should display, and the modifiers that go with it.
*
* A group has no warning or danger thresholds of its own, so it only ever reports as
* running or overtime. Feeding that phase through `getShowModifiers` means the warning
* and danger states are suppressed as a consequence of what a group is, rather than
* every view having to remember to special case them.
*/
export function resolveTimerDisplay({
time,
groupTimer,
event,
timerType,
countToEnd,
freezeOvertime,
freezeMessage,
hidePhase,
}: ResolveTimerDisplayOptions) {
const isGroup = groupTimer !== null;
const phase = (() => {
if (!isGroup) return time.phase;
// pending and none describe the playback state rather than a threshold,
// they are just as true of the group as they are of the event
if (time.phase === TimerPhase.Pending || time.phase === TimerPhase.None) return time.phase;
return groupTimer.current <= 0 ? TimerPhase.Overtime : TimerPhase.Default;
})();
return {
isGroup,
phase,
/** the values to render, shaped for getTimerByType */
source: isGroup ? groupTimer : time,
/** the target of a progress bar */
total: isGroup ? groupTimer.duration : getTotalTime(time.duration, time.addedTime),
// thresholds belong to the event, they carry no meaning against a group duration
warning: isGroup ? undefined : event?.timeWarning,
danger: isGroup ? undefined : event?.timeDanger,
...getShowModifiers(timerType, countToEnd, phase, freezeOvertime, freezeMessage, hidePhase),
};
}
/**
* What, if anything, should be displayed in the secondary field
*/
@@ -411,6 +411,7 @@ export function migrateRundown(
targetDuration: null,
colour: '', //leave default colour
custom: {}, // leave empty
useGroupTimer: false, // opt-in feature, off for migrated projects
// !==== RUNTIME METADATA ====! //
revision: -1,
timeStart: null,
@@ -316,6 +316,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
event2: {
id: 'event2',
@@ -392,6 +393,7 @@ describe('v3 to v4', () => {
targetDuration: null,
timeEnd: null,
timeStart: null,
useGroupTimer: false,
},
delay: {
type: SupportedEntry.Delay,
@@ -714,17 +714,17 @@ describe('processRundown()', () => {
});
describe('rundownMutation.add()', () => {
test('adds an event an empty rundown', () => {
test('adds an event to an empty rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({});
rundownMutation.add(rundown, mockEvent, null, null);
rundownMutation.add(rundown, mockEvent, null, null, null);
expect(rundown.order.length).toBe(1);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top if no afterId is given', () => {
test('adds an event at the end if no afterId is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1'],
@@ -734,28 +734,28 @@ describe('rundownMutation.add()', () => {
},
});
rundownMutation.add(rundown, mockEvent, null, null);
rundownMutation.add(rundown, mockEvent, null, null, null);
expect(rundown.order).toStrictEqual(['mock', '1']);
expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
expect(rundown.order).toStrictEqual(['1', 'mock']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top of the group if no after is given', () => {
test('adds an event at the end of the group if parent is provided but no after', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1' }),
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
@@ -765,12 +765,12 @@ describe('rundownMutation.add()', () => {
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1' }),
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
@@ -1839,6 +1839,49 @@ describe('rundownMutation.clone()', () => {
parent: null,
});
});
it('clones an event and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
it('clones an event and prepends it with before true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['2'], { before: true });
expect(testRundown.order).toStrictEqual([newEntry.id, '1', '2']);
});
it('clones a group and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeGroup({ id: '1', title: 'top', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
});
describe('rundownMutation.group()', () => {
@@ -301,6 +301,23 @@ describe('parseRundown()', () => {
expect(parsedRundown.entries.group).toMatchObject({ entries: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3);
});
it('normalises groups from projects made before the group timer existed', () => {
const rundown = {
id: 'test',
title: '',
order: ['group'],
flatOrder: ['group'],
entries: {
// a group as it would have been persisted by a previous version
group: { id: 'group', type: SupportedEntry.Group, title: 'legacy', entries: [] },
},
revision: 1,
} as unknown as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.entries.group).toMatchObject({ id: 'group', useGroupTimer: false });
});
});
describe('sanitiseCustomFields()', () => {
@@ -15,6 +15,7 @@ import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } fr
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
createGroupPatch,
deleteById,
doesInvalidateMetadata,
getIntegerAndFraction,
@@ -610,3 +611,25 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
describe('createGroupPatch()', () => {
it('preserves useGroupTimer when it is not part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
const patched = createGroupPatch(original, { title: 'Morning sessions' });
expect(patched.title).toBe('Morning sessions');
expect(patched.useGroupTimer).toBe(true);
});
it('updates useGroupTimer when it is part of the patch', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: false });
expect(createGroupPatch(original, { useGroupTimer: true }).useGroupTimer).toBe(true);
expect(createGroupPatch(original, { useGroupTimer: false }).useGroupTimer).toBe(false);
});
it('ignores non boolean values for useGroupTimer', () => {
const original = makeOntimeGroup({ id: 'group', useGroupTimer: true });
// @ts-expect-error -- testing a value coming from an unvalidated request
expect(createGroupPatch(original, { useGroupTimer: 'nope' }).useGroupTimer).toBe(true);
});
});
@@ -0,0 +1,40 @@
import type { OntimeGroup } from 'ontime-types';
import { describe, expect, test } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder } from '../rundown.validation.js';
const rundown = makeRundown({
order: ['top', 'group'],
entries: {
top: makeOntimeEvent({ id: 'top', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['nested'] }),
nested: makeOntimeEvent({ id: 'nested', parent: 'group' }),
},
});
describe('insertion anchor validation', () => {
test('rejects a missing anchor', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { before: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
test('rejects an anchor from a different order', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { after: 'nested' })).toThrow(
'Insertion anchor with ID nested is not in the target order',
);
});
test('accepts a group-local anchor', () => {
const group = rundown.entries.group as OntimeGroup;
expect(() => assertInsertAnchorInOrder(rundown, group, { before: 'nested' })).not.toThrow();
});
test('checks clone anchors exist', () => {
expect(() => assertInsertAnchorExists(rundown, { after: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
});
+41 -43
View File
@@ -439,21 +439,27 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
rundown.entries[nestedEntry.id] = nestedEntry;
}
// by default we insert after the cloned element
let atIndex = rundown.order.indexOf(entry.id) + 1;
const referenceId = options?.after ?? options?.before;
if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
const position: { after: EntryId | null; before: EntryId | null } = (() => {
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
}
}
const after = getInsertAfterId(rundown, null, options.after, options.before);
const before = options.before === true ? (after ?? null) : (options.before ?? null);
return { after, before };
})();
const atIndex = (() => {
if (position.before !== null) {
return rundown.order.indexOf(position.before);
}
if (position.after !== null) {
return rundown.order.indexOf(position.after) + 1;
}
return rundown.order.length;
})();
// we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
@@ -461,41 +467,33 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
return newGroup;
} else {
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
const parent = (() => {
const referenceId = (() => {
if (typeof options?.after === 'string') return options.after;
if (typeof options?.before === 'string') return options.before;
return undefined;
})();
let parent: OntimeGroup | null = null;
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry;
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
if (parentId === null) return null;
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
const maybeParent = rundown.entries[parentId];
return isOntimeGroup(maybeParent) ? maybeParent : null;
})();
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
const position = (() => {
// if no position is given, we add immediately after the element
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
const after = getInsertAfterId(rundown, parent, options.after, options.before);
const before = options?.before === true ? after : (options.before ?? null);
return { after, before };
})();
return addToRundown(rundown, clonedEntry, after, parent);
return addToRundown(rundown, clonedEntry, parent, position.after, position.before);
}
}
@@ -40,11 +40,15 @@ import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import {
generateEvent,
getFirstInsertId,
getIntegerAndFraction,
getLastInsertId,
getPreviousInsertId,
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
} from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
/**
* creates a new entry with given data
@@ -52,6 +56,8 @@ import {
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertSingleInsertAnchor(eventData);
// we allow the user to provide an ID, but make sure it is unique
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
throw new Error(`Event with ID ${eventData.id} already exists`);
@@ -68,14 +74,29 @@ export async function addEntry(rundownId: string, eventData: EventPostPayload):
parent = maybeParent;
}
assertInsertAnchorInOrder(rundown, parent, eventData);
// normalise the position of the event in the rundown order
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
const insertPosition = (() => {
if (eventData.before !== undefined) {
const beforeId = getFirstInsertId(rundown, parent, eventData.before);
return { afterId: null, beforeId, cueAfterId: getPreviousInsertId(rundown, parent, beforeId) };
}
// if after is an ID, we will try and find the entry
if (eventData.after !== undefined && eventData.after !== true) {
const afterId = getInsertAfterId(rundown, parent, eventData.after);
return { afterId, beforeId: null, cueAfterId: afterId };
}
return { afterId: null, beforeId: null, cueAfterId: getLastInsertId(rundown, parent) };
})();
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, afterId, parent);
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
@@ -395,7 +416,10 @@ export async function swapEvents(rundownId: string, fromId: EntryId, toId: Entry
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
assertSingleInsertAnchor(options);
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertInsertAnchorExists(rundown, options);
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
@@ -70,6 +70,50 @@ export function generateEvent(
throw new Error('Invalid event type');
}
/**
* Gets the last valid insertion reference for a top-level rundown or group order.
* Used when appending entries and when generating cues from the preceding entry.
*/
export function getLastInsertId(rundown: Rundown, parent: OntimeGroup | null): EntryId | null {
const insertionList = parent ? parent.entries : rundown.order;
return insertionList[insertionList.length - 1] ?? null;
}
/**
* Resolves a `before` insertion option to the first entry in the relevant order when `true` is provided.
* String values are already explicit anchors and are returned unchanged.
*/
export function getFirstInsertId(rundown: Rundown, parent: OntimeGroup | null, before: EntryId | true): EntryId | null {
if (before !== true) {
return before;
}
const insertionList = parent ? parent.entries : rundown.order;
return insertionList[0] ?? null;
}
/**
* Gets the sibling before a `before` insertion anchor in the top-level or group order.
* Returns `null` when the new entry will be inserted at the start.
*/
export function getPreviousInsertId(
rundown: Rundown,
parent: OntimeGroup | null,
beforeId: EntryId | null,
): EntryId | null {
if (beforeId === null) {
return null;
}
const insertionList = parent ? parent.entries : rundown.order;
const beforeIndex = insertionList.indexOf(beforeId);
if (beforeIndex < 1) {
return null;
}
return insertionList[beforeIndex - 1] ?? null;
}
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
@@ -135,6 +179,8 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(),
colour: makeString(patchGroup.colour, originalGroup.colour),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
revision: originalGroup.revision,
timeStart: originalGroup.timeStart,
timeEnd: originalGroup.timeEnd,
@@ -1,4 +1,5 @@
import { body, param } from 'express-validator';
import type { EntryId, InsertOptions, OntimeGroup, Rundown } from 'ontime-types';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -37,15 +38,27 @@ export const rundownImportValidator = [
export const entryPostValidator = [
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
body('after').optional().isString(),
body('before').optional().isString(),
body('after')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for after are an ID or true.'),
body('before')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for before are an ID or true.'),
requestValidationFunction,
];
export const clonePostValidator = [
body('after').optional().isString(),
body('before').optional().isString(),
body('after')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for after are an ID or true.'),
body('before')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for before are an ID or true.'),
requestValidationFunction,
];
@@ -93,4 +106,44 @@ export const entryRenumberValidator = [
requestValidationFunction,
];
/**
* Ensures callers use a single insertion anchor so placement is unambiguous.
*/
export function assertSingleInsertAnchor(options: InsertOptions) {
if (options.after !== undefined && options.before !== undefined) {
throw new Error('Use only one insertion anchor: after or before');
}
}
/**
* Ensures an ID anchor exists and belongs to the order it is intended to position within.
*/
export function assertInsertAnchorInOrder(rundown: Rundown, parent: OntimeGroup | null, options: InsertOptions) {
const anchor = getStringInsertAnchor(options);
if (anchor === undefined) return;
if (!Object.hasOwn(rundown.entries, anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
}
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList.includes(anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} is not in the target order`);
}
}
/** Ensures an ID anchor refers to an entry in the rundown. */
export function assertInsertAnchorExists(rundown: Rundown, options: InsertOptions) {
const anchor = getStringInsertAnchor(options);
if (anchor !== undefined && !Object.hasOwn(rundown.entries, anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
}
}
function getStringInsertAnchor(options: InsertOptions): EntryId | undefined {
if (typeof options.after === 'string') return options.after;
if (typeof options.before === 'string') return options.before;
return undefined;
}
// #endregion operations on rundown entries =======================
@@ -153,6 +153,41 @@ describe('mcp.service', () => {
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
});
it('omits insert anchors when creating an entry without a position', async () => {
await createEntryForMcp({
type: SupportedEntry.Milestone,
title: 'End marker',
});
expect(addEntryMock).toHaveBeenCalledWith(
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Milestone, title: 'End marker' }),
);
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('after');
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('before');
});
it('uses before true for the first batch entry and chains the rest', async () => {
await batchCreateEntriesForMcp({
before: true,
entries: [
{ type: SupportedEntry.Event, title: 'First' },
{ type: SupportedEntry.Event, title: 'Second' },
],
});
expect(addEntryMock).toHaveBeenNthCalledWith(
1,
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Event, title: 'First', before: true }),
);
expect(addEntryMock).toHaveBeenNthCalledWith(
2,
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Event, title: 'Second', after: 'entry-1' }),
);
});
it('rejects nested groups before creating entries', async () => {
await expect(
batchCreateEntriesForMcp({
+2 -2
View File
@@ -71,10 +71,10 @@ Read the ontime://schema resource if you need a data model reference.
Steps:
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and avoid duplicating existing entries.
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
5. Omit \`after\` and \`before\` on the batch call to append the new entries to the end of the rundown. Use \`after: true\` for an explicit append request, or \`before: true\` for an explicit prepend request.
Entry type guidance:
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
+1
View File
@@ -147,6 +147,7 @@ There are four entry types discriminated by \`type\`:
note: string
entries: EntryId[]
targetDuration: number | null
useGroupTimer: boolean // views show a shared timer for the whole group
custom: { [key: string]: string }
timeStart: number | null // calculated from nested entries (runtime)
timeEnd: number | null // calculated from nested entries (runtime)
+33 -14
View File
@@ -54,7 +54,9 @@ export type EventFieldArgs = Partial<
>;
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
export type GroupFieldArgs = Partial<
Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom' | 'useGroupTimer'>
>;
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
export type TargetRundownArgs = { rundownId?: string };
@@ -63,6 +65,11 @@ export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEnt
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
type BatchCreateEntriesArgs = TargetRundownArgs & {
entries: BatchCreateEntryArgs[];
after?: EntryId | true;
before?: EntryId | true;
};
export function resolveTargetRundownId(args: TargetRundownArgs): string {
return args.rundownId ?? getCurrentRundownId();
@@ -133,20 +140,24 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
const { type = SupportedEntry.Event, after, before } = args;
const insertOptions = {
...(after !== undefined ? { after } : {}),
...(before !== undefined ? { before } : {}),
};
switch (type) {
case SupportedEntry.Delay:
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
return { type: SupportedEntry.Delay, duration: args.duration, ...insertOptions };
case SupportedEntry.Milestone: {
const { cue, title, note, colour, custom } = args;
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, ...insertOptions };
}
case SupportedEntry.Group:
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
return { type: SupportedEntry.Group, title: args.title, after, before };
return { type: SupportedEntry.Group, title: args.title, ...insertOptions };
case SupportedEntry.Event: {
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
return { type: SupportedEntry.Event, ...eventFields };
const { type: _type, rundownId: _rundownId, after: _after, before: _before, ...eventFields } = args;
return { type: SupportedEntry.Event, ...eventFields, ...insertOptions };
}
default:
throw new Error(`Invalid entry type: ${String(type)}`);
@@ -161,6 +172,7 @@ function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGro
if (args.colour !== undefined) patch.colour = args.colour;
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
if (args.custom !== undefined) patch.custom = args.custom;
if (args.useGroupTimer !== undefined) patch.useGroupTimer = args.useGroupTimer;
return Object.keys(patch).length > 1 ? patch : null;
}
@@ -259,20 +271,25 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
}
export async function batchCreateEntriesForMcp(
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
) {
const { entries = [], after } = args;
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
const { entries = [], after, before } = args;
validateBatchCreateEntries(entries);
const allEntries = flattenBatchCreateEntries(entries);
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
const rundownId = resolveTargetRundownId(args);
let previousId = after;
let previousId: EntryId | undefined;
const created: OntimeEntry[] = [];
for (const entryArgs of entries) {
const firstInsertOptions =
created.length === 0
? {
...(after !== undefined ? { after } : {}),
...(before !== undefined ? { before } : {}),
}
: undefined;
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
const entry = await createBatchEntry(rundownId, entryArgs, previousId, undefined, firstInsertOptions);
created.push(...entry.created);
previousId = entry.entry.id;
}
@@ -303,6 +320,7 @@ async function createBatchEntry(
entryArgs: BatchCreateEntryArgs,
previousId?: EntryId,
parentId?: EntryId,
firstInsertOptions?: InsertOptions,
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
if (parentId && entryArgs.type === SupportedEntry.Group) {
throw new Error('Cannot create a group inside another group.');
@@ -311,8 +329,9 @@ async function createBatchEntry(
const { children: _children, ...createArgs } = entryArgs;
const payload = toEntryPayload(createArgs);
const insertOptions = {
...(previousId ? { after: previousId } : {}),
...(parentId ? { parent: parentId } : {}),
...(previousId !== undefined ? { after: previousId } : {}),
...(parentId !== undefined ? { parent: parentId } : {}),
...(previousId === undefined ? firstInsertOptions : {}),
};
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
+25 -6
View File
@@ -96,7 +96,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_create_entry',
description:
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
inputSchema: {
type: 'object',
properties: {
@@ -114,8 +114,14 @@ export const TOOL_DEFINITIONS = [
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
},
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
after: { type: 'string', description: 'Insert after this entry ID' },
before: { type: 'string', description: 'Insert before this entry ID' },
after: {
type: ['string', 'boolean'],
description: 'Insert after this entry ID, or true to append',
},
before: {
type: ['string', 'boolean'],
description: 'Insert before this entry ID, or true to prepend',
},
...EVENT_WRITABLE_FIELDS,
},
},
@@ -218,13 +224,20 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_batch_create_entries',
description:
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
inputSchema: {
type: 'object',
required: ['entries'],
properties: {
...RUNDOWN_TARGET_FIELD,
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
after: {
type: ['string', 'boolean'],
description: 'Insert the first entry after this entry ID, or true to append',
},
before: {
type: ['string', 'boolean'],
description: 'Insert the first entry before this entry ID, or true to prepend',
},
entries: {
type: 'array',
description: 'Array of entries to create, in desired order',
@@ -588,7 +601,13 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_batch_create_entries: async (args) => {
return ok(
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
await batchCreateEntriesForMcp(
args as TargetRundownArgs & {
entries: BatchCreateEntryArgs[];
after?: EntryId | true;
before?: EntryId | true;
},
),
);
},
+1
View File
@@ -207,6 +207,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({
clock: state.clock,
timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message },
offset: state.offset,
rundown: state.rundown,
+11
View File
@@ -39,6 +39,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -172,6 +173,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['0aaa7d'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -219,6 +221,7 @@ export const stageRundown: Rundown = {
note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -360,6 +363,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null,
useGroupTimer: false,
colour: '#A790F5',
custom: {},
revision: 0,
@@ -476,6 +480,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -588,6 +593,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0301'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -634,6 +640,7 @@ export const backstageRundown: Rundown = {
note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -768,6 +775,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null,
useGroupTimer: false,
colour: '#ED3333',
custom: {},
revision: 0,
@@ -851,6 +859,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -959,6 +968,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0301', 'br0302'],
targetDuration: null,
useGroupTimer: false,
colour: '#3E75E8',
custom: {},
revision: 0,
@@ -1029,6 +1039,7 @@ export const broadcastRundown: Rundown = {
note: '',
entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null,
useGroupTimer: false,
colour: '#339E4E',
custom: {},
revision: 0,
@@ -1,4 +1,16 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
EndAction,
MaybeNumber,
OntimeEvent,
OntimeGroup,
Playback,
Rundown,
SupportedEntry,
TimeOfDay,
TimeStrategy,
TimerPhase,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import type { RuntimeState } from '../../stores/runtimeState.js';
@@ -7,6 +19,8 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -1126,7 +1140,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000); // <--- offset is always the overtime
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
});
it('handles time-to-end started after the end time', () => {
@@ -1452,3 +1466,138 @@ describe('findDay()', () => {
expect(findDayOffset(22 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(0); // -> 1
});
});
describe('getGroupTiming()', () => {
const makeEvent = (id: string, duration: number, patch: object = {}) =>
({ id, type: SupportedEntry.Event, duration, gap: 0, skip: false, parent: 'group', ...patch }) as OntimeEvent;
const makeGroup = (entries: string[]) => ({ id: 'group', type: SupportedEntry.Group, entries }) as OntimeGroup;
const makeEntries = (...entries: OntimeEvent[]) =>
Object.fromEntries(entries.map((entry) => [entry.id, entry])) as Rundown['entries'];
it('splits the scheduled content around the loaded event', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20), makeEvent('3', 30));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 50 });
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10, after: 30 });
expect(getGroupTiming(group, entries, '3')).toStrictEqual({ before: 30, after: 0 });
});
it('accounts for the gaps between events', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30, { gap: 7 }));
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 20 + 5 + 30 + 7 });
// the gap before the loaded event has already passed, so it counts as time spent
expect(getGroupTiming(group, entries, '2')).toStrictEqual({ before: 10 + 5, after: 30 + 7 });
});
it('splits into values which add up to the group duration', () => {
const group = makeGroup(['1', '2', '3']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20, { gap: 5 }), makeEvent('3', 30));
const groupDuration = 10 + 20 + 5 + 30;
for (const id of ['1', '2', '3']) {
const timing = getGroupTiming(group, entries, id)!;
expect(timing.before + entries[id].duration + timing.after).toBe(groupDuration);
}
});
it('skips entries which are not playable events', () => {
const group = makeGroup(['1', '2', '3', '4']);
const entries = {
...makeEntries(makeEvent('1', 10), makeEvent('2', 20, { skip: true }), makeEvent('4', 40)),
'3': { id: '3', type: SupportedEntry.Milestone, parent: 'group' },
} as Rundown['entries'];
expect(getGroupTiming(group, entries, '1')).toStrictEqual({ before: 0, after: 40 });
});
it('returns null when the loaded event is not part of the group', () => {
const group = makeGroup(['1', '2']);
const entries = makeEntries(makeEvent('1', 10), makeEvent('2', 20));
expect(getGroupTiming(group, entries, 'elsewhere')).toBeNull();
});
});
describe('getGroupTimer()', () => {
const makeState = (patch: {
useGroupTimer?: boolean;
duration?: number;
timing?: { before: number; after: number } | null;
current?: MaybeNumber;
elapsed?: MaybeNumber;
addedTime?: number;
}) =>
({
groupNow: { duration: patch.duration ?? 100, useGroupTimer: patch.useGroupTimer ?? true },
_groupTiming: patch.timing === undefined ? { before: 0, after: 0 } : patch.timing,
timer: {
current: patch.current ?? null,
elapsed: patch.elapsed ?? null,
addedTime: patch.addedTime ?? 0,
},
}) as RuntimeState;
it('is null when there is no group running', () => {
expect(getGroupTimer({ groupNow: null, _groupTiming: null } as RuntimeState)).toBeNull();
});
it('is null when the group has not opted in', () => {
expect(getGroupTimer(makeState({ useGroupTimer: false, current: 10 }))).toBeNull();
});
it('is null when the loaded event is not part of the group', () => {
expect(getGroupTimer(makeState({ timing: null, current: 10 }))).toBeNull();
});
it('offsets the event timer by the content scheduled around it', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 20, elapsed: 0 });
expect(getGroupTimer(state)).toStrictEqual({ current: 70, elapsed: 30, duration: 100 });
});
it('keeps elapsed and current adding up to the total', () => {
const state = makeState({ duration: 100, timing: { before: 30, after: 50 }, current: 12, elapsed: 8 });
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('grows the total with the time added to the running event', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 160,
elapsed: 0,
addedTime: 60,
});
const groupTimer = getGroupTimer(state)!;
expect(groupTimer.duration).toBe(160);
// elapsed keeps counting up rather than clamping at zero once time is added
expect(groupTimer.elapsed).toBe(0);
expect(groupTimer.elapsed + groupTimer.current).toBe(groupTimer.duration);
});
it('reports elapsed time while the group is in credit from added time', () => {
const state = makeState({
duration: 100,
timing: { before: 0, after: 0 },
current: 150,
elapsed: 10,
addedTime: 60,
});
expect(getGroupTimer(state)).toStrictEqual({ current: 150, elapsed: 10, duration: 160 });
});
it('goes negative when the group runs into overtime', () => {
const state = makeState({ duration: 100, timing: { before: 90, after: 0 }, current: -15, elapsed: 25 });
expect(getGroupTimer(state)?.current).toBe(-15);
});
});
@@ -709,6 +709,18 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer };
}
/**
* the group timer is the event timer offset by a constant, so it changes exactly when
* the event timer does and can share its throttling.
* entry changes are also considered since loading an event can move us between groups
*/
if (updateTimer || entryChanged) {
if (!deepEqual(RuntimeService.previousState.groupTimer, state.groupTimer)) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
+89 -5
View File
@@ -1,7 +1,18 @@
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import {
Day,
EntryId,
GroupTimerState,
MaybeNumber,
OntimeGroup,
Rundown,
TimeOfDay,
TimerPhase,
isOntimeEvent,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
import type { GroupTiming, RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -180,13 +191,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
// in case of count to end, the absolute offset is overtime and added time
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
// in case of count to end, the absolute offset is just the overtime
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
return { absolute, relative };
}
/**
@@ -231,3 +242,76 @@ export function findDayOffset(plannedStart: number, clock: number): Day {
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
/**
* Splits a group's scheduled content around the loaded event.
*
* The result is stable for as long as the same event is loaded, so it is calculated
* when the group is loaded rather than on every update.
* The aggregation mirrors the group duration calculated in the rundown
* (see rundown.dao.ts): non playable entries are skipped and the gap is
* accounted for in every entry other than the first.
*/
export function getGroupTiming(
group: OntimeGroup,
entries: Rundown['entries'],
currentEventId: EntryId,
): GroupTiming | null {
const currentIndex = group.entries.indexOf(currentEventId);
if (currentIndex === -1) {
return null;
}
let before = 0;
let after = 0;
for (let i = 0; i < group.entries.length; i++) {
const entry = entries[group.entries[i]];
if (!isOntimeEvent(entry) || !isPlayableEvent(entry)) {
continue;
}
// the first entry of the group has no gap to account for,
// any other entry could be preceded by idle time
const gap = i > 0 ? entry.gap : 0;
if (i < currentIndex) {
before += gap + entry.duration;
} else if (i === currentIndex) {
// the loaded event contributes its own duration through the event timer,
// but the idle time before it has already passed
before += gap;
} else {
after += gap + entry.duration;
}
}
return { before, after };
}
/**
* Derives the shared group timer from the event timer.
*
* Keeping this relative to the event timer is what makes the group behave as if it were
* a single event containing all its children: pause, added time, overtime, roll and
* midnight rollovers are all inherited instead of being recalculated.
*/
export function getGroupTimer(state: RuntimeState): GroupTimerState | null {
const { groupNow, _groupTiming } = state;
if (groupNow === null || !groupNow.useGroupTimer || _groupTiming === null) {
return null;
}
const { current, elapsed, addedTime } = state.timer;
if (current === null) {
return null;
}
return {
current: current + _groupTiming.after,
elapsed: (elapsed ?? 0) + _groupTiming.before,
// mirrors the total time of an event, which grows with the time added to it
duration: groupNow.duration + addedTime,
};
}
@@ -9,6 +9,8 @@ const baseState: RuntimeState = {
eventNext: null,
eventFlag: null,
groupNow: null,
groupTimer: null,
_groupTiming: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
@@ -1036,4 +1036,167 @@ describe('loadGroupFlagAndEnd()', () => {
eventNow: rundown.entries[0],
});
});
test('countToEnd entries decouple the link chain for subsequent events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: countToEnd=true → breaks the link chain
// Event 2: linkStart=true → unlinked (chain broken by event 1)
// Event 3: flag event → also unlinked
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
// accumulatedGap includes event 1's duration carried forward to event 2
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
// so previousWasCountToEnd is null and no further duration is carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): also unlinked because the chain was broken at event 1
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
// Event 0 (loaded): no countToEnd, linkStart=true
// Event 1: linkStart=true → linked to loaded
// Event 2: countToEnd=true → breaks the chain (also last in group)
// Event 3: linkStart=true → unlinked (chain broken by event 2)
const rundown = makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
0: makeOntimeEvent({
id: '0',
parent: 'group',
timeStart: 0,
duration: 3600000,
countToEnd: false,
linkStart: true,
gap: 0,
} as any),
1: makeOntimeEvent({
id: '1',
parent: 'group',
timeStart: 3600000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
2: makeOntimeEvent({
id: '2',
parent: 'group',
timeStart: 7200000,
duration: 3600000,
countToEnd: true,
linkStart: true,
gap: 0,
} as any),
3: makeOntimeEvent({
id: '3',
parent: null,
timeStart: 10800000,
duration: 3600000,
linkStart: true,
gap: 0,
} as any),
},
order: ['group', '0', '1', '2', '3'],
});
const state = {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// _group is the last event in the group (event 2)
// isLinkedToLoaded is true because no preceding event had countToEnd
// accumulatedGap is 0 because no preceding event was countToEnd
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
expect(state._group).toMatchObject({
event: rundown.entries[2],
isLinkedToLoaded: true,
accumulatedGap: 0,
});
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
// accumulatedGap includes event 2's duration carried forward
expect(state._flag).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
accumulatedGap: 3600000,
});
// _end (event 3): unlinked because event 2 has countToEnd=true
expect(state._end).toMatchObject({
event: rundown.entries[3],
isLinkedToLoaded: false,
});
});
});
+44 -5
View File
@@ -16,6 +16,7 @@ import {
TimeOfDay,
TimerPhase,
TimerState,
GroupTimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
@@ -23,6 +24,7 @@ import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedEnd,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
@@ -38,6 +40,8 @@ import {
getCurrent,
getElapsed,
getExpectedFinish,
getGroupTimer,
getGroupTiming,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
@@ -50,6 +54,12 @@ type ExpectedMetadata = {
isLinkedToLoaded: boolean;
} | null;
/** scheduled content of the running group, split around the loaded event */
export type GroupTiming = {
before: number;
after: number;
};
export type RuntimeState = {
clock: TimeOfDay;
groupNow: OntimeGroup | null;
@@ -58,6 +68,8 @@ export type RuntimeState = {
eventFlag: PlayableEvent | null;
offset: Offset;
timer: TimerState;
/** derived from the timer, only refreshed on the getState() projection */
groupTimer: GroupTimerState | null;
rundown: RundownState;
// private properties of the timer calculations
_timer: {
@@ -71,6 +83,7 @@ export type RuntimeState = {
totalDelay: number; // this value comes from rundown service
};
_group: ExpectedMetadata;
_groupTiming: GroupTiming | null;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
@@ -85,6 +98,7 @@ const runtimeState: RuntimeState = {
eventFlag: null,
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
groupTimer: null,
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: {
forceFinish: null,
@@ -97,6 +111,7 @@ const runtimeState: RuntimeState = {
totalDelay: 0,
},
_group: null,
_groupTiming: null,
_flag: null,
_end: null,
_startEpoch: null,
@@ -114,6 +129,8 @@ export function getState(): Readonly<RuntimeState> {
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer },
// derived here so it can never drift from the timer values it is built on
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
@@ -155,6 +172,7 @@ export function clearState() {
runtimeState.groupNow = null;
runtimeState._group = null;
runtimeState._groupTiming = null;
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
@@ -831,7 +849,6 @@ function getExpectedTimes(state = runtimeState) {
state.offset.expectedRundownEnd = null;
state.offset.expectedGroupEnd = null;
state.offset.expectedFlagStart = null;
state.offset.expectedRundownEnd = null;
const { offset } = state;
const { plannedStart, actualStart } = state.rundown;
@@ -852,7 +869,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
}
}
@@ -884,7 +901,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = expectedStart + event.duration;
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
}
}
@@ -897,6 +914,7 @@ export function loadGroupFlagAndEnd(
const previousGroup = state.groupNow?.id;
state.groupNow = null;
state._group = null;
state._groupTiming = null;
state.eventFlag = null;
state._flag = null;
state._end = null;
@@ -920,6 +938,11 @@ export function loadGroupFlagAndEnd(
state.rundown.actualGroupStart = null;
}
// the split is stable while the same event is loaded, so we only calculate it here
if (state.groupNow !== null) {
state._groupTiming = getGroupTiming(state.groupNow, rundown.entries, state.eventNow.id);
}
// if we don't have a any flags in the rundown then no need to look for it
let foundFlag = !flagsPresent;
// if we don't have a last event for the group there is no need to find its end time
@@ -927,6 +950,7 @@ export function loadGroupFlagAndEnd(
let accumulatedGap = 0;
let isLinkedToLoaded = true;
let previousWasCountToEnd: Maybe<number> = null;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
@@ -934,8 +958,23 @@ export function loadGroupFlagAndEnd(
if (isOntimeEvent(entry)) {
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
if (previousWasCountToEnd !== null) {
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
* and break the link to the loaded event since countToEnd events reset the schedule
*/
accumulatedGap += entry.gap + previousWasCountToEnd;
isLinkedToLoaded = false;
} else {
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
}
if (entry.countToEnd) {
previousWasCountToEnd = entry.duration;
} else {
previousWasCountToEnd = null;
}
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
await page.getByRole('button', { name: 'Preview import' }).click();
await page.getByRole('button', { name: 'Apply import' }).click();
await expect(page.getByText('Import complete')).toBeVisible();
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
await page.getByRole('button', { name: 'Reset flow' }).click();
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
await page.getByRole('button', { name: 'Import another' }).click();
// verify the data in the rundown
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
@@ -4,8 +4,8 @@ import type { MaybeNumber } from '../../utils/utils.type.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
export type InsertOptions = {
after?: EntryId;
before?: EntryId;
after?: EntryId | true;
before?: EntryId | true;
};
export type EventPostPayload = Partial<OntimeEntry> & InsertOptions;
@@ -46,6 +46,8 @@ export type OntimeGroup = OntimeBaseEvent & {
targetDuration: MaybeNumber;
colour: string;
custom: EntryCustomFields;
/** whether views should display a shared timer for the group instead of the event timer */
useGroupTimer: boolean;
// !==== RUNTIME METADATA ====! //
revision: number;
timeStart: MaybeNumber; // calculated at runtime
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
groupTimer: null, // derived from the timer, null unless the running group opted in
message: {
timer: {
text: '',
@@ -3,12 +3,14 @@ import type { SimpleTimerState } from './AuxTimer.type.js';
import type { MessageState } from './MessageControl.type.js';
import type { Offset } from './Offset.type.js';
import type { RundownState } from './RundownState.type.js';
import type { TimerState } from './TimerState.type.js';
import type { GroupTimerState, TimerState } from './TimerState.type.js';
export type RuntimeStore = {
// timer data
clock: number;
timer: TimerState;
/** shared timer for the running group, null unless the group opted in */
groupTimer: GroupTimerState | null;
// messages service
message: MessageState;
@@ -11,6 +11,22 @@ export enum TimerPhase {
Pending = 'pending',
}
/**
* Timer for the running group, present when the group opted into a shared timer.
*
* The group is treated as a single event containing all its children: the values are
* derived from the running event timer plus the content scheduled around it, so pause,
* added time, overtime and roll are inherited from the event timer rather than recalculated.
*/
export type GroupTimerState = {
/** Time remaining in the group */
current: number;
/** Time elapsed since the group started */
elapsed: number;
/** Total time in the group, includes time added to the running event */
duration: number;
};
/**
* Gathers the current running timer state
*/
+1 -1
View File
@@ -119,7 +119,7 @@ export type { Offset } from './definitions/runtime/Offset.type.js';
export { OffsetMode } from './definitions/runtime/Offset.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
export { type GroupTimerState, type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
+1 -1
View File
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
@@ -1,7 +1,7 @@
import { Day, OffsetMode } from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
import { getExpectedStart } from './getExpectedStart';
import { getExpectedEnd, getExpectedStart } from './getExpected';
describe('getExpectedStart()', () => {
describe('Absolute offset mode', () => {
@@ -315,3 +315,144 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
});
});
describe('getExpectedEnd()', () => {
const baseState = {
currentDay: 0,
totalGap: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
test('a regular event ends at its expected start plus duration', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: false,
};
// on schedule
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
// running 20 behind pushes the end out
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).toBe(170);
});
test('a countToEnd event pins to the planned end while in overtime', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150);
});
test('an overnight countToEnd event returns a normalised end', () => {
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
const timeStart = 23 * MILLIS_PER_HOUR;
const duration = 2 * MILLIS_PER_HOUR;
const testEvent = {
timeStart,
duration,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(timeStart + duration);
});
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 20,
dayOffset: 0 as Day,
};
// events shift their schedule based on delay...
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
// ... but count to end events stay pinned to the scheduled end
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).toBe(150);
});
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 60,
dayOffset: 0 as Day,
countToEnd: true,
};
// the delayed start (160) is past the fixed end (150), so the event can no longer
// finish on time and the end follows the compromised start
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(160);
});
test('a countToEnd event on a later day keeps the day offset on the end', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
// not collapse to the day-shifted start
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
// when the running event is already on the same day, no extra day is added
const expectedStartSameDay = getExpectedStart(
{ ...testEvent, dayOffset: 0 as Day },
{ ...baseState, currentDay: 0, offset: 0 },
);
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).toBe(150);
});
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// a regular event in the same state is shifted by the relative-start offset to 180
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
180,
);
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
});
});
@@ -3,15 +3,6 @@ import { OffsetMode } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* @param event the event that we are counting to
* @param currentDay the day offset of the currently running event
* @param totalGap accumulated gap from the current event
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
* @param clock
* @param offset
* @returns
*/
export function getExpectedStart(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
state: {
@@ -60,3 +51,25 @@ export function getExpectedStart(
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
export function getExpectedEnd(
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
expectedStart: number,
currentRuntimeDay: number,
): number {
/**
* Count to end events are a special case
* - the end time is always the wall clock
*/
if (event.countToEnd) {
// account for day offset
const relativeDayOffset = event.dayOffset - currentRuntimeDay;
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
// count to end should finish on the planned time or on start
return Math.max(expectedStart, plannedEnd);
}
// for normal events, the expected end is when we would start + its duration
return expectedStart + event.duration;
}
@@ -54,6 +54,7 @@ export const group: Omit<OntimeGroup, 'id'> = {
targetDuration: null,
colour: '',
custom: {},
useGroupTimer: false,
// !==== RUNTIME METADATA ====! //
revision: 0, // calculated at runtime
timeStart: null, // calculated at runtime
@@ -69,6 +69,7 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
useGroupTimer: typeof patch.useGroupTimer === 'boolean' ? patch.useGroupTimer : false,
revision: 0,
timeStart: null,
timeEnd: null,
@@ -248,28 +248,30 @@ describe('getInsertAfterId()', () => {
revision: 1,
} as Rundown;
it('returns afterId if provided', () => {
it('returns the provided after id', () => {
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
});
it('returns null if neither afterId nor beforeId is provided', () => {
it('returns null if no anchors are provided', () => {
expect(getInsertAfterId(rundown, null)).toBeNull();
});
it('returns null if beforeId is not found', () => {
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
it('returns null if after is true', () => {
expect(getInsertAfterId(rundown, null, true)).toBeNull();
});
it('returns the previous id of an entry in the rundown', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
it('returns the before id if provided', () => {
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('2');
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('4');
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('group');
});
it('returns the previous id of an event in a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
it('returns the first top-level id if before is true', () => {
expect(getInsertAfterId(rundown, null, undefined, true)).toBe('1');
});
it('returns the first group entry if before is true inside a group', () => {
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, true)).toBe('31');
});
});
@@ -331,22 +333,22 @@ describe('addToRundown()', () => {
const rundown = { id: 'test', title: '', entries: {}, order: [], flatOrder: [], revision: 0 } as Rundown;
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, null, null);
addToRundown(rundown, newEntry, null, null, null);
expect(rundown.order).toEqual(['new']);
expect(rundown.flatOrder).toEqual(['new']);
expect(rundown.entries['new']).toBe(newEntry);
});
// case 2b: insert at the beginning of the rundown
it('adds at the beginning of order and flatOrder when afterId is null', () => {
// case 2c: insert at the end of the rundown
it('adds at the end of order and flatOrder when afterId is null', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, null, null);
addToRundown(rundown, newEntry, null, null, null);
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
expect(rundown.order).toEqual(['1', '2', 'group', 'new']);
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
});
// case 2a: insert after a given entry at top level
@@ -354,19 +356,55 @@ describe('addToRundown()', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, '1', null);
addToRundown(rundown, newEntry, null, '1', null);
expect(rundown.order).toEqual(['1', 'new', '2', 'group']);
expect(rundown.flatOrder).toEqual(['1', 'new', '2', 'group', '31', '32']);
});
// case 1b: insert at the beginning of a group
it('inserts right after the group header in flatOrder and sets parent', () => {
it('prepends to the rundown when before is true', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
const afterId = getInsertAfterId(rundown, null, undefined, true);
addToRundown(rundown, newEntry, null, afterId, afterId);
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
});
it('inserts after a top-level group and its children in flatOrder', () => {
const rundown = makeTestRundown();
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
addToRundown(rundown, newEntry, null, 'group', null);
expect(rundown.order).toEqual(['1', '2', 'group', 'new']);
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
});
// case 1c: insert at the end of a group
it('inserts at the end of a group and sets parent', () => {
const rundown = makeTestRundown();
const parent = rundown.entries['group'] as OntimeGroup;
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
addToRundown(rundown, newEntry, null, parent);
addToRundown(rundown, newEntry, parent, null, null);
expect(parent.entries).toEqual(['31', '32', 'new']);
expect(newEntry.parent).toBe('group');
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
// top-level order must not change when inserting into a group
expect(rundown.order).toEqual(['1', '2', 'group']);
});
it('prepends to a group when before is true', () => {
const rundown = makeTestRundown();
const parent = rundown.entries['group'] as OntimeGroup;
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
const afterId = getInsertAfterId(rundown, parent, undefined, true);
addToRundown(rundown, newEntry, parent, afterId, afterId);
expect(parent.entries).toEqual(['new', '31', '32']);
expect(newEntry.parent).toBe('group');
@@ -381,7 +419,7 @@ describe('addToRundown()', () => {
const parent = rundown.entries['group'] as OntimeGroup;
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
addToRundown(rundown, newEntry, '31', parent);
addToRundown(rundown, newEntry, parent, '31', null);
expect(parent.entries).toEqual(['31', 'new', '32']);
expect(newEntry.parent).toBe('group');
@@ -361,31 +361,26 @@ export function getNextGroupNormal(
return { entry: null, index: null };
}
/**
* Receives an insertion order and returns the reference to an entry ID
* after which we will insert the new entry
*/
export function getInsertAfterId(
rundown: Rundown,
parent: OntimeGroup | null,
afterId?: EntryId,
beforeId?: EntryId,
afterId?: EntryId | true,
beforeId?: EntryId | true,
): EntryId | null {
if (afterId) return afterId;
if (!beforeId) return null;
if (beforeId) {
const insertionList = parent ? parent.entries : rundown.order;
return beforeId === true ? (insertionList[0] ?? null) : beforeId;
}
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList || insertionList.length === 0) return null;
if (afterId) return afterId === true ? null : afterId;
const atIndex = insertionList.findIndex((id) => id === beforeId);
if (atIndex < 1) return null;
return insertionList[atIndex - 1];
return null;
}
type ResolveInsertParentOptions = {
parent?: EntryId | null;
after?: EntryId;
before?: EntryId;
after?: EntryId | true;
before?: EntryId | true;
};
/**
@@ -393,15 +388,21 @@ type ResolveInsertParentOptions = {
* Uses explicit parent first, then infers from sibling references.
*/
export function resolveInsertParent(rundown: Rundown, options: ResolveInsertParentOptions): EntryId | null {
if (options.parent) {
// 1. if we have a parent reference we return that
if (options.parent !== undefined && options.parent !== null) {
return options.parent;
}
const referenceId = options.after ?? options.before;
if (!referenceId) return null;
// 2. ... otherwise we look for a sibling and get their parent
const referenceId = (() => {
if (typeof options.after === 'string') return options.after;
if (typeof options.before === 'string') return options.before;
return undefined;
})();
if (referenceId === undefined) return null;
const maybeSibling = rundown.entries[referenceId];
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
if (maybeSibling !== undefined && 'parent' in maybeSibling && maybeSibling.parent !== null) {
return maybeSibling.parent;
}
@@ -410,52 +411,57 @@ export function resolveInsertParent(rundown: Rundown, options: ResolveInsertPare
/**
* Add entry to rundown, mutates the rundown in place.
* Handles the following cases:
* - 1a. add entry in group, after a given entry
* - 1b. add entry in group, at the beginning (right after the group header)
* - 2a. add entry to the rundown, after a given entry
* - 2b. add entry to the rundown, at the beginning
* if afterId and beforeId are not provided, we add at the end of the rundown
*/
export function addToRundown(
rundown: Rundown,
entry: OntimeEntry,
afterId: EntryId | null,
parent: OntimeGroup | null,
afterId: EntryId | null,
beforeId: EntryId | null,
): OntimeEntry {
// which list to use, the top level or a group order
const insertionList = parent ? parent.entries : rundown.order;
// the index inside the list
const insertionIndex = (() => {
if (beforeId) return insertionList.indexOf(beforeId);
if (afterId) return insertionList.indexOf(afterId) + 1;
return insertionList.length;
})();
// the index inside the flat order
const flatIndex = (() => {
if (beforeId) return rundown.flatOrder.indexOf(beforeId);
if (afterId) {
const afterEntry = rundown.entries[afterId];
const flatReferenceId =
!parent && isOntimeGroup(afterEntry) && afterEntry.entries?.length > 0
? afterEntry.entries[afterEntry.entries.length - 1]
: afterId;
return rundown.flatOrder.indexOf(flatReferenceId) + 1;
}
if (parent) {
const previousId = insertionList[insertionIndex - 1] ?? parent.id;
return rundown.flatOrder.indexOf(previousId) + 1;
}
return rundown.flatOrder.length;
})();
if (parent) {
// 1. inserting an entry inside a group
// assign the parent reference on the entry
if ('parent' in entry) {
entry.parent = parent.id;
if (isOntimeGroup(entry)) {
throw new Error('Cannot add a group to another group');
}
if (afterId) {
// 1a. insert after a given entry within the group
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
// 1b. insert at the beginning of the group (right after the group header in flatOrder)
parent.entries = insertAtIndex(0, entry.id, parent.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
entry.parent = parent.id;
parent.entries = insertAtIndex(insertionIndex, entry.id, parent.entries);
} else {
// 2. inserting an entry at top level
if (afterId) {
// 2a. insert after a given entry
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
// 2b. insert at the beginning
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
rundown.order = insertAtIndex(insertionIndex, entry.id, rundown.order);
}
rundown.flatOrder = insertAtIndex(flatIndex, entry.id, rundown.flatOrder);
// either way, we register the entry in the entries map
rundown.entries[entry.id] = entry;