mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c8228a19 | |||
| 5cf36f049a | |||
| c6248b0c72 | |||
| 2a890cf2b3 | |||
| a8c611911d | |||
| 8363f06a5c |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -95,22 +95,6 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
|
||||
};
|
||||
});
|
||||
|
||||
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
|
||||
return {
|
||||
aux1: state.auxtimer1.name,
|
||||
aux2: state.auxtimer2.name,
|
||||
aux3: state.auxtimer3.name,
|
||||
};
|
||||
});
|
||||
|
||||
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
|
||||
return (
|
||||
state.auxtimer1.playback === SimplePlayback.Start ||
|
||||
state.auxtimer2.playback === SimplePlayback.Start ||
|
||||
state.auxtimer3.playback === SimplePlayback.Start
|
||||
);
|
||||
});
|
||||
|
||||
export const useAuxTimerTime = (index: number) =>
|
||||
createSelector((state: RuntimeStore) => {
|
||||
if (index === 1) return state.auxtimer1.current;
|
||||
@@ -124,18 +108,15 @@ export const useAuxTimerControl = (index: number) =>
|
||||
return {
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
name: state.auxtimer1.name,
|
||||
};
|
||||
if (index === 2)
|
||||
return {
|
||||
playback: state.auxtimer2.playback,
|
||||
direction: state.auxtimer2.direction,
|
||||
name: state.auxtimer2.name,
|
||||
};
|
||||
return {
|
||||
playback: state.auxtimer3.playback,
|
||||
direction: state.auxtimer3.direction,
|
||||
name: state.auxtimer3.name,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -6,5 +6,4 @@ export const ontimePlaceholderSettings: Settings = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ type EditorSettingsStore = {
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
inheritGroupColour: boolean;
|
||||
auxTimersCollapsed: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
||||
@@ -22,7 +21,6 @@ type EditorSettingsStore = {
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
setDefaultTimerType: (defaultTimerType: TimerType) => void;
|
||||
setDefaultEndAction: (defaultEndAction: EndAction) => void;
|
||||
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
|
||||
};
|
||||
|
||||
export const editorSettingsDefaults = {
|
||||
@@ -34,7 +32,6 @@ export const editorSettingsDefaults = {
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
inheritGroupColour: false,
|
||||
auxTimersCollapsed: false,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
@@ -46,7 +43,6 @@ enum EditorSettingsKeys {
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
||||
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
@@ -71,10 +67,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
EditorSettingsKeys.InheritGroupColour,
|
||||
editorSettingsDefaults.inheritGroupColour,
|
||||
),
|
||||
auxTimersCollapsed: booleanFromLocalStorage(
|
||||
EditorSettingsKeys.AuxTimersCollapsed,
|
||||
editorSettingsDefaults.auxTimersCollapsed,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -118,10 +110,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
||||
return { inheritGroupColour };
|
||||
}),
|
||||
setAuxTimersCollapsed: (auxTimersCollapsed) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
|
||||
return { auxTimersCollapsed };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
|
||||
const custom = name?.trim();
|
||||
return custom ? custom : fallback;
|
||||
}
|
||||
|
||||
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
|
||||
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
|
||||
const custom = name?.trim();
|
||||
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+7
@@ -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));
|
||||
|
||||
+22
-8
@@ -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 && (
|
||||
|
||||
+4
@@ -92,6 +92,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbarWarning {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.mappingPaneTitle {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
+17
-2
@@ -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>
|
||||
|
||||
+102
-24
@@ -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;
|
||||
}
|
||||
|
||||
+126
-14
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
+36
-11
@@ -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,95 +0,0 @@
|
||||
import { Settings } from 'ontime-types';
|
||||
import { auxTimerNameMaxLength } from 'ontime-utils';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function AuxTimerSettings() {
|
||||
const { data, status, refetch } = useSettings();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
} = useForm<Settings>({
|
||||
defaultValues: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: Settings) => {
|
||||
try {
|
||||
await postSettings(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='aux-timer-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Aux timers
|
||||
<Panel.InlineElements>
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import AuxTimerSettings from './AuxTimerSettings';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import McpSection from './McpSection';
|
||||
import ProjectData from './ProjectData';
|
||||
@@ -13,7 +12,6 @@ import ViewSettings from './ViewSettings';
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||
@@ -28,9 +26,6 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={generalRef}>
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
<div ref={auxTimersRef}>
|
||||
<AuxTimerSettings />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ const staticOptions = [
|
||||
secondary: [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__aux-timers', label: 'Aux timers' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
|
||||
@@ -3,34 +3,6 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auxHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.auxHeaderButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.activeIndicator {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
}
|
||||
|
||||
.auxTimers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
||||
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
import AddTime from './add-time/AddTime';
|
||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
@@ -14,9 +8,6 @@ import style from './PlaybackControl.module.scss';
|
||||
|
||||
export default function PlaybackControl() {
|
||||
const data = usePlaybackControl();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
|
||||
const isAuxTimerActive = useAuxTimersActive();
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
@@ -29,42 +20,11 @@ export default function PlaybackControl() {
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<div className={style.auxHeader}>
|
||||
<span className={style.label}>
|
||||
Aux timers
|
||||
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
|
||||
</span>
|
||||
<div className={style.auxHeaderButtons}>
|
||||
<Tooltip
|
||||
text='Name aux timers'
|
||||
render={
|
||||
<IconButton
|
||||
size='small'
|
||||
variant='subtle-white'
|
||||
aria-label='Name aux timers'
|
||||
onClick={() => setLocation('settings__aux-timers')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size='small'
|
||||
variant='subtle-white'
|
||||
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
|
||||
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
|
||||
>
|
||||
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.auxTimers}>
|
||||
<AuxTimer index={1} />
|
||||
<AuxTimer index={2} />
|
||||
<AuxTimer index={3} />
|
||||
</div>
|
||||
{!auxTimersCollapsed && (
|
||||
<div className={style.auxTimers}>
|
||||
<AuxTimer index={1} />
|
||||
<AuxTimer index={2} />
|
||||
<AuxTimer index={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
// aux timers sit in a 3 column grid, without this a long name would grow its column
|
||||
// instead of shrinking to it, breaking the equal column layout
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
display: block;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -3,9 +3,7 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
|
||||
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
import style from './AuxTimer.module.scss';
|
||||
@@ -15,12 +13,10 @@ interface AuxTimerProps {
|
||||
}
|
||||
|
||||
export function AuxTimer({ index }: AuxTimerProps) {
|
||||
const { playback, direction, name } = useAuxTimerControl(index);
|
||||
const { playback, direction } = useAuxTimerControl(index);
|
||||
|
||||
const { stop, setDirection } = setAuxTimer;
|
||||
|
||||
const label = getAuxTimerIndexedLabel(name, index);
|
||||
|
||||
const toggleDirection = () => {
|
||||
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
|
||||
setDirection(index, newDirection);
|
||||
@@ -31,12 +27,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
|
||||
|
||||
return (
|
||||
<label className={style.label}>
|
||||
<Tooltip text={label} render={<span />} className={style.labelText}>
|
||||
{label}
|
||||
</Tooltip>
|
||||
Aux Timer {index}
|
||||
<div className={style.controls}>
|
||||
<div className={style.input}>
|
||||
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
|
||||
<AuxTimerInput index={index} isActive={isActive} />
|
||||
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
|
||||
@@ -56,10 +50,9 @@ export function AuxTimer({ index }: AuxTimerProps) {
|
||||
interface AuxTimerInputProps {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
|
||||
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
|
||||
const newTimeInMs = useAuxTimerTime(index);
|
||||
const { setDuration } = setAuxTimer;
|
||||
|
||||
@@ -77,7 +70,7 @@ function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
|
||||
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
+9
-26
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Playback, TimerPhase, ViewSettings } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useAuxTimersName, useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
|
||||
import { getAuxTimerLabel } from '../../common/utils/auxTimerUtils';
|
||||
import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
|
||||
import { getOffsetState } from '../../common/utils/offset';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -122,23 +121,22 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
|
||||
|
||||
function StudioTimersAux() {
|
||||
const auxTimer = useAuxTimersTime();
|
||||
const auxName = useAuxTimersName();
|
||||
|
||||
return (
|
||||
<div className='card' id='card-aux'>
|
||||
<div className='card__row'>
|
||||
<div>
|
||||
<div className='label'>{getAuxTimerLabel(auxName.aux1, 'Aux 1')}</div>
|
||||
<div className='label'>Aux 1</div>
|
||||
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='label center'>{getAuxTimerLabel(auxName.aux2, 'Aux 2')}</div>
|
||||
<div className='label center'>Aux 2</div>
|
||||
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='label right'>{getAuxTimerLabel(auxName.aux3, 'Aux 3')}</div>
|
||||
<div className='label right'>Aux 3</div>
|
||||
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,14 +90,4 @@ describe('test parseDatabaseModel() edge cases', () => {
|
||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||
expect(() => parseDatabaseModel('some random dataset')).toThrow();
|
||||
});
|
||||
|
||||
it('creates the aux timer names when importing a project file which predates the feature', () => {
|
||||
const oldProject = structuredClone(demoDb);
|
||||
// @ts-expect-error -- simulating a project file saved before aux timer naming existed
|
||||
delete oldProject.settings.auxTimerNames;
|
||||
|
||||
const { data } = parseDatabaseModel(oldProject);
|
||||
|
||||
expect(data.settings.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,15 +74,7 @@ export function migrateSettings(jsonData: object): (Settings & { serverPort: num
|
||||
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
||||
jsonData.settings,
|
||||
) as old_Settings;
|
||||
return {
|
||||
version: '4.0.0',
|
||||
serverPort,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { is } from '../../../utils/is.js';
|
||||
|
||||
@@ -24,7 +23,6 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||
const operatorKey = settings?.operatorKey;
|
||||
const timeFormat = settings?.timeFormat;
|
||||
const language = settings?.language;
|
||||
const auxTimerNames = sanitiseAuxTimerNames(settings?.auxTimerNames);
|
||||
const version = '4.5.0';
|
||||
db.settings = {
|
||||
version,
|
||||
@@ -32,7 +30,6 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
auxTimerNames,
|
||||
app: 'ontime',
|
||||
} as Settings;
|
||||
return { db, serverPort: settings?.serverPort };
|
||||
|
||||
@@ -184,7 +184,6 @@ describe('v3 to v4', () => {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
const newSettings = v3.migrateSettings(oldDb);
|
||||
expect(newSettings).toEqual(expectSettings);
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =======================
|
||||
|
||||
@@ -16,36 +16,6 @@ describe('parseSettings()', () => {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
});
|
||||
});
|
||||
|
||||
it('carries custom aux timer names through and pads to a length-3 array', () => {
|
||||
const result = parseSettings({
|
||||
settings: { version: '1', auxTimerNames: ['Speaker'] } as unknown as Settings,
|
||||
});
|
||||
expect(result.auxTimerNames).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('falls back to defaults when aux timer names are missing or malformed', () => {
|
||||
const result = parseSettings({
|
||||
settings: { version: '1', auxTimerNames: 'not-an-array' } as unknown as Settings,
|
||||
});
|
||||
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
});
|
||||
|
||||
it('creates the aux timer names for project files made before the feature existed', () => {
|
||||
const oldSettings = {
|
||||
version: '4.5.0',
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
const result = parseSettings({ settings: oldSettings as Settings });
|
||||
|
||||
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { getPartialProject } from '../../models/dataModel.js';
|
||||
|
||||
@@ -23,7 +22,5 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
||||
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
||||
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
||||
language: data.settings.language ?? defaultSettings.language,
|
||||
// older project files predate this property
|
||||
auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from '../../services/aux-timer-service/AuxTimerService.js';
|
||||
import { validateSettings, validateWelcomeDialog, validateServerPort } from './settings.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -42,10 +41,6 @@ router.post('/', validateSettings, async (req: Request, res: Response<Settings |
|
||||
|
||||
if (!deepEqual(data, settings)) {
|
||||
await getDataProvider().setSettings(data);
|
||||
// keep the runtime aux timers in sync so consumers get the new names live
|
||||
if (!deepEqual(data.auxTimerNames, settings.auxTimerNames)) {
|
||||
auxTimerService.loadNames(data.auxTimerNames);
|
||||
}
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { body } from 'express-validator';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
|
||||
@@ -28,7 +27,6 @@ export const validateSettings = [
|
||||
pinValidator('operatorKey'),
|
||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||
body('language').isString().trim().notEmpty(),
|
||||
body('auxTimerNames').isArray().withMessage('auxTimerNames must be an array').customSanitizer(sanitiseAuxTimerNames),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -63,6 +63,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 +138,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)}`);
|
||||
@@ -259,20 +268,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 +317,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 +326,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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { LogOrigin, SimpleDirection, SimplePlayback, runtimeStorePlaceholder } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
import serverTiming from 'server-timing';
|
||||
|
||||
import { oscServer } from './adapters/OscAdapter.js';
|
||||
@@ -27,7 +26,6 @@ import { bodyParser } from './middleware/bodyParser.js';
|
||||
import { compressedStatic } from './middleware/staticGZip.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from './services/aux-timer-service/AuxTimerService.js';
|
||||
import * as messageService from './services/message-service/message.service.js';
|
||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||
import { restoreService } from './services/restore-service/restore.service.js';
|
||||
@@ -206,7 +204,6 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
*/
|
||||
const state = getState();
|
||||
const [auxName1, auxName2, auxName3] = sanitiseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
|
||||
eventStore.init({
|
||||
clock: state.clock,
|
||||
timer: state.timer,
|
||||
@@ -222,28 +219,22 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName1,
|
||||
},
|
||||
auxtimer2: {
|
||||
duration: timerConfig.auxTimerDefault,
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName2,
|
||||
},
|
||||
auxtimer3: {
|
||||
duration: timerConfig.auxTimerDefault,
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName3,
|
||||
},
|
||||
ping: 1,
|
||||
});
|
||||
|
||||
// AuxTimerService owns its own SimpleTimer instances, so the store above doesn't update them
|
||||
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
|
||||
|
||||
// initialise message service
|
||||
messageService.init(eventStore.set, eventStore.get);
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('safeMerge', () => {
|
||||
editorKey: null,
|
||||
timeFormat: baseDb.settings.timeFormat,
|
||||
language: 'pt',
|
||||
auxTimerNames: baseDb.settings.auxTimerNames,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ export class SimpleTimer {
|
||||
current: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: '',
|
||||
};
|
||||
private startedAt: number | null = null;
|
||||
private pausedAt: number | null = null;
|
||||
@@ -24,16 +23,9 @@ export class SimpleTimer {
|
||||
current: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
// the name is a persisted configuration, independent of the timer runtime
|
||||
name: this.state.name,
|
||||
};
|
||||
}
|
||||
|
||||
public setName(name: string): SimpleTimerState {
|
||||
this.state.name = name;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration of the timer
|
||||
* @param time - time in milliseconds
|
||||
|
||||
@@ -16,7 +16,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -28,7 +27,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -40,7 +38,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime - 100,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
|
||||
@@ -61,7 +58,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime - 1500,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Pause,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
|
||||
@@ -87,7 +83,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -102,7 +97,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
|
||||
newState = timer.update(100);
|
||||
@@ -133,7 +127,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1000,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(100);
|
||||
@@ -142,7 +135,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime + 100,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(500);
|
||||
@@ -151,7 +143,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.setDirection(SimpleDirection.CountDown, 600);
|
||||
@@ -160,7 +151,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(700);
|
||||
@@ -169,7 +159,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1400,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.setDirection(SimpleDirection.CountUp, 700);
|
||||
@@ -178,7 +167,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1400,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(800);
|
||||
@@ -187,7 +175,6 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs } from 'ontime-utils';
|
||||
|
||||
/** Returns the current instant */
|
||||
export function now(): Instant {
|
||||
@@ -59,6 +59,10 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
|
||||
return (instant + totalDuration) as Instant;
|
||||
}
|
||||
|
||||
export function combineValues<T extends Duration | Day>(...duration: T[]): T {
|
||||
return Array.isArray(duration) ? (duration.reduce<number>((total, current) => total + current, 0) as T) : duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates elapsed time on the clock from a starting time to the current time
|
||||
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
|
||||
@@ -80,3 +84,15 @@ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day
|
||||
|
||||
return (currentDaySerial - startDaySerial) as Day;
|
||||
}
|
||||
|
||||
export const ZERO_DURATION = 0 as Duration;
|
||||
|
||||
/**
|
||||
* utility function to generate Instant times
|
||||
*/
|
||||
export function generateInstanceFromClock(hour: number, minutes: number, second: number, ms = 0) {
|
||||
return toInstant(
|
||||
(hour * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + second * MILLIS_PER_SECOND + ms) as TimeOfDay,
|
||||
now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ const dbModel: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
|
||||
@@ -29,7 +29,6 @@ export const demoDb: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ff7300',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { Duration, EndAction, Playback, 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';
|
||||
import * as timeCore from '../../lib/time-core/timeCore.js';
|
||||
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
getCurrent,
|
||||
@@ -19,48 +20,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
|
||||
describe('getElapsed()', () => {
|
||||
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
||||
const state = {
|
||||
clock: 5 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.generateInstanceFromClock(0, 5, 0),
|
||||
timer: {
|
||||
addedTime: -10 * MILLIS_PER_MINUTE,
|
||||
current: 15 * MILLIS_PER_MINUTE,
|
||||
duration: 20 * MILLIS_PER_MINUTE,
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('subtracts accumulated pause time', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.generateInstanceFromClock(0, 10, 0),
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
pausedDuration: 5 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('uses the current pause start while paused', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.generateInstanceFromClock(0, 10, 0),
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
@@ -82,7 +83,7 @@ describe('getExpectedFinish()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(null);
|
||||
});
|
||||
@@ -95,13 +96,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 10,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: true,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(20);
|
||||
});
|
||||
@@ -114,13 +115,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 10,
|
||||
startedAt: 1,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(11);
|
||||
});
|
||||
@@ -133,13 +134,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 20,
|
||||
duration: 10,
|
||||
startedAt: 1,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(31);
|
||||
@@ -153,13 +154,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: -10,
|
||||
duration: 10,
|
||||
startedAt: 1,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(1);
|
||||
@@ -173,13 +174,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: -100,
|
||||
duration: 10,
|
||||
startedAt: 1,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(1);
|
||||
@@ -193,13 +194,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 0,
|
||||
startedAt: 1,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(1);
|
||||
@@ -213,13 +214,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(10);
|
||||
@@ -234,13 +235,13 @@ describe('getExpectedFinish()', () => {
|
||||
timer: {
|
||||
addedTime: 10,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(40);
|
||||
@@ -253,7 +254,7 @@ describe('getExpectedFinish()', () => {
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
@@ -263,7 +264,7 @@ describe('getExpectedFinish()', () => {
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
// expected finish is not a duration but a point in time
|
||||
@@ -279,7 +280,7 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 30,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 0,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
timer: {
|
||||
addedTime: 10,
|
||||
duration: 111, // <-- we take the duration value
|
||||
@@ -289,7 +290,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(111);
|
||||
@@ -300,17 +301,17 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 1,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 10,
|
||||
startedAt: 0,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(9);
|
||||
@@ -321,17 +322,17 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 1,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
|
||||
timer: {
|
||||
addedTime: 10,
|
||||
duration: 10,
|
||||
startedAt: 0,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(19);
|
||||
@@ -342,17 +343,17 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 20,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 10,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs + 10,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(dayInMs + 10);
|
||||
@@ -363,17 +364,17 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 20,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 5,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs + 10,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(15);
|
||||
@@ -384,17 +385,17 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 20,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 5,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
|
||||
timer: {
|
||||
addedTime: 20,
|
||||
duration: dayInMs + 10,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(35);
|
||||
@@ -407,7 +408,7 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 100,
|
||||
@@ -420,7 +421,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(70);
|
||||
@@ -432,11 +433,11 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: 100,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
rundown: {
|
||||
plannedEnd: 100,
|
||||
@@ -445,7 +446,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(70);
|
||||
@@ -457,11 +458,11 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
|
||||
timer: {
|
||||
addedTime: 7,
|
||||
duration: 100,
|
||||
startedAt: 10,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
|
||||
},
|
||||
rundown: {
|
||||
plannedEnd: 100,
|
||||
@@ -470,7 +471,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(77);
|
||||
@@ -483,11 +484,11 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
_now: timeCore.generateInstanceFromClock(22, 5, 0),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity, // not relevant,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 79200000,
|
||||
@@ -497,7 +498,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(dayInMs - 79500000 + 600000);
|
||||
@@ -505,7 +506,7 @@ describe('getCurrent()', () => {
|
||||
|
||||
it('handles events that were started late', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
eventNow: {
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
@@ -515,7 +516,7 @@ describe('getCurrent()', () => {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity, // not relevant,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 82000000, // 22:46:40 <--- started now
|
||||
@@ -525,7 +526,7 @@ describe('getCurrent()', () => {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
|
||||
@@ -541,17 +542,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 0,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration,
|
||||
startedAt: 0,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const expectedFinish = getExpectedFinish(state);
|
||||
const current = getCurrent(state);
|
||||
@@ -569,25 +570,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
|
||||
timeEnd: 10,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
clock: 5,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
|
||||
timer: {
|
||||
addedTime: 3,
|
||||
duration,
|
||||
startedAt: 0,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const expectedFinish = getExpectedFinish(state);
|
||||
const current = getCurrent(state);
|
||||
|
||||
const elapsed = duration - current;
|
||||
expect(expectedFinish).toBe(13);
|
||||
expect(elapsed).toBe(2);
|
||||
expect(current).toBe(8);
|
||||
expect(elapsed).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -871,7 +872,7 @@ describe('getRuntimeOffset()', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
@@ -883,9 +884,9 @@ describe('getRuntimeOffset()', () => {
|
||||
plannedStart: 100,
|
||||
currentDay: 0,
|
||||
},
|
||||
clock: 150,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(50);
|
||||
@@ -899,7 +900,7 @@ describe('getRuntimeOffset()', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150, // we started 50ms delayed
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed
|
||||
addedTime: 10, // we compensated with 10ms
|
||||
current: 10, // we are 10ms into the timer
|
||||
},
|
||||
@@ -912,7 +913,7 @@ describe('getRuntimeOffset()', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(60);
|
||||
@@ -927,7 +928,7 @@ describe('getRuntimeOffset()', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 100, // we started ontime
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime
|
||||
current: -10, // we are 10 seconds over
|
||||
addedTime: 0, // we have not compensated with added time
|
||||
},
|
||||
@@ -940,7 +941,7 @@ describe('getRuntimeOffset()', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(10);
|
||||
@@ -954,9 +955,9 @@ describe('getRuntimeOffset()', () => {
|
||||
timeEnd: 150,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 150,
|
||||
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
|
||||
timer: {
|
||||
startedAt: 100, // started on time
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time
|
||||
current: 25, // are 25ms into it
|
||||
addedTime: 0,
|
||||
},
|
||||
@@ -969,7 +970,7 @@ describe('getRuntimeOffset()', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(25);
|
||||
@@ -977,7 +978,7 @@ describe('getRuntimeOffset()', () => {
|
||||
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000,
|
||||
@@ -1011,7 +1012,7 @@ describe('getRuntimeOffset()', () => {
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(0);
|
||||
@@ -1019,7 +1020,7 @@ describe('getRuntimeOffset()', () => {
|
||||
|
||||
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20
|
||||
_now: timeCore.generateInstanceFromClock(22, 13, 20),
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
type: 'event',
|
||||
@@ -1063,11 +1064,11 @@ describe('getRuntimeOffset()', () => {
|
||||
expectedFinish: 81600000,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(0);
|
||||
@@ -1075,7 +1076,7 @@ describe('getRuntimeOffset()', () => {
|
||||
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
_now: timeCore.generateInstanceFromClock(22, 46, 40),
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
type: 'event',
|
||||
@@ -1119,19 +1120,19 @@ describe('getRuntimeOffset()', () => {
|
||||
expectedFinish: 81600000,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 78000000,
|
||||
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
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', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
|
||||
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16m 40s after the scheduled end
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
@@ -1164,14 +1165,14 @@ describe('getRuntimeOffset()', () => {
|
||||
expectedFinish: 82000000 + 3600000, // <--- now + duration
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 82000000, // <--- started now
|
||||
startedAt: timeCore.generateInstanceFromClock(22, 46, 40),
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const updateCurrent = getCurrent(state);
|
||||
state.timer.current = updateCurrent;
|
||||
state.timer.current = updateCurrent as Duration;
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(millisToString(absolute)).toBe('00:16:40');
|
||||
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
|
||||
@@ -1187,7 +1188,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
@@ -1200,7 +1201,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute, relative } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(0);
|
||||
@@ -1214,7 +1215,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
@@ -1227,7 +1228,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute, relative } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(50);
|
||||
@@ -1241,7 +1242,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 100,
|
||||
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100),
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
@@ -1254,7 +1255,7 @@ describe('getRuntimeOffset() relative', () => {
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const { absolute, relative } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(-50);
|
||||
@@ -1276,7 +1277,7 @@ describe('getTimerPhase()', () => {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.None);
|
||||
@@ -1294,7 +1295,7 @@ describe('getTimerPhase()', () => {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Overtime);
|
||||
@@ -1312,7 +1313,7 @@ describe('getTimerPhase()', () => {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Danger);
|
||||
@@ -1330,7 +1331,7 @@ describe('getTimerPhase()', () => {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Warning);
|
||||
@@ -1348,7 +1349,7 @@ describe('getTimerPhase()', () => {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Default);
|
||||
@@ -1356,7 +1357,7 @@ describe('getTimerPhase()', () => {
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
rundown: {
|
||||
@@ -1382,13 +1383,13 @@ describe('getTimerPhase()', () => {
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
forceFinish: false,
|
||||
pausedAt: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
@@ -1396,7 +1397,7 @@ describe('getTimerPhase()', () => {
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
rundown: {
|
||||
@@ -1422,13 +1423,13 @@ describe('getTimerPhase()', () => {
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
forceFinish: false,
|
||||
pausedAt: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
|
||||
import { timerConfig } from '../../setup/config.js';
|
||||
@@ -25,20 +24,6 @@ export class AuxTimerService {
|
||||
this.getTime = getTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at bootstrap and whenever the loaded project's settings change,
|
||||
* so the running timers reflect the current project's aux timer names.
|
||||
*/
|
||||
loadNames(names?: string[]) {
|
||||
const [name1, name2, name3] = sanitiseAuxTimerNames(names);
|
||||
const patch: AuxTimerStateUpdate = {
|
||||
auxtimer1: this.aux1.setName(name1),
|
||||
auxtimer2: this.aux2.setName(name2),
|
||||
auxtimer3: this.aux3.setName(name3),
|
||||
};
|
||||
this.emit(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the aux timers are currently running
|
||||
*/
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { AuxTimerService } from '../AuxTimerService.js';
|
||||
|
||||
describe('AuxTimerService', () => {
|
||||
describe('loadNames()', () => {
|
||||
it('applies the names to each aux timer and broadcasts them', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['Speaker', 'Break', 'Q&A']);
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('Speaker');
|
||||
expect(patch.auxtimer2?.name).toBe('Break');
|
||||
expect(patch.auxtimer3?.name).toBe('Q&A');
|
||||
});
|
||||
|
||||
it('defaults missing names to an empty string', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['only-one']);
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('only-one');
|
||||
expect(patch.auxtimer2?.name).toBe('');
|
||||
expect(patch.auxtimer3?.name).toBe('');
|
||||
});
|
||||
|
||||
it('handles names missing from a project file', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
expect(() => service.loadNames(undefined)).not.toThrow();
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('');
|
||||
expect(patch.auxtimer2?.name).toBe('');
|
||||
expect(patch.auxtimer3?.name).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the name on the timer through subsequent commands', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['Speaker', '', '']);
|
||||
const started = service.start(1);
|
||||
|
||||
expect(started.name).toBe('Speaker');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
||||
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
removeFileExtension,
|
||||
} from '../../utils/fileManagement.js';
|
||||
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
|
||||
import { runtimeService } from '../runtime-service/runtime.service.js';
|
||||
import {
|
||||
doesProjectExist,
|
||||
@@ -90,16 +88,12 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
|
||||
// stop the runtime service
|
||||
runtimeService.stop();
|
||||
|
||||
// AuxTimerService holds its own state, independent of the loaded project, so it needs to be updated explicitly
|
||||
auxTimerService.loadNames(projectData.settings.auxTimerNames);
|
||||
|
||||
// load the rundown given by key otherwise load the first in the project
|
||||
const rundown =
|
||||
rundownId && rundownId in projectData.rundowns
|
||||
? projectData.rundowns[rundownId]
|
||||
: getFirstRundown(projectData.rundowns);
|
||||
|
||||
// initialising the rundown with reload sends a refetch to the clients
|
||||
await initRundown(rundown, projectData.customFields, true);
|
||||
|
||||
// persist the project selection
|
||||
@@ -352,12 +346,6 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
|
||||
// we can pass some stuff straight to the data provider
|
||||
await getDataProvider().mergeIntoData(rest);
|
||||
|
||||
// AuxTimerService holds its own state, so a settings patch needs to be applied to it explicitly
|
||||
if (rest.settings) {
|
||||
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
// the rundown depends on custom fields
|
||||
// so custom fields needs to be checked first
|
||||
if (customFields) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
|
||||
import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
|
||||
import { normaliseEndTime } from './timerUtils.js';
|
||||
|
||||
// import * as timeCore from '../lib/time-core/timeCore.js';
|
||||
|
||||
/**
|
||||
* Finds current event in a rolling rundown
|
||||
*/
|
||||
|
||||
@@ -760,16 +760,17 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
|
||||
// save the restore state
|
||||
if (hasImmediateChanges) {
|
||||
const internalState = runtimeState.getInternalState();
|
||||
restoreService
|
||||
.save({
|
||||
playback: state.timer.playback,
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
startedAt: state.timer.startedAt,
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
pausedDuration: state._timer.pausedDuration,
|
||||
pausedAt: internalState._timer.pausedAt,
|
||||
pausedDuration: internalState._timer.pausedDuration,
|
||||
firstStart: state.rundown.actualStart,
|
||||
startEpoch: state._startEpoch,
|
||||
startEpoch: internalState._startEpoch,
|
||||
currentDay: state.rundown.currentDay,
|
||||
})
|
||||
.catch((_e) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { Day, Duration, Maybe, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
import * as timeCore from '../lib/time-core/timeCore.js';
|
||||
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
|
||||
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
@@ -20,48 +22,46 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
|
||||
|
||||
/**
|
||||
* Calculates expected finish time of a running timer
|
||||
* @param {RuntimeState} state runtime state
|
||||
* @param {InternalRuntimeState} state runtime state
|
||||
* @returns {number | null} new current time or null if nothing is running
|
||||
*/
|
||||
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
export function getExpectedFinish(state: InternalRuntimeState): Maybe<TimeOfDay> {
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
|
||||
if (state.eventNow === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { countToEnd, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
const { clock } = state;
|
||||
const { _now } = state;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
||||
|
||||
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0;
|
||||
if (countToEnd) {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
return (timeEnd + addedTime + pausedTime) as TimeOfDay;
|
||||
}
|
||||
|
||||
// handle events that finish the day after
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
||||
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer
|
||||
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime;
|
||||
|
||||
if (expectedFinish > dayInMs) {
|
||||
return expectedFinish - dayInMs;
|
||||
return (expectedFinish - dayInMs) as TimeOfDay;
|
||||
}
|
||||
|
||||
// an event cannot finish before it started (user added too much negative time)
|
||||
return Math.max(expectedFinish, startedAt);
|
||||
return Math.max(expectedFinish, timeCore.toTimeOfDay(startedAt)) as TimeOfDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates running countdown
|
||||
* @param {RuntimeState} state runtime state
|
||||
* @param {InternalRuntimeState} state runtime state
|
||||
* @returns {number} current time for timer
|
||||
*/
|
||||
|
||||
export function getCurrent(state: RuntimeState): number {
|
||||
export function getCurrent(state: InternalRuntimeState): Duration {
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (state.eventNow === null || state.timer.duration === null) {
|
||||
@@ -71,12 +71,12 @@ export function getCurrent(state: RuntimeState): number {
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
const { countToEnd, timeStart, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
const { clock } = state;
|
||||
const { _now } = state;
|
||||
|
||||
if (countToEnd) {
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration;
|
||||
}
|
||||
|
||||
if (startedAt === null) {
|
||||
@@ -84,19 +84,19 @@ export function getCurrent(state: RuntimeState): number {
|
||||
}
|
||||
|
||||
if (pausedAt != null) {
|
||||
return startedAt + duration + addedTime - pausedAt;
|
||||
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - pausedAt) as Duration;
|
||||
}
|
||||
|
||||
const hasPassedMidnight = startedAt > clock;
|
||||
const hasPassedMidnight = timeCore.toTimeOfDay(startedAt) > timeCore.toTimeOfDay(_now);
|
||||
const correctDay = hasPassedMidnight ? dayInMs : 0;
|
||||
return startedAt + duration + addedTime - clock - correctDay;
|
||||
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - timeCore.toTimeOfDay(_now) - correctDay) as Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates active time elapsed since the timer started.
|
||||
*/
|
||||
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
const { clock } = state;
|
||||
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
|
||||
const { _now } = state;
|
||||
const { startedAt } = state.timer;
|
||||
const { pausedAt, pausedDuration } = state._timer;
|
||||
|
||||
@@ -104,11 +104,11 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceClock = pausedAt ?? clock;
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now);
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt));
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||
|
||||
return Math.max(0, activeElapsed);
|
||||
return Math.max(0, activeElapsed) as Duration;
|
||||
}
|
||||
|
||||
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||
@@ -127,7 +127,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
|
||||
// we cant have skipped if we havent started
|
||||
// we cant have skipped if we haven't started
|
||||
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -151,12 +151,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
|
||||
* Positive offset is over time / behind schedule
|
||||
* Negative offset is under time / ahead of schedule
|
||||
*/
|
||||
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
|
||||
const { eventNow, clock, _startDayOffset } = state;
|
||||
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } {
|
||||
const { eventNow, _now, _startDayOffset } = state;
|
||||
const { addedTime, current, startedAt } = state.timer;
|
||||
// nothing to calculate if there are no loaded events or if we havent started
|
||||
if (eventNow === null || startedAt === null || _startDayOffset === null) {
|
||||
return { absolute: 0, relative: 0 };
|
||||
return { absolute: ZERO_DURATION, relative: ZERO_DURATION };
|
||||
}
|
||||
|
||||
const { countToEnd, timeStart, dayOffset } = eventNow;
|
||||
@@ -171,29 +171,32 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
||||
}
|
||||
|
||||
// difference between planned event start and actual event start (will be positive if we started behind)
|
||||
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
|
||||
const eventStartOffset =
|
||||
timeCore.toTimeOfDay(startedAt) + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
|
||||
|
||||
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
|
||||
const overtime = Math.abs(Math.min(current, 0));
|
||||
|
||||
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : timeCore.toTimeOfDay(_now) - 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
|
||||
) as Duration;
|
||||
|
||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||
const relative = (absolute + plannedStart - actualStart - _startDayOffset * dayInMs) as Duration;
|
||||
|
||||
// in case of count to end, the absolute offset is just the overtime
|
||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks running timer to see which phase it currently is in
|
||||
* @param state
|
||||
*/
|
||||
export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
|
||||
if (!isPlaybackActive(state.timer.playback)) {
|
||||
return TimerPhase.None;
|
||||
}
|
||||
@@ -223,7 +226,7 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
|
||||
/**
|
||||
* Finds the day offset relative to an event start
|
||||
* used byt the runtimeState on first start to get correct offsets
|
||||
* used by the RuntimeState on first start to get correct offsets
|
||||
*/
|
||||
export function findDayOffset(plannedStart: number, clock: number): Day {
|
||||
const distance = clock - plannedStart;
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { Day, type Duration, type Instant, OffsetMode, Playback, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../runtimeState.js';
|
||||
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js';
|
||||
import type { InternalRuntimeState, RuntimeState } from '../runtimeState.js';
|
||||
|
||||
const baseState: RuntimeState = {
|
||||
clock: 0 as TimeOfDay,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
groupNow: null,
|
||||
rundown: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
actualGroupStart: null,
|
||||
currentDay: 0,
|
||||
},
|
||||
offset: {
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
expectedRundownEnd: null,
|
||||
expectedGroupEnd: null,
|
||||
expectedFlagStart: null,
|
||||
},
|
||||
export const runtimePlaceholder = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
addedTime: ZERO_DURATION,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: null,
|
||||
@@ -37,10 +16,43 @@ const baseState: RuntimeState = {
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
offset: {
|
||||
absolute: ZERO_DURATION,
|
||||
relative: ZERO_DURATION,
|
||||
mode: OffsetMode.Absolute,
|
||||
expectedGroupEnd: null,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
},
|
||||
rundown: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
plannedStart: 0 as TimeOfDay,
|
||||
plannedEnd: 0 as TimeOfDay,
|
||||
actualStart: null,
|
||||
actualGroupStart: null,
|
||||
|
||||
currentDay: 0 as Day,
|
||||
},
|
||||
} as Readonly<InternalRuntimeState>;
|
||||
|
||||
const baseState: InternalRuntimeState = {
|
||||
_now: 0 as Instant,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
groupNow: null,
|
||||
rundown: {
|
||||
...runtimePlaceholder.rundown,
|
||||
},
|
||||
offset: {
|
||||
...runtimePlaceholder.offset,
|
||||
},
|
||||
timer: { ...runtimePlaceholder.timer },
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
forceFinish: false,
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
pausedDuration: 0 as Duration,
|
||||
secondaryTarget: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
|
||||
@@ -6,9 +6,10 @@ import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
|
||||
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
import {
|
||||
type RuntimeState,
|
||||
type InternalRuntimeState,
|
||||
addTime,
|
||||
clearState,
|
||||
getInternalState,
|
||||
getState,
|
||||
load,
|
||||
loadGroupFlagAndEnd,
|
||||
@@ -32,7 +33,7 @@ const mockEvent = {
|
||||
} as PlayableEvent;
|
||||
|
||||
const mockState = {
|
||||
clock: 666,
|
||||
_now: 666,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
rundown: {
|
||||
@@ -53,7 +54,7 @@ const mockState = {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
@@ -108,34 +109,34 @@ describe('mutation on runtimeState', () => {
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
load(mockEvent, rundown, metadata);
|
||||
let newState = getState();
|
||||
let newState = getInternalState();
|
||||
expect(newState.eventNow?.id).toBe(mockEvent.id);
|
||||
expect(newState.eventNext?.id).toBe('event2');
|
||||
expect(newState.timer.playback).toBe(Playback.Armed);
|
||||
expect(newState.clock).not.toBe(666);
|
||||
expect(getState().clock).not.toBe(666);
|
||||
expect(newState.groupNow).toBeNull();
|
||||
|
||||
// 2. Start event
|
||||
vi.setSystemTime('jan 1 00:02');
|
||||
let success = start();
|
||||
newState = getState();
|
||||
newState = getInternalState();
|
||||
expect(success).toBe(true);
|
||||
expect(newState.timer).toMatchObject({
|
||||
playback: Playback.Play,
|
||||
});
|
||||
expect(newState.rundown.actualStart).toBe(newState.clock);
|
||||
expect(newState.rundown.actualStart).toBe(getState().clock);
|
||||
|
||||
// 3. Pause event
|
||||
vi.setSystemTime('jan 1 00:03');
|
||||
success = pause();
|
||||
newState = getState();
|
||||
newState = getInternalState();
|
||||
expect(success).toBe(true);
|
||||
expect(newState.clock).not.toBe(666);
|
||||
expect(getState().clock).not.toBe(666);
|
||||
expect(newState.timer).toMatchObject({
|
||||
playback: Playback.Pause,
|
||||
addedTime: 0,
|
||||
});
|
||||
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
||||
expect(newState._timer.pausedAt).toEqual(getState().clock);
|
||||
|
||||
success = pause();
|
||||
expect(success).toBe(false);
|
||||
@@ -143,7 +144,7 @@ describe('mutation on runtimeState', () => {
|
||||
// 4. Restart event
|
||||
vi.setSystemTime('jan 1 00:04');
|
||||
success = start();
|
||||
newState = getState();
|
||||
newState = getInternalState();
|
||||
expect(success).toBe(true);
|
||||
expect(newState.timer).toMatchObject({
|
||||
playback: Playback.Play,
|
||||
@@ -163,7 +164,7 @@ describe('mutation on runtimeState', () => {
|
||||
// 5. Stop event
|
||||
vi.setSystemTime('jan 1 00:05');
|
||||
success = stop();
|
||||
newState = getState();
|
||||
newState = getInternalState();
|
||||
expect(success).toBe(true);
|
||||
expect(newState.eventNow).toBe(null);
|
||||
expect(newState.timer).toMatchObject({
|
||||
@@ -825,7 +826,7 @@ describe('roll mode', () => {
|
||||
test('pending event', () => {
|
||||
const { rundown, metadata } = rundownCache.get();
|
||||
const { eventId, didStart } = roll(rundown, metadata);
|
||||
const state = getState();
|
||||
const state = getInternalState();
|
||||
|
||||
expect(eventId).toBe('1');
|
||||
expect(didStart).toBe(false);
|
||||
@@ -944,7 +945,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[11],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
@@ -972,7 +973,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
groupNow: rundown.entries[1],
|
||||
eventNow: rundown.entries[22],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
@@ -1000,7 +1001,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
groupNow: rundown.entries[1],
|
||||
eventNow: rundown.entries[0],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
@@ -1025,7 +1026,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[0],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
} as InternalRuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
@@ -1036,4 +1037,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 InternalRuntimeState;
|
||||
|
||||
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 InternalRuntimeState;
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,12 @@ import {
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
isOntimeEvent,
|
||||
runtimeStorePlaceholder,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
calculateDuration,
|
||||
checkIsNow,
|
||||
dayInMs,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
getLastEventNormal,
|
||||
isPlaybackActive,
|
||||
@@ -43,14 +43,18 @@ import {
|
||||
hasCrossedMidnight,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
|
||||
|
||||
type ExpectedMetadata = {
|
||||
event: OntimeEvent;
|
||||
accumulatedGap: number;
|
||||
accumulatedGap: Duration;
|
||||
isLinkedToLoaded: boolean;
|
||||
} | null;
|
||||
|
||||
export type RuntimeState = {
|
||||
/**
|
||||
* @deprecated we want to phase out the 24h clock
|
||||
*/
|
||||
clock: TimeOfDay;
|
||||
groupNow: OntimeGroup | null;
|
||||
eventNow: PlayableEvent | null;
|
||||
@@ -60,36 +64,72 @@ export type RuntimeState = {
|
||||
timer: TimerState;
|
||||
rundown: RundownState;
|
||||
// private properties of the timer calculations
|
||||
};
|
||||
|
||||
export type InternalRuntimeState = Omit<RuntimeState, 'clock' | 'timer' | 'offset' | 'rundown'> & {
|
||||
timer: {
|
||||
addedTime: Duration;
|
||||
current: Maybe<Duration>;
|
||||
duration: Maybe<Duration>;
|
||||
elapsed: Maybe<Duration>;
|
||||
expectedFinish: Maybe<TimeOfDay>;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: Maybe<Duration>;
|
||||
startedAt: Maybe<Instant>;
|
||||
};
|
||||
offset: {
|
||||
absolute: Duration;
|
||||
relative: Duration;
|
||||
mode: OffsetMode;
|
||||
expectedGroupEnd: Maybe<TimeOfDay>;
|
||||
expectedRundownEnd: Maybe<TimeOfDay>;
|
||||
expectedFlagStart: Maybe<TimeOfDay>;
|
||||
};
|
||||
rundown: {
|
||||
selectedEventIndex: Maybe<number>;
|
||||
numEvents: number;
|
||||
plannedStart: Maybe<TimeOfDay>;
|
||||
plannedEnd: Maybe<TimeOfDay>;
|
||||
actualStart: Maybe<TimeOfDay>;
|
||||
currentDay: Maybe<Day>;
|
||||
actualGroupStart: Maybe<TimeOfDay>;
|
||||
};
|
||||
_now: Instant;
|
||||
_timer: {
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
forceFinish: boolean; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
pausedDuration: number;
|
||||
pausedDuration: Duration;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
};
|
||||
_rundown: {
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
};
|
||||
_startDayOffset: Maybe<Day>;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_group: ExpectedMetadata;
|
||||
_flag: ExpectedMetadata;
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: timeCore.timeOfDayNow(),
|
||||
const runtimeState: InternalRuntimeState = {
|
||||
_now: timeCore.now(),
|
||||
groupNow: null,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
offset: { ...runtimeStorePlaceholder.offset },
|
||||
timer: { ...runtimeStorePlaceholder.timer },
|
||||
rundown: { ...runtimeStorePlaceholder.rundown },
|
||||
offset: { ...runtimePlaceholder.offset },
|
||||
timer: {
|
||||
...runtimePlaceholder.timer,
|
||||
},
|
||||
rundown: {
|
||||
...runtimePlaceholder.rundown,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
forceFinish: false,
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
pausedDuration: 0 as Duration,
|
||||
secondaryTarget: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
@@ -103,19 +143,32 @@ const runtimeState: RuntimeState = {
|
||||
_startDayOffset: null,
|
||||
};
|
||||
|
||||
export function getInternalState(): Readonly<InternalRuntimeState> {
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
// create a shallow copy of the state
|
||||
return {
|
||||
...runtimeState,
|
||||
clock: timeCore.toTimeOfDay(runtimeState._now),
|
||||
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
|
||||
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
|
||||
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
|
||||
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
|
||||
offset: { ...runtimeState.offset },
|
||||
rundown: { ...runtimeState.rundown },
|
||||
timer: { ...runtimeState.timer },
|
||||
_timer: { ...runtimeState._timer },
|
||||
_rundown: { ...runtimeState._rundown },
|
||||
timer: {
|
||||
addedTime: runtimeState.timer.addedTime,
|
||||
current: runtimeState.timer.current,
|
||||
duration: runtimeState.timer.duration,
|
||||
elapsed: runtimeState.timer.elapsed,
|
||||
expectedFinish: runtimeState.timer.expectedFinish,
|
||||
phase: runtimeState.timer.phase,
|
||||
playback: runtimeState.timer.playback,
|
||||
secondaryTimer: runtimeState.timer.secondaryTimer,
|
||||
startedAt: runtimeState.timer.startedAt ? timeCore.toTimeOfDay(runtimeState.timer.startedAt) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,22 +179,18 @@ export function clearEventData() {
|
||||
runtimeState.eventNow = null;
|
||||
runtimeState.eventNext = null;
|
||||
|
||||
runtimeState.offset.absolute = 0;
|
||||
runtimeState.offset.relative = 0;
|
||||
runtimeState.offset.expectedFlagStart = null;
|
||||
runtimeState.offset.expectedGroupEnd = null;
|
||||
runtimeState.offset.expectedRundownEnd = null;
|
||||
runtimeState.offset = { ...runtimePlaceholder.offset };
|
||||
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
runtimeState._now = timeCore.now();
|
||||
runtimeState.timer = { ...runtimePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
runtimeState._timer.forceFinish = null;
|
||||
runtimeState._timer.forceFinish = false;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.pausedDuration = 0;
|
||||
runtimeState._timer.pausedDuration = 0 as Duration;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
@@ -159,22 +208,18 @@ export function clearState() {
|
||||
runtimeState.rundown.actualStart = null;
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.offset.absolute = 0;
|
||||
runtimeState.offset.relative = 0;
|
||||
runtimeState.offset.expectedRundownEnd = null;
|
||||
runtimeState.offset.expectedGroupEnd = null;
|
||||
runtimeState.offset.expectedFlagStart = null;
|
||||
runtimeState.offset = { ...runtimePlaceholder.offset };
|
||||
|
||||
runtimeState._end = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
runtimeState._now = timeCore.now();
|
||||
runtimeState.timer = { ...runtimePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
runtimeState._timer.forceFinish = null;
|
||||
runtimeState._timer.forceFinish = false;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.pausedDuration = 0;
|
||||
runtimeState._timer.pausedDuration = 0 as Duration;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
runtimeState._timer.hasFinished = false;
|
||||
|
||||
@@ -216,9 +261,9 @@ export function updateRundownData(rundownData: {
|
||||
runtimeState._rundown.totalDelay = rundownData.totalDelay;
|
||||
|
||||
runtimeState.rundown.numEvents = rundownData.numEvents;
|
||||
runtimeState.rundown.plannedStart = rundownData.firstStart;
|
||||
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay;
|
||||
runtimeState.rundown.plannedEnd =
|
||||
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
|
||||
rundownData.firstStart === null ? null : ((rundownData.firstStart + rundownData.totalDuration) as TimeOfDay);
|
||||
|
||||
if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
|
||||
}
|
||||
@@ -252,7 +297,7 @@ export function load(
|
||||
|
||||
// update state
|
||||
runtimeState.timer.playback = Playback.Armed;
|
||||
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
|
||||
runtimeState.timer.duration = event.duration as Duration;
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
|
||||
|
||||
@@ -266,13 +311,13 @@ export function load(
|
||||
(firstStart === null || typeof firstStart === 'number') &&
|
||||
(startEpoch === null || typeof startEpoch === 'number')
|
||||
) {
|
||||
runtimeState.rundown.actualStart = firstStart;
|
||||
runtimeState.rundown.actualStart = firstStart as TimeOfDay;
|
||||
runtimeState._startEpoch = startEpoch;
|
||||
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
|
||||
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
|
||||
}
|
||||
if (currentDay !== undefined) {
|
||||
runtimeState.rundown.currentDay = currentDay;
|
||||
runtimeState.rundown.currentDay = currentDay as Day;
|
||||
}
|
||||
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
||||
runtimeState.offset.absolute = absolute;
|
||||
@@ -364,16 +409,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
|
||||
|
||||
// handle edge cases with roll
|
||||
if (runtimeState.timer.playback === Playback.Roll) {
|
||||
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
|
||||
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
|
||||
// if waiting to roll, we update the targets and potentially start the timer
|
||||
if (runtimeState._timer.secondaryTarget !== null) {
|
||||
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
|
||||
// if the event is now, we queue a start
|
||||
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
|
||||
} else {
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||
runtimeState.eventNow.timeStart,
|
||||
runtimeState.eventNow.timeStart as TimeOfDay,
|
||||
offsetClock,
|
||||
) as TimeOfDay;
|
||||
}
|
||||
@@ -390,7 +435,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
|
||||
|
||||
runtimeState.timer.startedAt = null;
|
||||
runtimeState._timer.hasFinished = false;
|
||||
runtimeState.timer.addedTime = 0;
|
||||
runtimeState.timer.addedTime = 0 as Duration;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
|
||||
// this could be looked after by the timer
|
||||
@@ -413,7 +458,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
|
||||
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
|
||||
}
|
||||
|
||||
export function start(state: RuntimeState = runtimeState): boolean {
|
||||
export function start(state: InternalRuntimeState = runtimeState): boolean {
|
||||
if (state.eventNow === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -421,22 +466,19 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
|
||||
state.clock = now;
|
||||
state._now = timeCore.now();
|
||||
state.timer.secondaryTimer = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state._timer.pausedAt) {
|
||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||
state.timer.addedTime += timeToAdd;
|
||||
state._timer.pausedDuration += timeToAdd;
|
||||
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration;
|
||||
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd);
|
||||
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd);
|
||||
state._timer.pausedAt = null;
|
||||
}
|
||||
|
||||
if (state.timer.startedAt === null) {
|
||||
state.timer.startedAt = state.clock;
|
||||
state.timer.startedAt = state._now;
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Play;
|
||||
@@ -444,14 +486,15 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
state.timer.elapsed = getElapsed(state);
|
||||
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) +
|
||||
state.eventNow.dayOffset) as Day;
|
||||
state.rundown.currentDay = state._startDayOffset;
|
||||
state._startEpoch = epoch;
|
||||
state.rundown.actualStart = state.clock;
|
||||
state._startEpoch = state._now;
|
||||
state.rundown.actualStart = timeCore.toTimeOfDay(state._now);
|
||||
}
|
||||
|
||||
if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
|
||||
state.rundown.actualGroupStart = state.clock;
|
||||
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now);
|
||||
}
|
||||
|
||||
// update timer phase
|
||||
@@ -474,18 +517,18 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pause(state: RuntimeState = runtimeState): boolean {
|
||||
export function pause(state: InternalRuntimeState = runtimeState): boolean {
|
||||
if (state.timer.playback !== Playback.Play) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Pause;
|
||||
state.clock = timeCore.timeOfDayNow();
|
||||
state._timer.pausedAt = state.clock;
|
||||
state._now = timeCore.now();
|
||||
state._timer.pausedAt = timeCore.toTimeOfDay(state._now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stop(state: RuntimeState = runtimeState): boolean {
|
||||
export function stop(state: InternalRuntimeState = runtimeState): boolean {
|
||||
if (state.timer.playback === Playback.Stop) {
|
||||
return false;
|
||||
}
|
||||
@@ -515,7 +558,7 @@ export function addTime(amount: number) {
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
runtimeState._timer.forceFinish = true;
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
@@ -524,8 +567,8 @@ export function addTime(amount: number) {
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration);
|
||||
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration);
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime delays: over - under
|
||||
@@ -545,10 +588,8 @@ export type UpdateResult = {
|
||||
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
const previousClock = runtimeState.clock;
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now; // we update the clock on every update call
|
||||
const previousClock = timeCore.toTimeOfDay(runtimeState._now);
|
||||
runtimeState._now = timeCore.now(); // we update the clock on every update call
|
||||
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState.timer.playback)) {
|
||||
@@ -557,13 +598,13 @@ export function update(): UpdateResult {
|
||||
|
||||
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
|
||||
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart);
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, timeCore.toTimeOfDay(runtimeState._now));
|
||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||
}
|
||||
|
||||
@@ -617,22 +658,24 @@ export function update(): UpdateResult {
|
||||
}
|
||||
|
||||
// account for offset
|
||||
const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
|
||||
const offsetClock = timeCore.toTimeOfDay(
|
||||
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
|
||||
);
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
|
||||
if (hasCrossedMidnight) {
|
||||
// if we crossed midnight, we need to update the target
|
||||
// this is the same logic from the roll function
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||
runtimeState.eventNow.timeStart,
|
||||
runtimeState.eventNow.timeStart as TimeOfDay,
|
||||
offsetClock,
|
||||
) as TimeOfDay;
|
||||
}
|
||||
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
|
||||
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration;
|
||||
return {
|
||||
hasTimerFinished: false,
|
||||
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
|
||||
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer! <= 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -649,9 +692,7 @@ export function roll(
|
||||
}
|
||||
|
||||
// we will need to do some calculations, update the time first
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now;
|
||||
runtimeState._now = timeCore.now();
|
||||
|
||||
// 2. if there is an event armed, we use it
|
||||
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
|
||||
@@ -663,7 +704,9 @@ export function roll(
|
||||
}
|
||||
|
||||
if (offset) {
|
||||
runtimeState.offset = { ...offset };
|
||||
runtimeState.offset = {
|
||||
...offset,
|
||||
} as InternalRuntimeState['offset'];
|
||||
}
|
||||
runtimeState.timer.playback = Playback.Roll;
|
||||
|
||||
@@ -672,15 +715,16 @@ export function roll(
|
||||
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
|
||||
? runtimeState.eventNow.timeEnd + dayInMs
|
||||
: runtimeState.eventNow.timeEnd;
|
||||
runtimeState.timer.expectedFinish = normalisedEndTime;
|
||||
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay;
|
||||
|
||||
// account for offset
|
||||
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
|
||||
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration);
|
||||
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
|
||||
|
||||
// state catch up
|
||||
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
|
||||
runtimeState.timer.current = runtimeState.timer.duration;
|
||||
runtimeState.timer.elapsed = 0;
|
||||
runtimeState.timer.elapsed = 0 as Duration;
|
||||
|
||||
// check if the event is ready to start or if needs to be pending
|
||||
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
|
||||
@@ -689,8 +733,8 @@ export function roll(
|
||||
* If we are starting an event in roll mode
|
||||
* we backtrace all the start times to the supposed start time of the event
|
||||
*/
|
||||
const plannedStart = runtimeState.eventNow.timeStart;
|
||||
runtimeState.timer.startedAt = plannedStart;
|
||||
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
|
||||
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
|
||||
// reset the secondary timer to cancel any countdowns
|
||||
runtimeState.timer.secondaryTimer = null;
|
||||
|
||||
@@ -708,18 +752,20 @@ export function roll(
|
||||
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||
runtimeState.eventNow.dayOffset) as Day;
|
||||
// backdate _startEpoch to when the event conceptually started
|
||||
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
|
||||
// calculate currentDay from the backdated epoch
|
||||
runtimeState.rundown.currentDay =
|
||||
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
runtimeState.rundown.currentDay = timeCore.combineValues(
|
||||
runtimeState._startDayOffset,
|
||||
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||
runtimeState.eventNow.timeStart,
|
||||
runtimeState.eventNow.timeStart as TimeOfDay,
|
||||
offsetClock,
|
||||
) as TimeOfDay;
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
}
|
||||
|
||||
@@ -736,9 +782,9 @@ export function roll(
|
||||
|
||||
// account for offset but we only keep it if passed to us
|
||||
if (offset) {
|
||||
runtimeState.offset = { ...offset };
|
||||
runtimeState.offset = { ...offset } as InternalRuntimeState['offset'];
|
||||
}
|
||||
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
|
||||
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
|
||||
|
||||
const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
|
||||
|
||||
@@ -765,7 +811,7 @@ export function roll(
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
// we need to normalise start time in case it is the day after
|
||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
|
||||
|
||||
// preload timer properties
|
||||
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
|
||||
@@ -788,18 +834,18 @@ export function roll(
|
||||
* when we start in roll mode
|
||||
* we need to backtrace all times to the supposed start time of the event
|
||||
*/
|
||||
const plannedStart = runtimeState.eventNow.timeStart;
|
||||
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
|
||||
const endTime =
|
||||
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
|
||||
? runtimeState.eventNow.timeEnd + dayInMs
|
||||
: runtimeState.eventNow.timeEnd;
|
||||
runtimeState.timer.startedAt = plannedStart;
|
||||
runtimeState.timer.expectedFinish = endTime;
|
||||
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
|
||||
runtimeState.timer.expectedFinish = endTime as TimeOfDay;
|
||||
|
||||
// state catch up
|
||||
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.timer.elapsed = 0;
|
||||
runtimeState.timer.elapsed = 0 as Duration;
|
||||
|
||||
// update runtime
|
||||
runtimeState.rundown.actualStart = plannedStart;
|
||||
@@ -812,11 +858,11 @@ export function roll(
|
||||
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||
runtimeState.eventNow.dayOffset) as Day;
|
||||
// backdate _startEpoch to when the event conceptually started
|
||||
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
|
||||
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
|
||||
// calculate currentDay from the backdated epoch
|
||||
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
|
||||
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
|
||||
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now)) as Day;
|
||||
|
||||
return { eventId: runtimeState.eventNow.id, didStart: true };
|
||||
}
|
||||
@@ -831,7 +877,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 +897,11 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(
|
||||
lastEvent,
|
||||
lastEventExpectedStart,
|
||||
state.rundown.currentDay!,
|
||||
) as TimeOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,7 +918,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedFlagStart = expectedStart;
|
||||
state.offset.expectedFlagStart = expectedStart as TimeOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +933,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
||||
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +976,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,20 +984,35 @@ 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)) {
|
||||
foundFlag = true;
|
||||
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
|
||||
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
|
||||
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
|
||||
foundGroupEnd = true;
|
||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -955,7 +1020,7 @@ export function loadGroupFlagAndEnd(
|
||||
const lastID = playableEventOrder.at(-1);
|
||||
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
|
||||
if (lastEvent) {
|
||||
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
|
||||
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,9 +6,4 @@ export type Settings = {
|
||||
operatorKey: null | string;
|
||||
timeFormat: TimeFormat;
|
||||
language: string;
|
||||
/**
|
||||
* Custom names for the aux timers, one entry per aux timer in order (index 0 is aux timer 1).
|
||||
* An empty string means the timer is unnamed and consumers show the default label
|
||||
*/
|
||||
auxTimerNames: string[];
|
||||
};
|
||||
|
||||
@@ -14,6 +14,4 @@ export type SimpleTimerState = {
|
||||
current: number;
|
||||
playback: SimplePlayback;
|
||||
direction: SimpleDirection;
|
||||
/** Custom name for the aux timer. Empty string when unnamed */
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -53,21 +53,18 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
auxtimer2: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
auxtimer3: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
ping: 1,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -99,9 +99,6 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// aux timers
|
||||
export { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './src/aux-timer-utils/auxTimerUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './auxTimerUtils.js';
|
||||
|
||||
describe('sanitiseAuxTimerNames()', () => {
|
||||
it('generates the default value when given nothing', () => {
|
||||
expect(sanitiseAuxTimerNames()).toStrictEqual(['', '', '']);
|
||||
});
|
||||
|
||||
it('always returns exactly three entries', () => {
|
||||
expect(sanitiseAuxTimerNames(['a', 'b', 'c', 'extra'])).toStrictEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('pads missing entries with an empty string', () => {
|
||||
expect(sanitiseAuxTimerNames(['Speaker'])).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitiseAuxTimerNames([' Speaker ', '', ''])).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('caps the name length', () => {
|
||||
const tooLong = 'a'.repeat(auxTimerNameMaxLength + 10);
|
||||
expect(sanitiseAuxTimerNames([tooLong])[0]).toHaveLength(auxTimerNameMaxLength);
|
||||
});
|
||||
|
||||
it('falls back to defaults for malformed data', () => {
|
||||
expect(sanitiseAuxTimerNames('not-an-array')).toStrictEqual(['', '', '']);
|
||||
expect(sanitiseAuxTimerNames(null)).toStrictEqual(['', '', '']);
|
||||
expect(sanitiseAuxTimerNames([42, {}, undefined])).toStrictEqual(['', '', '']);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Maximum length of a user given aux timer name */
|
||||
export const auxTimerNameMaxLength = 30;
|
||||
|
||||
function sanitiseAuxTimerName(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().slice(0, auxTimerNameMaxLength) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ontime has three aux timers. Given whatever was found on disk or in a request body,
|
||||
* returns a name for each of them, so callers never need to deal with a missing
|
||||
* or malformed auxTimerNames (eg. a project file saved before this feature existed).
|
||||
*/
|
||||
export function sanitiseAuxTimerNames(names?: unknown): [string, string, string] {
|
||||
const source = Array.isArray(names) ? names : [];
|
||||
return [sanitiseAuxTimerName(source[0]), sanitiseAuxTimerName(source[1]), sanitiseAuxTimerName(source[2])];
|
||||
}
|
||||
+142
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -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;
|
||||
}
|
||||
@@ -342,7 +342,6 @@ export const demoDb: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ff7300',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OntimeEvent } from 'ontime-types';
|
||||
import type { Duration, OntimeEvent } from 'ontime-types';
|
||||
import { TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { dayInMs } from '../date-utils/conversionUtils.js';
|
||||
@@ -103,16 +103,17 @@ export function validateTimes(
|
||||
|
||||
/**
|
||||
* @description calculates event duration considering midnight
|
||||
* @deprecated - duration is the more useful value from the event data, prefer using that directly
|
||||
* @param {number} timeStart
|
||||
* @param {number} timeEnd
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateDuration(timeStart: number, timeEnd: number): number {
|
||||
export function calculateDuration(timeStart: number, timeEnd: number): Duration {
|
||||
// Durations must be positive
|
||||
if (timeEnd < timeStart) {
|
||||
return timeEnd + dayInMs - timeStart;
|
||||
return (timeEnd + dayInMs - timeStart) as Duration;
|
||||
}
|
||||
return timeEnd - timeStart;
|
||||
return (timeEnd - timeStart) as Duration;
|
||||
}
|
||||
|
||||
export function calculateEnd(timeStart: number, duration: number): number {
|
||||
|
||||
Reference in New Issue
Block a user