Compare commits

..

3 Commits

Author SHA1 Message Date
alex-arc 96c8228a19 refactor: use branded types in runtimestate 2026-07-29 23:15:08 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
45 changed files with 899 additions and 1294 deletions
@@ -6,8 +6,7 @@ import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
interface ColourInputProps {
/** undefined represents values which do not agree, no swatch is highlighted */
value: string | undefined;
value: string;
name: 'colour';
handleChange: (newValue: 'colour', name: string) => void;
}
@@ -45,11 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
<SwatchPicker
color={value ?? ''}
onChange={setColour}
isSelected={value !== undefined && !colours.includes(value)}
/>
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div>
);
}
@@ -1,31 +0,0 @@
import { shouldSubmitValue } from '../useReactiveTextInput';
describe('shouldSubmitValue()', () => {
it('submits a value which differs from the initial value', () => {
expect(shouldSubmitValue('new', 'old')).toBe(true);
});
it('does not submit a value which has not changed', () => {
expect(shouldSubmitValue('same', 'same')).toBe(false);
});
it('submits an empty value when the initial value is known', () => {
// clearing a field which has a value is a valid edit
expect(shouldSubmitValue('', 'old')).toBe(true);
});
it('does not submit an empty value when the initial value is unknown', () => {
// an unknown value renders as an empty field, submitting it on blur
// would overwrite every entry being edited with an empty value
expect(shouldSubmitValue('', undefined)).toBe(false);
});
it('submits a typed value when the initial value is unknown', () => {
expect(shouldSubmitValue('typed', undefined)).toBe(true);
});
it('submits an unchanged value when the caller opts in', () => {
expect(shouldSubmitValue('same', 'same', true)).toBe(true);
expect(shouldSubmitValue('', undefined, true)).toBe(true);
});
});
@@ -1,23 +1,6 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
/**
* Whether a value should be sent to the server
* An undefined initial value means that the value is unknown, for example when several
* entries are being edited and they do not agree on a value
* It is shown as an empty field, so submitting it would overwrite data the user has not edited
*/
export function shouldSubmitValue(
valueToSubmit: string,
initialText: string | undefined,
allowSubmitSameValue?: boolean,
): boolean {
if (allowSubmitSameValue) {
return true;
}
return valueToSubmit !== (initialText ?? '');
}
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@@ -26,8 +9,7 @@ interface UseReactiveTextInputReturn {
}
export default function useReactiveTextInput(
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
initialText: string | undefined,
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: {
@@ -40,7 +22,7 @@ export default function useReactiveTextInput(
allowKeyboardNavigation?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText ?? '');
const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
@@ -75,7 +57,8 @@ export default function useReactiveTextInput(
*/
const handleSubmit = useCallback(
(valueToSubmit: string) => {
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
// No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
@@ -103,10 +86,10 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed
setText(initialText ?? '');
setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText ?? '';
ref.current.value = initialText;
}
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
@@ -41,10 +41,7 @@ export default function TimeInput<T extends string>({
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (time === undefined) {
// there is no value to show, we leave the field empty so that the placeholder is visible
setValue('');
} else if (typeof time !== 'number' || isNaN(time)) {
if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
} else if (shouldFormat) {
setValue(formatTime(time));
@@ -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;
@@ -16,21 +16,13 @@ interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
options: SelectOption<T>[];
fluid?: boolean;
size?: 'medium' | 'large';
/** shown when the value is null, used to represent values which do not agree */
placeholder?: string;
}
export default function Select<T>({
options,
fluid,
size = 'medium',
placeholder,
...selectRootProps
}: SelectProps<T>) {
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
@@ -29,11 +29,6 @@
}
}
/* values do not agree, we show an empty track instead of a thumb position */
.mixed .thumb {
visibility: hidden;
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
@@ -6,13 +6,11 @@ import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
/** the switch represents several values which do not agree, we hide the thumb */
mixed?: boolean;
}
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) {
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
@@ -1,76 +0,0 @@
import { OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
import { applyPatchToEvents, canPredictBatchResult } from '../entryAction.utils';
function makeEntries(): RundownEntries {
return {
'1': { type: SupportedEntry.Event, id: '1', title: 'one', custom: { lx: 'a', sound: 'x' } } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', title: 'two', custom: { lx: 'b' } } as OntimeEvent,
'3': { type: SupportedEntry.Event, id: '3', title: 'three', custom: {} } as OntimeEvent,
group: { type: SupportedEntry.Group, id: 'group', title: 'a group', custom: {} } as OntimeGroup,
};
}
describe('applyPatchToEvents()', () => {
it('applies the patch to the given events only', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
expect((patched['2'] as OntimeEvent).title).toBe('patched');
expect((patched['3'] as OntimeEvent).title).toBe('three');
});
it('patches custom fields instead of replacing them', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { custom: { lx: 'new' } });
// the field which was not part of the patch must survive
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'new', sound: 'x' });
expect((patched['2'] as OntimeEvent).custom).toStrictEqual({ lx: 'new' });
});
it('keeps the existing custom fields when the patch has none', () => {
const patched = applyPatchToEvents(makeEntries(), ['1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('ignores ids which are not in the rundown', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', 'does-not-exist'], { title: 'patched' });
expect(Object.keys(patched)).toStrictEqual(['1', '2', '3', 'group']);
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
it('ignores entries which are not events', () => {
const patched = applyPatchToEvents(makeEntries(), ['group'], { title: 'patched' });
expect((patched.group as OntimeGroup).title).toBe('a group');
});
it('does not mutate the given entries', () => {
const entries = makeEntries();
applyPatchToEvents(entries, ['1'], { title: 'patched', custom: { lx: 'new' } });
expect((entries['1'] as OntimeEvent).title).toBe('one');
expect((entries['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('handles repeated ids', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
});
describe('canPredictBatchResult()', () => {
it('allows resolving patches which do not affect the schedule', () => {
expect(canPredictBatchResult({ title: 'a title' })).toBe(true);
expect(canPredictBatchResult({ colour: 'red', flag: true })).toBe(true);
});
it('defers to the server when the duration changes', () => {
// a duration cascades into the start and end of every linked event
expect(canPredictBatchResult({ duration: 1000 })).toBe(false);
expect(canPredictBatchResult({ title: 'a title', duration: 1000 })).toBe(false);
});
});
@@ -1,39 +0,0 @@
import { EntryId, OntimeEvent, RundownEntries, isOntimeEvent } from 'ontime-types';
/**
* Applies a patch to a set of events, used to optimistically resolve a batch edit
* Entries which are missing or are not events are left untouched, mirroring the server
* @returns a new entries object, the given entries are not mutated
*/
export function applyPatchToEvents(
entries: RundownEntries,
ids: EntryId[],
patch: Partial<OntimeEvent>,
): RundownEntries {
const patchedEntries = { ...entries };
for (const id of new Set(ids)) {
const entry = patchedEntries[id];
if (!isOntimeEvent(entry)) {
continue;
}
patchedEntries[id] = {
...entry,
...patch,
// custom fields are patched, not replaced
custom: patch.custom ? { ...entry.custom, ...patch.custom } : entry.custom,
};
}
return patchedEntries;
}
/**
* Whether the result of a batch edit can be resolved without the server
* Changing the duration cascades through the rundown, so the resulting
* schedule is only known once the server has recalculated it
*/
export function canPredictBatchResult(patch: Partial<OntimeEvent>): boolean {
return !('duration' in patch);
}
+15 -4
View File
@@ -52,7 +52,6 @@ import {
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { applyPatchToEvents, canPredictBatchResult } from './entryAction.utils';
export type EventOptions = Partial<{
// options of any new entries (event / delay / group)
@@ -484,9 +483,21 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
// when the result cannot be resolved here we wait for the recalculated rundown from the server
if (previousRundown && canPredictBatchResult(data.data)) {
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data);
if (previousRundown) {
const eventIds = new Set(data.ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData<Rundown>(queryKey, {
id: previousRundown.id,
@@ -25,6 +25,13 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -8,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -56,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -101,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -123,11 +127,12 @@ export default function SourcesPanel() {
}
};
const handleFinished = () => {
const handleFinished = (rundownTitle: string) => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
@@ -139,7 +144,7 @@ export default function SourcesPanel() {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
handleFinished(title);
return;
}
@@ -156,7 +161,7 @@ export default function SourcesPanel() {
customFields: preview.customFields,
providedFields,
});
handleFinished();
handleFinished(currentRundown.title);
};
const loadWorksheetMetadata = useCallback(
@@ -289,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div>
)}
{isGSheetFlow && (
@@ -92,6 +92,10 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -132,7 +132,16 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -1,27 +1,35 @@
.emptyState {
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
color: $ui-white;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
}
.emptyBody {
color: $gray-400;
font-size: 0.95rem;
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -34,31 +42,101 @@
}
th {
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal;
}
th,
td {
padding: 0.5rem;
box-sizing: border-box;
min-width: 8rem;
vertical-align: top;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
tr:nth-child(even) {
background-color: $white-1;
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
width: 4.5rem;
min-width: 4.5rem;
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
}
.rowType {
width: 7rem;
min-width: 7rem;
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
}
@@ -1,7 +1,11 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +99,57 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
</div>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => (
{displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return (
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr>
);
})}
@@ -5,7 +5,8 @@ import type {
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -32,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -40,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview };
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null };
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null };
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
}
return { ...state, loading: '', error: action.error };
}
@@ -221,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview' });
dispatch({ type: 'clearPreview', needsRefresh: true });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -297,15 +300,37 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`;
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
}
const { flatOrder } = state.preview.rundown;
const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})();
return {
@@ -22,13 +22,10 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
return event ?? null;
}, [entryId, rundown.entries, rundown.order.length]);
// the cuesheet always edits a single entry
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
if (events) {
if (isOntimeEvent(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor events={events} />
<EventEditor event={entry} />
</div>
);
}
@@ -75,26 +75,6 @@
gap: 1rem;
}
// a label with an action aligned to the end of the row
.labelRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.clearAction {
font-size: $aux-text-size;
color: $blue-400;
margin-bottom: $element-inner-spacing; // match the label
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-500;
}
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1,19 +1,14 @@
import { OntimeEvent } from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Info from '../../../common/components/info/Info';
import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EventEditorBatchSchedule from './composite/EventEditorBatchSchedule';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorSchedule from './composite/EventEditorSchedule';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers';
import { mixedPlaceholder } from './entryEditor.utils';
import { mergeEvents, resolveConflict } from './mergeEvents';
import style from './EntryEditor.module.scss';
@@ -21,115 +16,66 @@ import style from './EntryEditor.module.scss';
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
/** events being edited, editing several events at once shows a merged view of their values */
events: OntimeEvent[];
event: OntimeEvent;
}
export default function EventEditor({ events }: EventEditorProps) {
export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry, batchUpdateEvents } = useEntryActionsContext();
const { updateEntry } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor');
const ids = useMemo(() => events.map((event) => event.id), [events]);
const merged = useMemo(() => mergeEvents(events), [events]);
// when editing a single event, we can show the values which are unique to it
const singleEvent = events.length === 1 ? events[0] : null;
/**
* Applies a patch to every event being edited
*/
const submit = useCallback(
(patch: Partial<OntimeEvent>) => {
if (ids.length === 1) {
updateEntry({ id: ids[0], ...patch });
return;
}
batchUpdateEvents(patch, ids);
},
[batchUpdateEvents, ids, updateEntry],
);
const handleSubmit = useCallback(
(field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
submit({ custom: { [fieldLabel]: value } });
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else {
submit({ [field]: value });
updateEntry({ id: event.id, [field]: value });
}
},
[submit],
[event.id, updateEntry],
);
// inputs keep local state, we remount them when the edited entries change
const editorKey = ids.join();
return (
<div className={style.content}>
{singleEvent ? (
<EventEditorSchedule
key={`${editorKey}-schedule`}
eventId={singleEvent.id}
timeStart={singleEvent.timeStart}
timeEnd={singleEvent.timeEnd}
duration={singleEvent.duration}
timeStrategy={singleEvent.timeStrategy}
linkStart={singleEvent.linkStart}
delay={singleEvent.delay}
/>
) : (
<EventEditorBatchSchedule
key={`${editorKey}-schedule`}
duration={resolveConflict(merged.duration)}
submit={submit}
/>
)}
<EventEditorTimes
key={`${editorKey}-times`}
countToEnd={resolveConflict(merged.countToEnd)}
endAction={resolveConflict(merged.endAction)}
timerType={resolveConflict(merged.timerType)}
timeWarning={resolveConflict(merged.timeWarning)}
timeDanger={resolveConflict(merged.timeDanger)}
submit={submit}
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
countToEnd={event.countToEnd}
delay={event.delay}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/>
<EventEditorTitles
key={`${editorKey}-titles`}
eventId={singleEvent?.id ?? null}
eventCount={events.length}
cue={singleEvent?.cue ?? ''}
flag={resolveConflict(merged.flag)}
title={resolveConflict(merged.title)}
note={resolveConflict(merged.note)}
colour={resolveConflict(merged.colour)}
submit={submit}
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
flag={event.flag}
title={event.title}
note={event.note}
colour={event.colour}
/>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields
key={`${editorKey}-custom`}
fields={customFields}
custom={merged.custom}
idKey={editorKey}
mixedPlaceholder={mixedPlaceholder}
handleSubmit={handleSubmit}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
</div>
<div className={style.column}>
<Editor.Title>
Automations
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
{isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
</Editor.Title>
{singleEvent ? (
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
) : (
<Info>Automations are not available when editing multiple events</Info>
)}
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
</div>
</div>
);
@@ -112,12 +112,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields
fields={customFields}
handleSubmit={handleSubmit}
custom={group.custom}
idKey={group.id}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
</div>
</div>
);
@@ -69,12 +69,7 @@ export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields
fields={customFields}
handleSubmit={handleSubmit}
custom={milestone.custom}
idKey={milestone.id}
/>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
</div>
</div>
);
@@ -1,4 +1,4 @@
import { OntimeEntry, OntimeEvent, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,25 +15,6 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
/**
* Events in the current selection
* Only events can be multi selected, groups and milestones are always selected on their own
*/
const events = useMemo<OntimeEvent[]>(() => {
if (data.order.length === 0) {
return [];
}
const selection: OntimeEvent[] = [];
selectedEvents.forEach((entryId) => {
const entry = data.entries[entryId];
if (isOntimeEvent(entry)) {
selection.push(entry);
}
});
return selection;
}, [data.order.length, data.entries, selectedEvents]);
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
return null;
@@ -48,20 +29,19 @@ export default function RundownEntryEditor() {
return event ?? null;
}, [data.order.length, data.entries, selectedEvents]);
if (events.length > 0) {
const singleEvent = events.length === 1 ? events[0] : null;
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor events={events} />
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
</div>
);
}
if (!entry) {
return <EventEditorEmpty />;
}
if (isOntimeEvent(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
</div>
);
}
if (isOntimeMilestone(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
@@ -1,122 +0,0 @@
import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { conflict, isConflict, mergeEvents, resolveConflict } from '../mergeEvents';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
title: 'title',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
...patch,
} as OntimeEvent;
}
describe('mergeEvents()', () => {
it('returns the values of a single event', () => {
const event = makeEvent({ id: '1', title: 'only event' });
expect(mergeEvents([event])).toStrictEqual({
title: 'only event',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
});
});
it('keeps values which are shared by all events', () => {
const merged = mergeEvents([makeEvent({ id: '1' }), makeEvent({ id: '2' }), makeEvent({ id: '3' })]);
expect(merged.title).toBe('title');
expect(merged.colour).toBe('#FFCC78');
expect(merged.timerType).toBe(TimerType.CountDown);
expect(merged.duration).toBe(600000);
});
it('marks only the fields which differ as conflicting', () => {
const merged = mergeEvents([makeEvent({ id: '1', title: 'first' }), makeEvent({ id: '2', title: 'second' })]);
expect(merged.title).toBe(conflict);
expect(merged.note).toBe('note');
expect(merged.colour).toBe('#FFCC78');
expect(merged.flag).toBe(false);
});
it('handles boolean and numeric fields', () => {
const merged = mergeEvents([
makeEvent({ id: '1', flag: true, timeWarning: 1000, duration: 1000 }),
makeEvent({ id: '2', flag: false, timeWarning: 1000, duration: 2000 }),
]);
expect(merged.flag).toBe(conflict);
expect(merged.timeWarning).toBe(1000);
expect(merged.duration).toBe(conflict);
});
describe('custom fields', () => {
it('merges values under the same key', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: 'same', sound: 'a' } }),
makeEvent({ id: '2', custom: { lx: 'same', sound: 'b' } }),
]);
expect(merged.custom).toStrictEqual({ lx: 'same', sound: conflict });
});
it('treats a missing key as empty', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: { lx: 'value' } }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({ lx: conflict });
});
it('collects keys from all events', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: '' } }),
makeEvent({ id: '2', custom: { sound: 'value' } }),
]);
// lx is empty in both events, sound is only filled in one of them
expect(merged.custom).toStrictEqual({ lx: '', sound: conflict });
});
it('does not report a key which is absent from every event', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: {} }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({});
// an absent key is distinguishable from a conflicting one
expect(merged.custom.lx).toBeUndefined();
expect(isConflict(merged.custom.lx)).toBe(false);
});
});
});
describe('resolveConflict()', () => {
it('passes known values through', () => {
expect(resolveConflict('value')).toBe('value');
expect(resolveConflict(0)).toBe(0);
expect(resolveConflict(false)).toBe(false);
expect(resolveConflict('')).toBe('');
});
it('resolves a conflict to undefined', () => {
expect(resolveConflict(conflict)).toBeUndefined();
});
});
@@ -1,48 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, dayInMs, parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { mixedPlaceholder } from '../entryEditor.utils';
import style from '../EntryEditor.module.scss';
interface EventEditorBatchScheduleProps {
/** undefined when the events do not share a duration */
duration: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
}
/**
* Schedule fields which can be applied to several events at once
* Start and end times are absolute points in time: giving several events the same
* value would collapse their durations, so only the duration is offered here
* The server infers the duration lock and recalculates the rundown once for the whole batch
*/
export default memo(EventEditorBatchSchedule);
function EventEditorBatchSchedule({ duration, submit }: EventEditorBatchScheduleProps) {
const handleSubmit = (_field: 'duration', value: string) => {
// durations cannot exceed a day
submit({ duration: Math.min(parseUserTime(value), dayInMs - MILLIS_PER_SECOND) });
};
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='duration'>Duration</Editor.Label>
<TimeInput
id='duration'
name='duration'
submitHandler={handleSubmit}
time={duration}
placeholder={duration === undefined ? mixedPlaceholder : 'Duration'}
align='left'
/>
</div>
</div>
</div>
);
}
@@ -1,9 +1,8 @@
import { CustomFields, EntryCustomFields } from 'ontime-types';
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { CSSProperties, Fragment } from 'react';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor';
import { MergedCustomFields, resolveConflict } from '../mergeEvents';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
@@ -12,29 +11,21 @@ import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
/** values of the entries being edited, undefined when the entries do not agree */
custom: EntryCustomFields | MergedCustomFields;
/** used to generate stable keys for the fields */
idKey: string;
mixedPlaceholder?: string;
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorCustomFields({
fields: customFields,
custom,
idKey,
mixedPlaceholder,
handleSubmit,
entry,
}: EntryEditorCustomFieldsProps) {
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${idKey}-${fieldKey}`;
const key = `${entry.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
// a key which is absent from the merged view is not present in any of the entries
const initialValue = resolveConflict(custom[fieldKey] ?? '');
const placeholder = initialValue === undefined ? mixedPlaceholder : undefined;
const initialValue = entry.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
@@ -45,7 +36,6 @@ export default function EntryEditorCustomFields({
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder={placeholder}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
@@ -61,13 +51,13 @@ export default function EntryEditorCustomFields({
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder={placeholder ?? 'Paste image URL'}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue ?? ''} />
<EventEditorImage src={initialValue} />
</div>
);
}
@@ -1,62 +0,0 @@
import { TimeStrategy } from 'ontime-types';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorScheduleProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
delay: number;
}
/**
* Schedule of a single event
* Schedule values cascade through the rundown, so they are not editable across a selection
*/
export default memo(EventEditorSchedule);
function EventEditorSchedule({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
delay,
}: EventEditorScheduleProps) {
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { EndAction, TimeStrategy, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
@@ -8,47 +8,103 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
countToEnd: boolean | undefined;
endAction: EndAction | undefined;
timerType: TimerType | undefined;
timeWarning: number | undefined;
timeDanger: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
}
type TimeFields = 'timeWarning' | 'timeDanger';
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
submit,
}: EventEditorTimesProps) {
const handleTimeSubmit = (field: TimeFields, value: string) => {
submit({ [field]: parseUserTime(value) });
const { updateEntry } = useEntryActionsContext();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: value as boolean });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEntry({ id: eventId, [field]: value });
return;
}
};
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
<>
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>Event Behaviour</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
value={endAction ?? null}
placeholder={mixedPlaceholder}
value={endAction}
onValueChange={(value: EndAction | null) => {
if (value === null) return;
submit({ endAction: value });
handleSubmit('endAction', value);
}}
options={[
{ value: EndAction.None, label: 'None' },
@@ -62,11 +118,10 @@ function EventEditorTimes({
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
checked={countToEnd ?? false}
mixed={countToEnd === undefined}
onCheckedChange={(value) => submit({ countToEnd: value })}
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
/>
{switchLabel(countToEnd)}
{countToEnd ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
@@ -86,11 +141,10 @@ function EventEditorTimes({
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
value={timerType ?? null}
placeholder={mixedPlaceholder}
value={timerType}
onValueChange={(value: TimerType | null) => {
if (value === null) return;
submit({ timerType: value });
handleSubmit('timerType', value);
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
@@ -107,9 +161,9 @@ function EventEditorTimes({
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleTimeSubmit}
submitHandler={handleSubmit}
time={timeWarning}
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'}
placeholder='Duration'
/>
</div>
<div>
@@ -117,9 +171,9 @@ function EventEditorTimes({
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleTimeSubmit}
submitHandler={handleSubmit}
time={timeDanger}
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'}
placeholder='Duration'
/>
</div>
</div>
@@ -1,36 +1,34 @@
import { OntimeEvent } from 'ontime-types';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch';
import { enDash } from '../../../../common/utils/styleUtils';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
/** id of the event being edited, null when editing several events */
eventId: string | null;
/** amount of events being edited */
eventCount: number;
eventId: string;
cue: string;
flag: boolean | undefined;
title: string | undefined;
note: string | undefined;
colour: string | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
flag: boolean;
title: string;
note: string;
colour: string;
}
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) {
const isMulti = eventId === null;
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActionsContext();
const flagSubmitHandler = (newValue: boolean) => {
updateEntry({ id: eventId, flag: newValue });
};
const textSubmitHandler = (field: string, newValue: string) => {
submit({ [field]: newValue });
updateEntry({ id: eventId, [field]: newValue });
};
return (
@@ -38,39 +36,21 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Title>Event Data</Editor.Title>
<div className={style.splitThree}>
<div>
<Editor.Label htmlFor='eventId'>{isMulti ? 'Selection (read only)' : 'Event ID (read only)'}</Editor.Label>
<Input
id='eventId'
data-testid='input-textfield'
value={isMulti ? `${eventCount} events selected` : eventId}
readOnly
fluid
/>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
</div>
{isMulti ? (
<div>
<Editor.Label htmlFor='cue'>Cue (not available)</Editor.Label>
<Input id='cue' value={enDash} readOnly fluid />
</div>
) : (
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={textSubmitHandler}
maxLength={10}
/>
)}
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={textSubmitHandler}
maxLength={10}
/>
<div>
<Editor.Label htmlFor='flag'>Flag</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='flag'
checked={flag ?? false}
mixed={flag === undefined}
onCheckedChange={(newValue) => submit({ flag: newValue })}
/>
{switchLabel(flag)}
<Switch id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
{flag ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
@@ -78,20 +58,8 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} />
</div>
<EntryEditorTextInput
field='title'
label='Title'
initialValue={title}
placeholder={title === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
<EventTextArea
field='note'
label='Note'
initialValue={note}
placeholder={note === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
</div>
);
}
@@ -5,15 +5,11 @@ import { AutoTextarea } from '../../../../common/components/input/auto-textarea/
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor';
import style from '../EntryEditor.module.scss';
interface CountedTextAreaProps {
className?: string;
field: EventEditorUpdateFields;
label: string;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
placeholder?: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -23,7 +19,6 @@ export default function EventTextArea({
field,
label,
initialValue,
placeholder,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
@@ -34,27 +29,16 @@ export default function EventTextArea({
submitOnCtrlEnter: true,
});
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return (
<div>
<div className={style.labelRow}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextarea
id={field}
inputref={ref}
rows={1}
data-testid='input-textarea'
placeholder={placeholder}
fluid
value={value}
onChange={onChange}
@@ -6,13 +6,10 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
import { EventEditorUpdateFields } from '../EventEditor';
import { GroupEditorUpdateTextFields } from '../GroupEditor';
import style from '../EntryEditor.module.scss';
interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | GroupEditorUpdateTextFields;
label: string;
/** undefined represents values which do not agree across the edited entries */
initialValue: string | undefined;
initialValue: string;
placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
@@ -34,21 +31,11 @@ export default function EntryEditorTextInput({
submitOnEnter: true,
});
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return (
<div>
<div className={style.labelRow}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<Input
id={field}
ref={ref}
@@ -1,10 +0,0 @@
/** Shown in place of a value when the entries being edited do not agree */
export const mixedPlaceholder = 'Mixed';
/** Returns the label of a switch which may represent values that do not agree */
export function switchLabel(value: boolean | undefined): string {
if (value === undefined) {
return mixedPlaceholder;
}
return value ? 'On' : 'Off';
}
@@ -1,108 +0,0 @@
import { CustomFieldKey, OntimeEvent } from 'ontime-types';
/**
* Marks a value which is not the same across the entries being edited
* This is a distinct value from undefined, which means that the field is absent
* Being a symbol, it also cannot be assigned to a patch by accident
*/
export const conflict: unique symbol = Symbol('conflict');
export type Conflict = typeof conflict;
/** A value which may not be the same across the entries being edited */
export type MergedValue<T> = T | Conflict;
export function isConflict<T>(value: MergedValue<T>): value is Conflict {
return value === conflict;
}
/** Resolves a merged value for the UI, where an unknown value is represented by undefined */
export function resolveConflict<T>(value: MergedValue<T>): T | undefined {
return isConflict(value) ? undefined : value;
}
/**
* Fields which can be edited across a selection of events
* timeStart and timeEnd are absolute points in time and are excluded:
* giving several events the same start or end collapses their durations
*/
export const batchEditableFields = [
'title',
'note',
'colour',
'flag',
'duration',
'endAction',
'countToEnd',
'timerType',
'timeWarning',
'timeDanger',
'linkStart',
] as const;
type BatchEditableField = (typeof batchEditableFields)[number];
/**
* Custom fields of the entries being edited
* A missing key means that none of the entries have a value for that field
*/
export type MergedCustomFields = Record<CustomFieldKey, MergedValue<string>>;
/**
* A merged view over a set of events
* A field holds the conflict symbol when the events do not agree on its value
*/
export type MergedEvent = {
[K in BatchEditableField]: MergedValue<OntimeEvent[K]>;
} & {
custom: MergedCustomFields;
};
/**
* Merges a list of events into a single view
* For a single event, no field is in conflict and every value matches the event
* @param events - events to merge, must contain at least one element
*/
export function mergeEvents(events: OntimeEvent[]): MergedEvent {
return {
title: mergeField(events, 'title'),
note: mergeField(events, 'note'),
colour: mergeField(events, 'colour'),
flag: mergeField(events, 'flag'),
duration: mergeField(events, 'duration'),
endAction: mergeField(events, 'endAction'),
countToEnd: mergeField(events, 'countToEnd'),
timerType: mergeField(events, 'timerType'),
timeWarning: mergeField(events, 'timeWarning'),
timeDanger: mergeField(events, 'timeDanger'),
linkStart: mergeField(events, 'linkStart'),
custom: mergeCustomFields(events),
};
}
/**
* Returns the shared value of a field, or the conflict symbol if the events disagree
*/
function mergeField<K extends BatchEditableField>(events: OntimeEvent[], field: K): MergedValue<OntimeEvent[K]> {
const value = events[0][field];
return events.some((event) => event[field] !== value) ? conflict : value;
}
/**
* Merges the custom fields of a list of events
* Fields missing from an entry are considered empty
*/
function mergeCustomFields(events: OntimeEvent[]): MergedCustomFields {
const merged: MergedCustomFields = {};
for (const event of events) {
for (const key of Object.keys(event.custom)) {
if (key in merged) {
continue;
}
const value = event.custom[key] ?? '';
merged[key] = events.some((other) => (other.custom[key] ?? '') !== value) ? conflict : value;
}
}
return merged;
}
+17 -1
View File
@@ -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(),
);
}
@@ -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,11 +1120,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(400000 - 200000); // <--- offset is always the overtime + added time
@@ -1131,7 +1132,7 @@ describe('getRuntimeOffset()', () => {
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);
+2
View File
@@ -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) => {
+39 -36
View File
@@ -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,20 +171,23 @@ 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
// in case of count to end, the absolute offset is overtime and added time
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
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;
return { absolute, relative };
}
@@ -193,7 +196,7 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
* 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;
@@ -1087,7 +1088,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1169,7 +1170,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as RuntimeState;
} as InternalRuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+154 -105
View File
@@ -17,7 +17,6 @@ import {
TimerPhase,
TimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
import {
calculateDuration,
@@ -44,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;
@@ -61,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,
},
@@ -104,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,
},
};
}
@@ -127,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;
}
@@ -160,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;
@@ -217,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();
}
@@ -253,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;
@@ -267,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;
@@ -365,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;
}
@@ -391,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
@@ -414,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;
}
@@ -422,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;
@@ -445,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
@@ -475,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;
}
@@ -516,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) {
@@ -525,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
@@ -546,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)) {
@@ -558,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);
}
@@ -618,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,
};
}
}
@@ -650,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) {
@@ -664,7 +704,9 @@ export function roll(
}
if (offset) {
runtimeState.offset = { ...offset };
runtimeState.offset = {
...offset,
} as InternalRuntimeState['offset'];
}
runtimeState.timer.playback = Playback.Roll;
@@ -673,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);
@@ -690,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;
@@ -709,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;
}
@@ -737,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);
@@ -766,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);
@@ -789,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;
@@ -813,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 };
}
@@ -852,7 +897,11 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
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 = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay;
}
}
@@ -957,13 +1006,13 @@ export function loadGroupFlagAndEnd(
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 };
}
}
}
@@ -971,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 };
}
}
-87
View File
@@ -1,87 +0,0 @@
import { expect, test } from '@playwright/test';
test('Editing multiple events', async ({ page }) => {
await page.goto('/editor');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
// create two events with distinct titles
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
const editor = page.getByTestId('editor-container');
// a single selection shows the full schedule and the event id
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('first');
// selecting both events shows a merged view
await page
.getByTestId('entry-2')
.getByTestId('rundown-event')
.click({ modifiers: ['Shift'] });
await expect(editor.locator('#eventId')).toHaveValue('2 events selected');
await expect(editor.getByText('Automations are not available when editing multiple events')).toBeVisible();
// start and end times are unique to an event, only the duration can be batched
await expect(editor.getByTestId('time-input-timeStart')).toBeHidden();
await expect(editor.getByTestId('time-input-timeEnd')).toBeHidden();
await expect(editor.getByTestId('time-input-duration')).toBeVisible();
// fields which do not agree are shown as mixed
const title = editor.getByLabel('Title', { exact: true });
await expect(title).toHaveValue('');
await expect(title).toHaveAttribute('placeholder', 'Mixed');
// leaving a mixed field without editing it does not overwrite the events
await title.click();
await page.keyboard.press('Tab');
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('first');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('second');
// a mixed field cannot be cleared by emptying it, so an explicit action is offered
const clearTitle = editor.getByRole('button', { name: 'Clear', exact: true }).first();
await expect(clearTitle).toBeVisible();
await clearTitle.click();
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('');
// the events now agree, so the field can be cleared by hand and the action is withdrawn
await expect(editor.getByLabel('Title', { exact: true })).not.toHaveAttribute('placeholder', 'Mixed');
// editing a field applies it to the whole selection
await title.fill('shared title');
await title.press('Enter');
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('shared title');
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('shared title');
// the value is no longer mixed
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('shared title');
// a batched duration is applied to every event and the rundown is recalculated once
const duration = editor.getByTestId('time-input-duration');
await duration.click();
await duration.fill('5m');
await duration.press('Enter');
await expect(page.getByTestId('entry-1').getByTestId('time-input-duration')).toHaveValue('00:05:00');
await expect(page.getByTestId('entry-2').getByTestId('time-input-duration')).toHaveValue('00:05:00');
// the second event is linked, so its start follows the new end of the first
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeStart')).toHaveValue('00:05:00');
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeEnd')).toHaveValue('00:10:00');
// going back to a single selection restores the full editor
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
await expect(editor.locator('#eventId')).not.toHaveValue('2 events selected');
});
@@ -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();
@@ -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 {