Compare commits

...

17 Commits

Author SHA1 Message Date
Claude b59dc796cf feat: collapsible aux timers section, fix icon button variants
- Add a collapse toggle next to the aux timers settings shortcut,
  mirroring the chevron pattern already used for rundown groups. State
  persists in localStorage via the existing editorSettings store, so
  it's a durable per-user preference rather than resetting on reload.
  This matters most on small viewports, where the aux timer controls
  (3 rows) are a disproportionate cost relative to their usage.
- When collapsed, a small active-indicator dot appears next to the
  "Aux timers" label if any aux timer is currently running, so a
  running timer never goes silently invisible.
- Switch both header icon buttons (settings shortcut, collapse toggle)
  from the 'subtle' variant (blue, reserved for actions) to
  'subtle-white', matching how utility/navigation icon buttons are
  styled elsewhere (eg. the rundown group collapse chevron).

Verified in the browser: collapsing reclaims the three-row control
block, the indicator dot appears while a timer runs and the panel is
collapsed, and both icon buttons render white/gray instead of blue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-08-01 07:53:20 +00:00
Claude d70d7f2fd8 fix: prevent aux timer names from breaking the playback control layout
The aux timer label sits inside a 3 column CSS grid. Grid items default
to min-width: auto, so their minimum width is set by their content -
with white-space: nowrap forcing that to the full unbroken name, the
column would grow to fit it instead of the ellipsis ever kicking in,
breaking the equal column layout for long names.

Set min-width: 0 on the label so it can shrink to its grid column, and
swap the native title attribute for the app's shared Tooltip component
so the full name is still available on hover, consistent with how
truncated text is handled elsewhere in the app.

Verified in the browser: a long name now truncates with an ellipsis
without disturbing the three-column layout, and hovering shows the
full name in a tooltip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-08-01 07:32:02 +00:00
Claude 469d83dc0b fix: keep editor identifying timers by index, revert secondary-source labels
- AuxTimer.tsx (editor playback control) now always shows the timer's
  index alongside its custom name, eg. "Aux 1: Speaker", instead of
  replacing the index entirely. Truncates to a single line with an
  ellipsis (and a title tooltip) so long names don't wrap and break the
  compact three-column layout.
- Revert the secondary-source select and its preview label
  (TimerViewControl.tsx, TimerPreview.tsx) back to plain "Aux 1/2/3" -
  these identify a fixed technical source, not the timer's identity, so
  they should stay stable regardless of naming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-08-01 07:25:18 +00:00
Claude 59bff62f76 refactor: address review feedback on aux timer naming
- Revert the automation action labels back to "Aux N: action" (no
  functional reason to change this wording).
- Simplify the aux timers header in the playback control: the flex
  container now only handles layout, and the label text reuses the
  same font-size/color as the per-timer labels below it, instead of a
  bespoke style block duplicating those values.
- Replace the generic, length-driven normaliser with an explicit
  sanitiseAuxTimerNames() that always deals with exactly three timers,
  and rename it away from "normalise" (which didn't convey that it
  trims, caps length and fills in missing entries). Static defaults
  now use a plain ['', '', ''] literal instead of calling the
  sanitiser with no input to sanitise.
- Settings.type.ts and AuxTimerSettings.tsx no longer generate their
  three fields from a loop; the form mirrors the same explicit,
  one-field-per-row style already used by GeneralSettings.tsx.
- Trim comments that only restated what the following line already
  says, keeping the ones that explain non-obvious behaviour (why
  AuxTimerService needs to be resynced separately from the data
  provider, why SimpleTimer.reset() preserves the name).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-08-01 07:15:12 +00:00
Claude 38e3ab979d fix: resync aux timer names on project load, consolidate name handling
Addresses the review findings on the aux timer naming feature.

- Aux timer names are project data, but they were only applied at
  bootstrap and on a settings POST. Loading another project left the
  previous project's names in the runtime store, so the controls and
  views disagreed with the settings panel until a restart. The names are
  now applied when a project is loaded, and patching the current project
  settings applies them and sends a settings refetch to the clients
  (loading a project already triggers a full refetch via the rundown).
- Consolidate the name handling into a single normaliser in ontime-utils,
  used by the project file parser, the API validation and to build the
  default value. This creates the property when importing project files
  saved before the feature existed, and enforces the name length limit
  server side rather than only in the form.
- loadNames normalises its input, so the runtime state is consistent
  regardless of the shape of the stored settings.
- Clarify that auxTimerNames is ordered (index 0 is aux timer 1), and
  derive the settings form from the shared aux timer count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-07-30 17:31:27 +00:00
Claude 2f29060fa4 refactor: expose aux timer names via runtime store, index in automations
Addresses review feedback on the aux timer naming feature:

- Automation action labels now reference the timers by index
  ("Aux timer 1: start") rather than the custom names, keeping the
  automation config stable regardless of naming.
- Expose the custom names through the consumer-facing runtime interface:
  the aux timer objects broadcast over the websocket now carry a `name`
  field. Names are seeded from the persisted settings at bootstrap and
  kept in sync whenever the settings change, so every consumer (views and
  integrations) reads them from the same runtime data.
- The client control and view consumers now read the name from the
  runtime store instead of querying settings directly; the settings form
  remains the persisted editing source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-07-25 08:49:51 +00:00
Claude 2f7909ccc3 feat: allow naming aux timers
Adds custom, persisted names for the three aux timers, editable in the
editor settings and surfaced everywhere an aux title is shown.

- Persist `auxTimerNames` on the Settings object (default empty, falls
  back to the stock "Aux N" label when unset). Reuses the existing
  settings pipeline: validation sanitises to a fixed-length trimmed
  array, the parser normalises loaded/legacy data, and migrations carry
  the field forward.
- New "Aux timers" settings section to name each timer, plus a shortcut
  button next to the aux timers in the playback control that jumps
  straight to it.
- Resolve names via a shared getAuxTimerLabel helper in the aux timer
  control, Studio view, secondary-source selector and preview, and the
  automation action labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-07-23 15:48:09 +00:00
will@t1d.dev 5f040092cb fix: timer-legacy view not served in desktop distributions
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.

Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
2026-07-19 10:02:45 +02:00
Carlos Valente 347c748dd9 feat(rundown): add shortcut to jump to current element 2026-07-18 17:03:02 +02:00
Carlos Valente f234a1f892 fix(rundown): prevent skipped events from influencing day calculations 2026-07-18 16:52:36 +02:00
Carlos Valente 0c5e87b7e7 fix(ui): prevent warning indicator from overflowing container 2026-07-18 16:52:36 +02:00
Carlos Valente 64082ceac0 refactor(rundown): improve visibility of count to end 2026-07-18 16:45:55 +02:00
Carlos Valente f598e8dab1 refactor(time control): improve handling of overtime and count to end 2026-07-18 16:45:55 +02:00
Carlos Valente 0c84a3ff9e fix: delays account for midnight 2026-07-18 16:45:55 +02:00
Carlos Valente c8760b5e9c bump version to 4.11.0 2026-07-18 15:24:25 +02:00
Claude 34360a5b8f feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-17 22:26:19 +02:00
Claude 5e6debcce0 fix(views): center timer digits with flexbox for embedded browser compatibility
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).

Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.

Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.

Fixes #2126

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
2026-07-17 20:59:49 +02:00
80 changed files with 1751 additions and 150 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.10.0",
"version": "4.11.0",
"private": true,
"type": "module",
"dependencies": {
+10
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
+30
View File
@@ -95,6 +95,22 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
};
});
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
return {
aux1: state.auxtimer1.name,
aux2: state.auxtimer2.name,
aux3: state.auxtimer3.name,
};
});
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
return (
state.auxtimer1.playback === SimplePlayback.Start ||
state.auxtimer2.playback === SimplePlayback.Start ||
state.auxtimer3.playback === SimplePlayback.Start
);
});
export const useAuxTimerTime = (index: number) =>
createSelector((state: RuntimeStore) => {
if (index === 1) return state.auxtimer1.current;
@@ -108,15 +124,18 @@ export const useAuxTimerControl = (index: number) =>
return {
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
name: state.auxtimer1.name,
};
if (index === 2)
return {
playback: state.auxtimer2.playback,
direction: state.auxtimer2.direction,
name: state.auxtimer2.name,
};
return {
playback: state.auxtimer3.playback,
direction: state.auxtimer3.direction,
name: state.auxtimer3.name,
};
})();
@@ -139,6 +158,17 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
@@ -6,4 +6,5 @@ export const ontimePlaceholderSettings: Settings = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
@@ -13,6 +13,7 @@ type EditorSettingsStore = {
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
auxTimersCollapsed: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
@@ -21,6 +22,7 @@ type EditorSettingsStore = {
setDangerTime: (dangerTime: string) => void;
setDefaultTimerType: (defaultTimerType: TimerType) => void;
setDefaultEndAction: (defaultEndAction: EndAction) => void;
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
};
export const editorSettingsDefaults = {
@@ -32,6 +34,7 @@ export const editorSettingsDefaults = {
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
auxTimersCollapsed: false,
};
enum EditorSettingsKeys {
@@ -43,6 +46,7 @@ enum EditorSettingsKeys {
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -67,6 +71,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
auxTimersCollapsed: booleanFromLocalStorage(
EditorSettingsKeys.AuxTimersCollapsed,
editorSettingsDefaults.auxTimersCollapsed,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -110,5 +118,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
setAuxTimersCollapsed: (auxTimersCollapsed) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
return { auxTimersCollapsed };
}),
};
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
@@ -0,0 +1,10 @@
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
const custom = name?.trim();
return custom ? custom : fallback;
}
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
const custom = name?.trim();
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
}
+6
View File
@@ -3,6 +3,7 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedStart,
} from 'ontime-utils';
@@ -29,6 +30,11 @@ export function nowInMillis(): number {
return elapsed;
}
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/**
* @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -38,6 +38,18 @@ export function validateProjectFile(file: File) {
}
}
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
@@ -1,4 +1,6 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
@@ -22,7 +24,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -35,6 +37,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource =
| {
kind: 'excel';
fileName: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
@@ -55,7 +58,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const { data: currentRundown } = useRundown();
const { importRundown } = useSpreadsheetImport();
const { applyImport } = useSpreadsheetImport();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +77,7 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({
kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
@@ -126,21 +130,32 @@ export default function SourcesPanel() {
setError('');
};
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
const handleApplyImport = async (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
return;
}
if (!currentRundown) {
throw new Error('No current rundown loaded');
}
await importRundown(
{
[currentRundown.id]: {
...preview.rundown,
id: currentRundown.id,
title: currentRundown.title,
},
},
preview.customFields,
);
// override or merge into the current rundown; merge uses providedFields to know which columns
// the sheet supplied so matched events keep the rest (e.g. automations)
await applyImport({
mode,
targetRundownId: currentRundown.id,
rundown: preview.rundown,
customFields: preview.customFields,
providedFields,
});
handleFinished();
};
@@ -208,6 +223,13 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`;
})();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return (
<Panel.Section>
@@ -287,6 +309,7 @@ export default function SourcesPanel() {
bodyElements={
<SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata}
@@ -0,0 +1,55 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,6 +33,11 @@
white-space: nowrap;
}
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger {
justify-content: center;
white-space: nowrap;
@@ -1,10 +1,20 @@
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import { useMemo } from 'react';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm';
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>;
}
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit,
handleExportSubmit,
handleApply,
} = useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
onExport,
});
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -107,33 +164,63 @@ export default function SheetImportEditor({
</div>
{displayError && <Panel.Error>{displayError}</Panel.Error>}
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
{importMode === 'merge' && !isIdColumnMapped(values) && (
<Panel.Description tone='warning'>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
your rundown to a spreadsheet first to keep its IDs.
</Panel.Description>
)}
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
<Panel.InlineElements wrap='wrap'>
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<Button
variant='primary'
onClick={handleApply}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
>
Apply import
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
</Button>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<ApplyImportButton
preview={state.preview}
mode={importMode}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements>
</Panel.Section>
);
@@ -1,15 +1,21 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
builtInFieldDefs,
convertToImportMap,
createDefaultFormValues,
defaultImportMode,
getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => {
@@ -128,3 +134,64 @@ describe('convertToImportMap()', () => {
});
});
});
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,3 +1,4 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -49,6 +50,16 @@ export function createDefaultFormValues(): ImportFormValues {
};
}
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName
@@ -81,6 +92,24 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
});
}
/**
* Returns the fields the import map supplies — the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => {
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
const persistedData = localStorage.getItem(storageKey);
if (!persistedData) {
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(raw);
const parsed: unknown = JSON.parse(persistedData);
if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true };
}
@@ -150,6 +179,32 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
}
}
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/
@@ -1,7 +1,12 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -11,8 +16,11 @@ import {
builtInFieldDefs,
convertToImportMap,
getImportWarnings,
getPersistedImportMode,
getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields,
persistImportMode,
persistImportState,
} from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -100,16 +108,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
}
export function useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -170,6 +185,8 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -182,6 +199,12 @@ export function useSheetImportForm({
dispatch({ type: 'reset' });
}, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => {
if (worksheetNames.length === 0) return;
@@ -241,13 +264,15 @@ export function useSheetImportForm({
try {
dispatch({ type: 'startApply' });
await onApply(state.preview);
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' });
} catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) });
}
}, [getValues, onApply, sourceKey, state.preview]);
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
const handleExport = useCallback(
async (formValues: ImportFormValues) => {
@@ -300,6 +325,10 @@ export function useSheetImportForm({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport),
handleApply,
@@ -1,30 +1,17 @@
import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { RundownImportPayload } from 'ontime-types';
import { useCallback } from 'react';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
import { importRundownWithOptions } from '../../../../../common/api/rundown';
export default function useSpreadsheetImport() {
const queryClient = useQueryClient();
/** applies rundown and customFields to current project */
const importRundown = useCallback(
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
// through that single path rather than racing it with an optimistic write from here
await importRundownWithOptions(payload);
}, []);
return {
importRundown,
applyImport,
};
}
@@ -0,0 +1,95 @@
import { Settings } from 'ontime-types';
import { auxTimerNameMaxLength } from 'ontime-utils';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
export default function AuxTimerSettings() {
const { data, status, refetch } = useSettings();
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, errors },
} = useForm<Settings>({
defaultValues: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: Settings) => {
try {
await postSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const isLoading = status === 'pending';
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='aux-timer-settings'
>
<Panel.Card>
<Panel.SubHeader>
Aux timers
<Panel.InlineElements>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -3,6 +3,7 @@ import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import AuxTimerSettings from './AuxTimerSettings';
import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData';
@@ -12,6 +13,7 @@ import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
@@ -26,6 +28,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={generalRef}>
<GeneralSettings />
</div>
<div ref={auxTimersRef}>
<AuxTimerSettings />
</div>
<div ref={viewRef}>
<ViewSettings />
</div>
@@ -17,6 +17,7 @@ const staticOptions = [
secondary: [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__aux-timers', label: 'Aux timers' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__mcp', label: 'MCP Server' },
@@ -3,6 +3,34 @@
margin: 0 auto;
}
.auxHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.auxHeaderButtons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.activeIndicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
}
.auxTimers {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1,4 +1,10 @@
import { usePlaybackControl } from '../../../common/hooks/useSocket';
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
@@ -8,6 +14,9 @@ import style from './PlaybackControl.module.scss';
export default function PlaybackControl() {
const data = usePlaybackControl();
const { setLocation } = useAppSettingsNavigation();
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
const isAuxTimerActive = useAuxTimersActive();
return (
<div className={style.mainContainer}>
@@ -20,11 +29,42 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
<div className={style.auxHeader}>
<span className={style.label}>
Aux timers
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
</span>
<div className={style.auxHeaderButtons}>
<Tooltip
text='Name aux timers'
render={
<IconButton
size='small'
variant='subtle-white'
aria-label='Name aux timers'
onClick={() => setLocation('settings__aux-timers')}
/>
}
>
<IoSettingsOutline />
</Tooltip>
<IconButton
size='small'
variant='subtle-white'
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
>
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
</div>
{!auxTimersCollapsed && (
<div className={style.auxTimers}>
<AuxTimer index={1} />
<AuxTimer index={2} />
<AuxTimer index={3} />
</div>
)}
</div>
);
}
@@ -1,8 +1,18 @@
.label {
display: block;
margin-top: 1rem;
// aux timers sit in a 3 column grid, without this a long name would grow its column
// instead of shrinking to it, breaking the equal column layout
min-width: 0;
}
.labelText {
display: block;
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
@@ -3,7 +3,9 @@ import { millisToString, parseUserTime } from 'ontime-utils';
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -13,10 +15,12 @@ interface AuxTimerProps {
}
export function AuxTimer({ index }: AuxTimerProps) {
const { playback, direction } = useAuxTimerControl(index);
const { playback, direction, name } = useAuxTimerControl(index);
const { stop, setDirection } = setAuxTimer;
const label = getAuxTimerIndexedLabel(name, index);
const toggleDirection = () => {
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setDirection(index, newDirection);
@@ -27,10 +31,12 @@ export function AuxTimer({ index }: AuxTimerProps) {
return (
<label className={style.label}>
Aux Timer {index}
<Tooltip text={label} render={<span />} className={style.labelText}>
{label}
</Tooltip>
<div className={style.controls}>
<div className={style.input}>
<AuxTimerInput index={index} isActive={isActive} />
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
@@ -50,9 +56,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
interface AuxTimerInputProps {
index: number;
isActive: boolean;
placeholder: string;
}
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
const newTimeInMs = useAuxTimerTime(index);
const { setDuration } = setAuxTimer;
@@ -70,7 +77,7 @@ function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
}
return (
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
);
}
@@ -73,6 +73,10 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,12 +1,13 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer();
'use memo';
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
<RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)}
</div>
{children}
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
}
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
return (
<>
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span>
</span>
</>
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
handleCollapseGroup,
});
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
const jumpToCurrent = useCallback(() => {
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
if (!targetId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[targetId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
// Keyboard shortcuts
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
});
// DND handlers
@@ -44,6 +44,9 @@ function EventEditorEmpty() {
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to current entry'>
<Combo keys={[deviceAlt, 'L']} />
</Shortcut>
<Shortcut label='Deselect entry'>
<Combo keys={['Esc']} />
</Shortcut>
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime } from '../../../../common/utils/time';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
@@ -68,8 +68,10 @@ function EventEditorTimes({
};
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(timeStart + delay)}${formatTime(timeEnd + delay)}`
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
@@ -86,7 +88,6 @@ function EventEditorTimes({
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
}
/**
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
background-color: $block-bg;
margin-block: 0.25rem;
overflow: initial;
position: relative;
display: grid;
grid-template-areas:
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center;
padding-right: $block-clearance;
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
align-items: center;
gap: $block-clearance;
height: 100%;
min-width: 0;
}
.eventTimers.editMode:hover {
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
align-items: center;
justify-content: space-between;
.nextTag {
.warningMeta {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning {
color: $orange-500;
}
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
countToEnd={countToEnd}
/>
</div>
<div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>}
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -157,7 +157,13 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
<LuArrowDownToLine
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
</Tooltip>
<Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} />
@@ -1,10 +1,33 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02');
});
it('wraps negative delayed starts under midnight', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:59');
});
it('wraps later-day negative delays using delay as the source of truth', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:30');
});
it('displays positive delays as wall-clock time', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 02:30');
});
});
@@ -1,15 +1,16 @@
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay);
const delayedStart = normaliseWallClock(timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
@@ -4,10 +4,15 @@
.timerNote {
width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500;
font-size: 1.5em;
}
.timerNotePlaceholder {
visibility: hidden;
}
.inactive {
color: $muted-gray;
}
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps {
eventId: string;
countToEnd: boolean;
timeStart: number;
timeEnd: number;
duration: number;
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
linkStart: boolean;
delay: number;
showLabels?: boolean;
showWarnings?: boolean;
}
export default memo(TimeInputFlow);
function TimeInputFlow({
eventId,
countToEnd,
timeStart,
timeEnd,
duration,
@@ -35,6 +34,7 @@ function TimeInputFlow({
linkStart,
delay,
showLabels,
showWarnings = true,
}: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,10 +56,6 @@ function TimeInputFlow({
warnings.push('Over midnight');
}
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -136,11 +132,19 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
{warnings.length > 0 && (
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
<IoAlertCircleOutline />
</Tooltip>
)}
{showWarnings &&
(warnings.length > 0 ? (
<Tooltip
text={warnings.join(' - ')}
className={style.timerNote}
data-testid='event-warning'
render={<span />}
>
<IoAlertCircleOutline />
</Tooltip>
) : (
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
))}
</>
);
}
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
{
type: 'item',
label: 'Planning',
description: 'Edit-focused list with planning stats',
description: 'Edit focused list with planning stats',
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
},
@@ -28,9 +28,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
@@ -1,7 +1,8 @@
import { Playback, TimerPhase, ViewSettings } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { useAuxTimersName, useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
import { getAuxTimerLabel } from '../../common/utils/auxTimerUtils';
import { getOffsetState } from '../../common/utils/offset';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -121,22 +122,23 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
function StudioTimersAux() {
const auxTimer = useAuxTimersTime();
const auxName = useAuxTimersName();
return (
<div className='card' id='card-aux'>
<div className='card__row'>
<div>
<div className='label'>Aux 1</div>
<div className='label'>{getAuxTimerLabel(auxName.aux1, 'Aux 1')}</div>
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
</div>
<div>
<div className='label center'>Aux 2</div>
<div className='label center'>{getAuxTimerLabel(auxName.aux2, 'Aux 2')}</div>
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
</div>
<div>
<div className='label right'>Aux 3</div>
<div className='label right'>{getAuxTimerLabel(auxName.aux3, 'Aux 3')}</div>
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
</div>
</div>
+3 -3
View File
@@ -82,9 +82,9 @@
.timer-container {
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
+8 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.10.0",
"version": "4.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -125,6 +125,13 @@
"**/*",
"*{.ts}"
]
},
{
"from": "../server/src/html/",
"to": "extraResources/html/",
"filter": [
"**/*"
]
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.10.0",
"version": "4.11.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.10.0",
"version": "4.11.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -90,4 +90,14 @@ describe('test parseDatabaseModel() edge cases', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
it('creates the aux timer names when importing a project file which predates the feature', () => {
const oldProject = structuredClone(demoDb);
// @ts-expect-error -- simulating a project file saved before aux timer naming existed
delete oldProject.settings.auxTimerNames;
const { data } = parseDatabaseModel(oldProject);
expect(data.settings.auxTimerNames).toStrictEqual(['', '', '']);
});
});
@@ -74,7 +74,15 @@ export function migrateSettings(jsonData: object): (Settings & { serverPort: num
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
return {
version: '4.0.0',
serverPort,
editorKey,
operatorKey,
timeFormat,
language,
auxTimerNames: ['', '', ''],
};
}
}
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { is } from '../../../utils/is.js';
@@ -23,6 +24,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
const auxTimerNames = sanitiseAuxTimerNames(settings?.auxTimerNames);
const version = '4.5.0';
db.settings = {
version,
@@ -30,6 +32,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
operatorKey,
timeFormat,
language,
auxTimerNames,
app: 'ontime',
} as Settings;
return { db, serverPort: settings?.serverPort };
@@ -184,6 +184,7 @@ describe('v3 to v4', () => {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
const newSettings = v3.migrateSettings(oldDb);
expect(newSettings).toEqual(expectSettings);
@@ -432,6 +432,40 @@ describe('processRundown()', () => {
expect(initResult.totalDuration).toBe(500 - 100);
});
it('skipped events do not advance day offsets or affect gaps', () => {
const rundown = makeRundown({
order: ['1', 'skipped', '2'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
skipped: makeOntimeEvent({
id: 'skipped',
skip: true,
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
}),
},
});
const initResult = processRundown(rundown, {});
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
});
it('calculates total duration across days with gap', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
@@ -1,8 +1,18 @@
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import {
EndAction,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
TimeStrategy,
TimerLifeCycle,
TimerType,
Trigger,
} from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import {
calculateDayOffset,
deleteById,
@@ -10,6 +20,8 @@ import {
getIntegerAndFraction,
hasChanges,
makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
} from '../rundown.utils.js';
describe('test event validator', () => {
@@ -287,3 +299,314 @@ describe('getIntegerAndFraction()', () => {
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
});
});
/**
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
* any field it does not provide (e.g. automations) is kept from the existing event.
*/
describe('mergeRundownPreservingFields()', () => {
const automation: Trigger = {
id: 'trigger-onair',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-onair',
};
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
title: 'Main show',
revision: 3,
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
title: 'From spreadsheet',
revision: 0,
order: ['welcome', 'lunch'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
},
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// identity and revision come from the current rundown
expect(merged.id).toBe('show-rundown');
expect(merged.title).toBe('Main show');
expect(merged.revision).toBe(4);
// structure and order come from the incoming rundown
expect(merged.order).toEqual(['welcome', 'lunch']);
expect(merged.flatOrder).toEqual(incoming.flatOrder);
});
it('deletes current entries that are absent from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome' }),
keynote: makeOntimeEvent({ id: 'keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['welcome'],
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
expect(merged.entries.welcome).toBeDefined();
expect(merged.entries.keynote).toBeUndefined();
});
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// the incoming group fully replaces the previous event, no old data is carried over
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
});
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
});
// the sheet mapped title and note
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
// an empty provided value replaces the current one
expect(keynote.note).toBe('');
});
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
},
});
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Opening keynote',
note: 'from a stale column',
triggers: [],
}),
},
});
// only title is mapped, so note and automations keep the existing values, not the incoming ones
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.note).toBe('green room');
expect(keynote.triggers).toEqual([automation]);
});
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Keynote',
custom: { lighting: 'warm', song: 'intro theme' },
}),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
},
});
// the sheet mapped the title and the lighting custom field, but not song
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.custom.lighting).toBe('cold');
// an unmapped custom field is kept from the existing event
expect(keynote.custom.song).toBe('intro theme');
});
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
});
it('keeps the existing time strategy when the provided times are ambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
});
it('merges a matched group, keeping the fields the sheet cannot express', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
});
// the sheet cannot express a group's target duration, so the incoming group does not carry one
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const session = merged.entries.session as OntimeGroup;
expect(session.title).toBe('New session');
expect(session.targetDuration).toBe(3_600_000);
});
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
});
// only title is mapped, so the milestone's note keeps the existing value
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const reminder = merged.entries.reminder as OntimeMilestone;
expect(reminder.title).toBe('Green room reminder');
expect(reminder.note).toBe('call talent');
});
it('does not mutate the current rundown and deep-clones the kept automations', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// kept automations are a copy, not a shared reference to the current rundown
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
});
it('keeps the not-provided fields through a parseRundown round-trip', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const parsed = parseRundown(merged, {});
const keynote = parsed.entries.keynote as OntimeEvent;
expect(keynote.triggers).toEqual([automation]);
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
});
});
describe('isLoadedPlayable()', () => {
it('returns true when the loaded event still exists and is playable', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
});
it('returns false when the loaded event was removed', () => {
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the loaded event is now skipped', () => {
const rundown = makeRundown({
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
});
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the matched entry is no longer an event', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
@@ -279,14 +279,22 @@ function processEntry<T extends OntimeEntry>(
// 2. handle custom fields - mutates entry
sanitiseCustomFields(customFields, entry);
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
/*
* we initialise data so that is not calculated for skipped events
* consider especially the day offset, while skipped events have no
* day offset, we match it to the previous element to avoid
* pushing confusing data to the UI
*/
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
entry.delay = 0;
entry.gap = 0;
entry.parent = childOfGroup;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(entry)) {
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
rundownMetadata.playableEventOrder.push(entry.id);
// first start is always the first event
@@ -1,7 +1,15 @@
import type { Request, Response, Router } from 'express';
import express from 'express';
import { matchedData } from 'express-validator';
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
import {
ErrorResponse,
OntimeEntry,
ProjectRundowns,
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -12,7 +20,9 @@ import {
applyDelay,
batchEditEntries,
cloneEntry,
applyImportToRundown,
createNewRundown,
createRundownFromImport,
deleteAllEntries,
deleteEntries,
deleteRundown,
@@ -36,6 +46,7 @@ import {
entryReorderValidator,
entrySwapValidator,
rundownArrayOfIds,
rundownImportValidator,
rundownPatchValidator,
rundownPostValidator,
} from './rundown.validation.js';
@@ -147,6 +158,39 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
}
});
/**
* Applies an imported rundown: override or merge into an existing rundown, or create a new one.
*/
router.post(
'/import',
rundownImportValidator,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData<RundownImportPayload>(req);
let projectRundowns: ProjectRundowns;
if (mode === 'new') {
projectRundowns = await createRundownFromImport(rundown, customFields);
} else {
// the validator guarantees this for override/merge, the guard narrows the type and adds defence in depth
if (!targetRundownId) {
throw new Error('targetRundownId is required when mode is override or merge');
}
projectRundowns = await applyImportToRundown(
mode,
targetRundownId,
rundown,
customFields,
providedFields ?? { event: [], custom: [] },
);
}
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -4,6 +4,7 @@ import {
CustomFields,
EntryId,
EventPostPayload,
ImportedFields,
InsertOptions,
LogOrigin,
OntimeEntry,
@@ -12,6 +13,7 @@ import {
ProjectRundowns,
RefetchKey,
Rundown,
RundownImportMergeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
@@ -25,6 +27,7 @@ import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import {
createTransaction,
customFieldMutation,
@@ -33,8 +36,15 @@ import {
rundownMutation,
updateBackgroundRundown,
} from './rundown.dao.js';
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
import {
generateEvent,
getIntegerAndFraction,
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
} from './rundown.utils.js';
/**
* creates a new entry with given data
@@ -656,8 +666,8 @@ export async function loadRundown(id: string) {
}
/**
* Sets a new rundown in the cache
* and marks it as the currently loaded one
* Sets a new rundown in the cache and marks it as the currently loaded one.
* Switching to a rundown always stops playback.
*/
export async function initRundown(
rundown: Readonly<Rundown>,
@@ -679,6 +689,25 @@ export async function initRundown(
});
}
/**
* Applies a rebuilt version of the currently loaded rundown in place.
* Unlike switching rundowns, this maintains playback when possible
*/
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
const loadedEvent = runtimeService.getLoadedEventId();
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
runtimeService.stop();
}
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
updateRuntimeOnChange(rundownMetadata);
setImmediate(() => {
// notifying the timer hot-reloads the playing event and keeps playback
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
sendRefetch(RefetchKey.ProjectRundowns);
});
}
export async function createNewRundown(title: string) {
const emptyRundown = makeNewRundown();
emptyRundown.title = title;
@@ -743,6 +772,86 @@ export async function duplicateExistingRundown(id: string) {
return dataProvider.getProjectRundowns();
}
/**
* Validates an imported rundown against the resulting custom fields, then commits those custom
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
* validation cannot leave a partial custom-field write behind.
* @throws if the rundown or custom fields fail validation
*/
async function parseImportAndCommitCustomFields(
source: Readonly<Rundown>,
incomingCustomFields: CustomFields,
): Promise<Rundown> {
const dataProvider = getDataProvider();
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
const parsed = parseRundown(source, mergedCustomFields);
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
return parsed;
}
/**
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
* taking structure and order from the incoming data.
* - 'override' replaces all content with the incoming data
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
* (e.g. automations) from the existing entry
*
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
* maintained when the playing event survives.
* @throws if the target rundown does not exist
*/
export async function applyImportToRundown(
strategy: RundownImportMergeStrategy,
targetRundownId: string,
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
providedFields: ImportedFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// throws if the rundown was deleted between preview and apply
const existing = dataProvider.getRundown(targetRundownId);
const source =
strategy === 'merge'
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
if (isCurrentRundown(targetRundownId)) {
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
} else {
await dataProvider.setRundown(parsed.id, parsed);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
}
return dataProvider.getProjectRundowns();
}
/**
* Creates a new rundown from an imported rundown and loads it,
* so the user immediately sees the imported data.
* Loading a rundown stops playback (as with any rundown switch).
*/
export async function createRundownFromImport(
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// assign a fresh id so we never collide with an existing rundown
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
parsed.revision = 0;
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
await initRundown(parsed, dataProvider.getCustomFields(), true);
return dataProvider.getProjectRundowns();
}
/**
* Deletes a rundown
* @throws if attempting to delete the loaded rundown or the last rundown in the project
@@ -2,6 +2,7 @@ import {
CustomFields,
EntryCustomFields,
EntryId,
ImportedFields,
OntimeBaseEvent,
OntimeDelay,
OntimeEntry,
@@ -11,12 +12,14 @@ import {
ProjectRundown,
ProjectRundowns,
Rundown,
RundownEntries,
SupportedEntry,
TimeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
isPlayableEvent,
} from 'ontime-types';
import {
createDelay,
@@ -225,6 +228,83 @@ export function getUniqueId(rundown: Rundown): EntryId {
return id;
}
/**
* Builds an entry patch containing exactly the fields the spreadsheet supplied, for both built-in
* and custom fields. Everything the sheet did not map is left out, so applying the patch keeps the
* existing value for those fields.
*/
function buildImportPatch(entry: OntimeEntry, providedFields: ImportedFields): Partial<OntimeEntry> {
const source = entry as Record<string, unknown>;
const patch: Record<string, unknown> = {};
for (const field of providedFields.event) {
patch[field] = source[field];
}
if (providedFields.custom.length > 0) {
const entryCustom = (source.custom ?? {}) as EntryCustomFields;
const custom: EntryCustomFields = {};
for (const key of providedFields.custom) {
custom[key] = entryCustom[key] ?? '';
}
patch.custom = custom;
}
return patch as Partial<OntimeEntry>;
}
/**
* Merges an imported rundown into an existing one
* - the incoming rundown is the source of truth for entry identity and structure (order + grouping)
* - a matched entry of the same type is merged field-by-field: a field the sheet provided overwrites
* (even when empty), a field the sheet cannot express (an event's automations, a group's target
* duration, an unmapped custom field) is kept from the existing entry
* - a new id, or an id whose type changed, takes the incoming entry wholesale
* - existing entries absent from the incoming rundown are dropped
*/
export function mergeRundownPreservingFields(
incoming: Readonly<Rundown>,
existing: Readonly<Rundown>,
providedFields: ImportedFields,
): Rundown {
const entries: RundownEntries = {};
for (const [id, incomingEntry] of Object.entries(incoming.entries)) {
const existingEntry = existing.entries[id];
// a new id, or one whose type changed, is not compatible for a merge: take the incoming data
if (existingEntry === undefined || existingEntry.type !== incomingEntry.type) {
entries[id] = incomingEntry;
continue;
}
// merge the sheet's data onto the existing entry through the canonical patch function, which
// keeps every unmapped field and infers an event's time strategy from the provided times
const merged = applyPatchToEntry(existingEntry, buildImportPatch(incomingEntry, providedFields));
// grouping comes from the sheet structure, not a data column: a group owns its children, every
// other entry knows its parent
const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure });
}
return {
id: existing.id,
title: existing.title,
order: [...incoming.order],
flatOrder: [...incoming.flatOrder],
revision: existing.revision + 1,
entries,
};
}
/**
* Whether the currently playing event survives a change to its rundown,
* i.e. it still exists and is playable in the new version.
*/
export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundown>): boolean {
const entry = rundown.entries[loadedEventId];
return entry !== undefined && isOntimeEvent(entry) && isPlayableEvent(entry);
}
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
@@ -11,6 +11,27 @@ export const rundownPatchValidator = [
requestValidationFunction,
];
export const rundownImportValidator = [
body('mode').isString().isIn(['override', 'merge', 'new']),
body('targetRundownId')
.if(body('mode').isIn(['override', 'merge']))
.isString()
.trim()
.notEmpty()
.withMessage('targetRundownId is required when mode is override or merge'),
body('rundown').isObject(),
body('rundown.entries').isObject(),
body('rundown.order').isArray(),
body('rundown.flatOrder').isArray(),
body('customFields').isObject(),
body('providedFields').optional().isObject(),
body('providedFields.event').optional().isArray(),
body('providedFields.event.*').isString(),
body('providedFields.custom').optional().isArray(),
body('providedFields.custom.*').isString(),
requestValidationFunction,
];
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -16,6 +16,36 @@ describe('parseSettings()', () => {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
});
});
it('carries custom aux timer names through and pads to a length-3 array', () => {
const result = parseSettings({
settings: { version: '1', auxTimerNames: ['Speaker'] } as unknown as Settings,
});
expect(result.auxTimerNames).toStrictEqual(['Speaker', '', '']);
});
it('falls back to defaults when aux timer names are missing or malformed', () => {
const result = parseSettings({
settings: { version: '1', auxTimerNames: 'not-an-array' } as unknown as Settings,
});
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
});
it('creates the aux timer names for project files made before the feature existed', () => {
const oldSettings = {
version: '4.5.0',
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
};
const result = parseSettings({ settings: oldSettings as Settings });
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
});
});
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { getPartialProject } from '../../models/dataModel.js';
@@ -22,5 +23,7 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
language: data.settings.language ?? defaultSettings.language,
// older project files predate this property
auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames),
};
}
@@ -9,6 +9,7 @@ import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { auxTimerService } from '../../services/aux-timer-service/AuxTimerService.js';
import { validateSettings, validateWelcomeDialog, validateServerPort } from './settings.validation.js';
export const router: Router = express.Router();
@@ -41,6 +42,10 @@ router.post('/', validateSettings, async (req: Request, res: Response<Settings |
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
// keep the runtime aux timers in sync so consumers get the new names live
if (!deepEqual(data.auxTimerNames, settings.auxTimerNames)) {
auxTimerService.loadNames(data.auxTimerNames);
}
sendRefetch(RefetchKey.Settings);
}
@@ -1,4 +1,5 @@
import { body } from 'express-validator';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -27,6 +28,7 @@ export const validateSettings = [
pinValidator('operatorKey'),
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(),
body('auxTimerNames').isArray().withMessage('auxTimerNames must be an array').customSanitizer(sanitiseAuxTimerNames),
requestValidationFunction,
];
+11 -1
View File
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { LogOrigin, SimpleDirection, SimplePlayback, runtimeStorePlaceholder } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import serverTiming from 'server-timing';
import { oscServer } from './adapters/OscAdapter.js';
@@ -26,6 +27,7 @@ import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
import { auxTimerService } from './services/aux-timer-service/AuxTimerService.js';
import * as messageService from './services/message-service/message.service.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
import { restoreService } from './services/restore-service/restore.service.js';
@@ -117,8 +119,9 @@ app.use(`${prefix}/external`, (req, res) => {
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
// Serve legacy timer for old browsers that don't support modern JS
// dotfiles must be allowed since the install path can contain dot directories (eg AppImage mounts in /tmp/.mount_*)
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
res.sendFile(srcFiles.timerLegacy);
res.sendFile(srcFiles.timerLegacy, { dotfiles: 'allow' });
});
// Base route for static files
@@ -203,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
* Module initialises the services and provides initial payload for the store
*/
const state = getState();
const [auxName1, auxName2, auxName3] = sanitiseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
eventStore.init({
clock: state.clock,
timer: state.timer,
@@ -218,22 +222,28 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName1,
},
auxtimer2: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName2,
},
auxtimer3: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName3,
},
ping: 1,
});
// AuxTimerService owns its own SimpleTimer instances, so the store above doesn't update them
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
// initialise message service
messageService.init(eventStore.set, eventStore.get);
@@ -81,6 +81,7 @@ describe('safeMerge', () => {
editorKey: null,
timeFormat: baseDb.settings.timeFormat,
language: 'pt',
auxTimerNames: baseDb.settings.auxTimerNames,
});
});
@@ -6,6 +6,7 @@ export class SimpleTimer {
current: 0,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: '',
};
private startedAt: number | null = null;
private pausedAt: number | null = null;
@@ -23,9 +24,16 @@ export class SimpleTimer {
current: 0,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
// the name is a persisted configuration, independent of the timer runtime
name: this.state.name,
};
}
public setName(name: string): SimpleTimerState {
this.state.name = name;
return this.state;
}
/**
* Sets the duration of the timer
* @param time - time in milliseconds
@@ -16,6 +16,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Stop,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -27,6 +28,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -38,6 +40,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime - 100,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
};
expect(newState).toStrictEqual(expected);
@@ -58,6 +61,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime - 1500,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Pause,
name: '',
};
expect(newState).toStrictEqual(expected);
@@ -83,6 +87,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Stop,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -97,6 +102,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
};
newState = timer.update(100);
@@ -127,6 +133,7 @@ describe('SimpleTimer count-down', () => {
current: 1000,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(100);
@@ -135,6 +142,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime + 100,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(500);
@@ -143,6 +151,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.setDirection(SimpleDirection.CountDown, 600);
@@ -151,6 +160,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(700);
@@ -159,6 +169,7 @@ describe('SimpleTimer count-down', () => {
current: 1400,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.setDirection(SimpleDirection.CountUp, 700);
@@ -167,6 +178,7 @@ describe('SimpleTimer count-down', () => {
current: 1400,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(800);
@@ -175,6 +187,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
});
+1
View File
@@ -30,6 +30,7 @@ const dbModel: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
},
viewSettings: {
overrideStyles: false,
+1
View File
@@ -29,6 +29,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
},
viewSettings: {
dangerColor: '#ff7300',
@@ -1,4 +1,5 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { timerConfig } from '../../setup/config.js';
@@ -24,6 +25,20 @@ export class AuxTimerService {
this.getTime = getTime;
}
/**
* Called at bootstrap and whenever the loaded project's settings change,
* so the running timers reflect the current project's aux timer names.
*/
loadNames(names?: string[]) {
const [name1, name2, name3] = sanitiseAuxTimerNames(names);
const patch: AuxTimerStateUpdate = {
auxtimer1: this.aux1.setName(name1),
auxtimer2: this.aux2.setName(name2),
auxtimer3: this.aux3.setName(name3),
};
this.emit(patch);
}
/**
* Whether any of the aux timers are currently running
*/
@@ -0,0 +1,53 @@
import { RuntimeStore } from 'ontime-types';
import { AuxTimerService } from '../AuxTimerService.js';
describe('AuxTimerService', () => {
describe('loadNames()', () => {
it('applies the names to each aux timer and broadcasts them', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['Speaker', 'Break', 'Q&A']);
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('Speaker');
expect(patch.auxtimer2?.name).toBe('Break');
expect(patch.auxtimer3?.name).toBe('Q&A');
});
it('defaults missing names to an empty string', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['only-one']);
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('only-one');
expect(patch.auxtimer2?.name).toBe('');
expect(patch.auxtimer3?.name).toBe('');
});
it('handles names missing from a project file', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
expect(() => service.loadNames(undefined)).not.toThrow();
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('');
expect(patch.auxtimer2?.name).toBe('');
expect(patch.auxtimer3?.name).toBe('');
});
it('keeps the name on the timer through subsequent commands', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['Speaker', '', '']);
const started = service.start(1);
expect(started.name).toBe('Speaker');
});
});
});
@@ -1,9 +1,10 @@
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -28,6 +29,7 @@ import {
removeFileExtension,
} from '../../utils/fileManagement.js';
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
import { runtimeService } from '../runtime-service/runtime.service.js';
import {
doesProjectExist,
@@ -88,12 +90,16 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
// stop the runtime service
runtimeService.stop();
// AuxTimerService holds its own state, independent of the loaded project, so it needs to be updated explicitly
auxTimerService.loadNames(projectData.settings.auxTimerNames);
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
? projectData.rundowns[rundownId]
: getFirstRundown(projectData.rundowns);
// initialising the rundown with reload sends a refetch to the clients
await initRundown(rundown, projectData.customFields, true);
// persist the project selection
@@ -346,6 +352,12 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
// AuxTimerService holds its own state, so a settings patch needs to be applied to it explicitly
if (rest.settings) {
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
sendRefetch(RefetchKey.Settings);
}
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {
@@ -165,6 +165,13 @@ class RuntimeService {
}
}
/**
* Returns the id of the currently loaded event, or null if none is loaded
*/
public getLoadedEventId(): EntryId | null {
return runtimeState.getState().eventNow?.id ?? null;
}
/**
* Called when the underlying data has changed,
* we check if the change affects the runtime
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.10.0",
"version": "4.11.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.10.0",
"version": "4.11.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -18,3 +18,44 @@ export type SpreadsheetPreviewResponse = {
customFields: CustomFields;
summary: RundownSummary;
};
/**
* How elements matched by id should be reconciled when importing into an existing rundown
* - override: imported data replaces the whole matched element
* - merge: imported data updates only the fields the spreadsheet supplied, keeping the rest
*/
export type RundownImportMergeStrategy = 'override' | 'merge';
/**
* What an import does with the data
* - override / merge apply the import onto an existing rundown (reconciling matched elements)
* - new creates a fresh rundown from the import
*/
export type RundownImportMode = RundownImportMergeStrategy | 'new';
/**
* The fields an import supplies, i.e. the columns the spreadsheet maps.
* This is the complete description of what the incoming data provides, so a merge can apply exactly
* these fields onto a matched event and keep everything else (e.g. automations) untouched.
*/
export type ImportedFields = {
/** mapped OntimeEvent fields (the import-map keys are OntimeEvent field names) */
event: string[];
/** mapped custom field keys */
custom: string[];
};
/**
* Payload for the rundown import endpoint
* - override / merge apply the import onto the target rundown (targetRundownId required)
* - new creates a fresh rundown from the import
*/
export type RundownImportPayload = {
mode: RundownImportMode;
/** required when mode is 'override' or 'merge' */
targetRundownId?: string;
rundown: Rundown;
customFields: CustomFields;
/** the fields the spreadsheet supplies; used by 'merge' to patch only those on a matched event */
providedFields?: ImportedFields;
};
@@ -6,4 +6,9 @@ export type Settings = {
operatorKey: null | string;
timeFormat: TimeFormat;
language: string;
/**
* Custom names for the aux timers, one entry per aux timer in order (index 0 is aux timer 1).
* An empty string means the timer is unnamed and consumers show the default label
*/
auxTimerNames: string[];
};
@@ -14,4 +14,6 @@ export type SimpleTimerState = {
current: number;
playback: SimplePlayback;
direction: SimpleDirection;
/** Custom name for the aux timer. Empty string when unnamed */
name: string;
};
@@ -53,18 +53,21 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
name: '',
},
auxtimer2: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
name: '',
},
auxtimer3: {
current: 0,
direction: SimpleDirection.CountUp,
duration: 0,
playback: SimplePlayback.Stop,
name: '',
},
ping: 1,
};
+4
View File
@@ -97,6 +97,10 @@ export type {
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
SpreadsheetPreviewResponse,
RundownImportMergeStrategy,
RundownImportMode,
RundownImportPayload,
ImportedFields,
} from './api/spreadsheet/Spreadsheet.type.js';
// web socket
+3
View File
@@ -99,6 +99,9 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
// aux timers
export { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './src/aux-timer-utils/auxTimerUtils.js';
//Colour
export {
colourToHex,
@@ -0,0 +1,30 @@
import { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './auxTimerUtils.js';
describe('sanitiseAuxTimerNames()', () => {
it('generates the default value when given nothing', () => {
expect(sanitiseAuxTimerNames()).toStrictEqual(['', '', '']);
});
it('always returns exactly three entries', () => {
expect(sanitiseAuxTimerNames(['a', 'b', 'c', 'extra'])).toStrictEqual(['a', 'b', 'c']);
});
it('pads missing entries with an empty string', () => {
expect(sanitiseAuxTimerNames(['Speaker'])).toStrictEqual(['Speaker', '', '']);
});
it('trims whitespace', () => {
expect(sanitiseAuxTimerNames([' Speaker ', '', ''])).toStrictEqual(['Speaker', '', '']);
});
it('caps the name length', () => {
const tooLong = 'a'.repeat(auxTimerNameMaxLength + 10);
expect(sanitiseAuxTimerNames([tooLong])[0]).toHaveLength(auxTimerNameMaxLength);
});
it('falls back to defaults for malformed data', () => {
expect(sanitiseAuxTimerNames('not-an-array')).toStrictEqual(['', '', '']);
expect(sanitiseAuxTimerNames(null)).toStrictEqual(['', '', '']);
expect(sanitiseAuxTimerNames([42, {}, undefined])).toStrictEqual(['', '', '']);
});
});
@@ -0,0 +1,16 @@
/** Maximum length of a user given aux timer name */
export const auxTimerNameMaxLength = 30;
function sanitiseAuxTimerName(value: unknown): string {
return typeof value === 'string' ? value.trim().slice(0, auxTimerNameMaxLength) : '';
}
/**
* Ontime has three aux timers. Given whatever was found on disk or in a request body,
* returns a name for each of them, so callers never need to deal with a missing
* or malformed auxTimerNames (eg. a project file saved before this feature existed).
*/
export function sanitiseAuxTimerNames(names?: unknown): [string, string, string] {
const source = Array.isArray(names) ? names : [];
return [sanitiseAuxTimerName(source[0]), sanitiseAuxTimerName(source[1]), sanitiseAuxTimerName(source[2])];
}
@@ -342,6 +342,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
},
viewSettings: {
dangerColor: '#ff7300',