Compare commits

..

12 Commits

Author SHA1 Message Date
Claude fe96941737 refactor: limit runtimeState core exports to consumed seams
Only the cores exercised by tests remain exported (startCore,
addTimeCore, updateCore, getExpectedTimesCore,
loadGroupFlagAndEndCore). The rest stay parameterized but module
private until a consumer needs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 14:54:56 +00:00
Claude ee9ac492c3 test: cover hot reload and add direct calculation tests for runtimeState cores
- characterisation scenarios for the hot-reload path (updateAll and
  updateLoaded), which was previously untested: editing the running
  event, removing the running event, and re-arming the loaded event
- direct tests for the parameterized cores against plain state objects
  with no module mocks: the update tick arithmetic and finish trigger
  window, added-time edge cases, and a documented matrix of the
  expected-times projection (offset sign, gap absorption, linked
  events, relative mode, delays and day offsets)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 14:53:01 +00:00
Claude 4a0a174eb9 refactor: extract broadcast diffing into a testable function
The state diffing and gating logic moves verbatim from the
broadcastResult decorator into collectRuntimeStateChanges in
runtime.utils.ts. The decorator becomes a thin shell: snapshot, diff,
batch the changed keys, save the restore point, send.

The entire gating matrix is now unit tested against plain state
objects (see runtime.utils.test.ts), including the characterised
entry-diffing short-circuit, without sockets or module mocking. The
broadcast-contract tests remain the end-to-end lock and are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 10:01:02 +00:00
Claude 56257adcbd perf: hoist update() inner functions out of the tick path
updateIfIdle and updateIfWaitingToRoll were declared inside update(),
allocating two closures on every 32ms tick. They are now module-level
functions receiving the state explicitly. Behaviour is unchanged, as
proven by the characterisation suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 09:57:06 +00:00
Claude b10c38514f refactor: parameterize runtimeState mutators on an explicit state
Every mutator in runtimeState.ts now has a *Core function that receives
the state it operates on and touches nothing else. The module keeps the
singleton and exports thin wrappers with the existing names and
signatures, so all call sites (EventTimer, runtime service, rundown
service) are unchanged and the hot path gains no allocations.

This also removes the partial-injection hazards of the previous seams:
start(state) wrote timer.phase, offset and expected times to the module
singleton rather than the received state, and stop(state) cleared the
singleton. Production behaviour is identical since production always
operated on the singleton; the characterisation harness and snapshots
are untouched and prove it.

The cores can now be unit tested against a plain state object without
the singleton, module resets or the rundown cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 09:55:56 +00:00
Claude 80cb1f96e7 test: add characterisation harness for the runtimeState machinery
Locks down the current behaviour of the runtime state hot path before
any refactoring work:

- scenario driver and fixtures that script playback sequences against
  the real runtimeState singleton with fake timers, mirroring the
  EventTimer tick loop
- characterisation catalogue covering absolute/relative offsets,
  expected group/flag/rundown times, gap compensation, delays,
  countToEnd, addTime edge cases, pause accounting, roll modes and
  playback across midnight
- broadcast-contract tests locking which top-level store keys are
  emitted per action and tick, including the per-second gating and
  integration throttling
- unit tests for the previously untested change-detection predicates
  in runtime.utils.ts

The tests characterise current behaviour, including quirks (documented
inline): the entry diffing short-circuit that drips changed entries one
per broadcast, the sticky forceFinish flag after negative addTime, and
roll-continuation overwriting actualStart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-03 17:05:30 +00:00
Carlos Valente 42147d4d5b allow editing cuesheet link permissions 2026-07-01 13:24:47 +02:00
Carlos Valente 7850358039 bump ontime to version 4.10.0 2026-07-01 13:20:04 +02:00
Claude 52cae1e22f feat(timeline): add group reference
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2026-07-01 12:48:58 +02:00
Carlos Valente 48f9fb2609 expose mcp server in ontime 2026-07-01 12:48:40 +02:00
Carlos Valente ce8a4d230b refactor(translations): allow resetting to default values 2026-07-01 09:02:13 +02:00
Carlos Valente 1553573b94 feat(url-presets): allow presets to appear as nav menu items (#2110) 2026-06-28 18:07:02 +02:00
57 changed files with 3352 additions and 700 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.9.0",
"version": "4.10.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.9.0",
"version": "4.10.0",
"private": true,
"type": "module",
"dependencies": {
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label}
</ClientLink>
))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div>
{isLocalhost && (
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -37,6 +37,7 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
displayInNav: false,
options: {},
},
];
@@ -119,6 +120,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
options: {
read: 'full',
write: '-',
@@ -131,6 +133,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
},
];
const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -139,6 +142,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked',
target: OntimeView.Cuesheet,
search: 'n=1',
displayInNav: false,
},
];
@@ -220,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
displayInNav: false,
},
],
[
@@ -231,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
displayInNav: false,
},
],
[
@@ -242,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
displayInNav: false,
},
],
[
@@ -253,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
displayInNav: false,
},
],
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path,
search: url.searchParams.toString(),
enabled: true,
displayInNav: false,
};
}
@@ -1,7 +1,8 @@
import { URLPreset } from 'ontime-types';
import { OntimeView, URLPreset } from 'ontime-types';
import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
@@ -22,13 +23,23 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets();
const { deletePreset, isMutating } = useUpdateUrlPreset();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return (
<Panel.Section>
<Panel.Card>
@@ -53,10 +64,12 @@ export default function URLPresets() {
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th>
<th>Alias</th>
<th />
@@ -65,10 +78,23 @@ export default function URLPresets() {
<tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return (
<tr key={preset.alias}>
<td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
<Switch
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td>
<td>
<Tag>{preset.target}</Tag>
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss';
@@ -32,6 +33,7 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer,
search: '',
enabled: true,
displayInNav: false,
};
interface URLPresetFormProps {
@@ -61,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
});
const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => {
try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) {
await updatePreset(urlPreset.alias, data);
await updatePreset(urlPreset.alias, payload);
} else {
await addPreset(data);
await addPreset(payload);
}
onClose();
} catch (error) {
@@ -74,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}
};
// focus on alias when the form opens
useEffect(() => {
setFocus('alias');
}, [setFocus]);
@@ -126,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions}>Generate</Button>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
{!isEditingCuesheet && (
<>
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
<Button
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save
</Button>
</Panel.InlineElements>
@@ -10,13 +10,19 @@ export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => {
const baseUrl = isOntimeCloud
? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
const baseUrl = (() => {
if (isOntimeCloud) return serverURL;
// for local setups we prefer the localhost IP to avoid remote access
if (infoData.networkInterfaces.length > 0) {
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
}
return serverURL;
})();
// we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
@@ -1,5 +1,5 @@
import { TranslationObject, langEn } from 'ontime-types';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../common/api/utils';
@@ -10,6 +10,8 @@ import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './CustomTranslationModal.module.scss';
interface CustomTranslationModalProps {
isOpen: boolean;
onClose: () => void;
@@ -17,14 +19,7 @@ interface CustomTranslationModalProps {
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation();
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
const {
handleSubmit,
@@ -33,13 +28,17 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
formState: { isSubmitting, isDirty, errors, isValid },
setError,
} = useForm({
defaultValues,
resetOptions: {
keepDirtyValues: true,
},
defaultValues: customTranslationFormValues,
mode: 'onChange',
});
// keep data up to date
useEffect(() => {
if (isOpen) {
reset(customTranslationFormValues);
}
}, [customTranslationFormValues, isOpen, reset]);
const onSubmit = async (formData: Record<string, string>) => {
try {
const translationData: Record<string, string> = {};
@@ -54,6 +53,10 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
}
};
const resetToEnglish = () => {
reset(toFormValues(langEn), { keepDefaultValues: true });
};
return (
<Modal
title='Edit custom translations'
@@ -84,9 +87,12 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
</Panel.Section>
}
footerElements={
<div>
<div className={style.footer}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements>
<Button size='large' onClick={onClose}>
Cancel
@@ -109,6 +115,14 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
);
}
function toFormValues(translation: Partial<TranslationObject>) {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
});
return values;
}
function toFormKey(key: string) {
return key.replace('.', '_');
}
@@ -1,6 +1,6 @@
import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form';
import { generateUrl } from '../../common/api/session';
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss';
@@ -53,12 +53,26 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const cuesheetReadRef = useRef<HTMLInputElement>(null);
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
const [url, setUrl] = useState('');
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
read: 'full',
write: 'full',
});
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
const { addPreset } = useUpdateUrlPreset();
const { addPreset, updatePreset } = useUpdateUrlPreset();
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
const createdAlias = useRef<string | null>(null);
/**
* Permissions live outside react-hook-form, so we reset a successful state manually
* whenever they change - this re-arms the "Create share link" button as the previous
* link no longer reflects the selected permissions.
*/
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
setCuesheetPermissions(permissions);
setFormState((current) => (current === 'success' ? 'pending' : current));
}, []);
const {
handleSubmit,
@@ -90,16 +104,20 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions');
}
const presets = await addPreset({
const payload = {
target: OntimeView.Cuesheet,
enabled: true,
alias,
search: '',
displayInNav: false,
options: {
read: options.read,
write: options.write,
},
});
} as const;
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
createdAlias.current = alias;
return presets.find((preset) => preset.alias === alias);
};
@@ -108,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
setFormState('loading');
if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
read: cuesheetReadRef.current?.value ?? 'full',
write: cuesheetWriteRef.current?.value ?? 'full',
read: cuesheetPermissions.read,
write: cuesheetPermissions.write,
});
if (!urlPreset) {
@@ -157,6 +175,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
}
};
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
const canSubmit = isDirty || formState !== 'success';
return (
@@ -220,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
})}
/>
</Panel.ListItem>
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
<CuesheetLinkOptions onChange={handlePermissionsChange} />
</>
)}
@@ -268,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
</Panel.ListGroup>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end' className={style.end}>
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
<Button
type='submit'
variant={canSubmit ? 'primary' : 'subtle'}
loading={formState === 'loading'}
disabled={noReadAccess}
>
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
</Button>
</Panel.InlineElements>
</div>
<Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description>
<QRCode size={172} value={url} />
<div className={style.copiableLink} data-testid='copy-link'>
{url}
</div>
<CopyTag copyValue={url}>Copy link</CopyTag>
{url ? (
<>
<QRCode size={172} value={url} />
<div className={style.copiableLink} data-testid='copy-link'>
{url}
</div>
<CopyTag copyValue={url}>Copy link</CopyTag>
</>
) : (
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
)}
</Panel.Section>
</div>
</form>
@@ -1,4 +1,4 @@
import { Fragment, RefObject, useMemo, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch';
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom';
interface CuesheetLinkOptionsProps {
readRef?: RefObject<HTMLInputElement | null>;
writeRef?: RefObject<HTMLInputElement | null>;
export interface CuesheetPermissionValues {
read: string;
write: string;
}
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
interface CuesheetLinkOptionsProps {
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
initialRead?: string;
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
initialWrite?: string;
/** Notifies the parent whenever the resolved read/write permissions change */
onChange: (permissions: CuesheetPermissionValues) => void;
}
/** A null result means "full or unset" - there is no explicit per-column selection to seed */
function parseKeys(permission: string | undefined): Set<string> | null {
if (permission == null || permission === 'full') {
return null;
}
if (permission === '-') {
return new Set();
}
return new Set(permission.split(','));
}
function modeFromPermission(permission: string | undefined): AccessMode {
return permission == null || permission === 'full' ? 'full' : 'custom';
}
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
// Parsed seed values - stable for the lifetime of a given preset
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
const defaultRead = useCallback(
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
[initialReadKeys],
);
const defaultWrite = useCallback(
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
[initialWriteKeys],
);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
useEffect(() => {
setReadSwitches((prev) => {
const next = { ...prev };
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
}
return next;
});
return initialState;
});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true;
setWriteSwitches((prev) => {
const next = { ...prev };
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
}
return next;
});
return initialState;
});
}, [allColumns, defaultRead, defaultWrite]);
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
Object.keys(updatedReadSwitches).forEach((key) => {
updatedReadSwitches[key] = true;
});
return updatedReadSwitches;
});
};
const handleWriteModeChange = (value: AccessMode) => {
setWritePermissions(value);
// Full write implies full read
if (value === 'full') {
setReadPermissions('full');
}
setWritePermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches };
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
updatedReadSwitches[column.value] = true;
updatedWriteSwitches[column.value] = true;
});
return updatedWriteSwitches;
});
return updatedReadSwitches;
});
};
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
if (type === 'read') {
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
return updatedReadSwitches;
});
} else {
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
return updatedWriteSwitches;
});
const handleReadSwitch = (key: string, value: boolean) => {
setReadSwitches((prev) => ({ ...prev, [key]: value }));
// A column the recipient cannot read cannot be written either
if (!value) {
setWriteSwitches((prev) => ({ ...prev, [key]: false }));
}
};
const getReadPermissions = () => {
const handleWriteSwitch = (key: string, value: boolean) => {
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
// Granting write access requires read access
if (value) {
setReadSwitches((prev) => ({ ...prev, [key]: true }));
}
};
const resolvedRead = useMemo(() => {
if (readPermissions === 'full' || writePermissions === 'full') {
return 'full';
}
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readPermissions, writePermissions, readSwitches, allColumns]);
return Object.entries(readSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
const getWritePermissions = () => {
const resolvedWrite = useMemo(() => {
if (writePermissions === 'full') {
return 'full';
}
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writePermissions, writeSwitches, allColumns]);
return Object.entries(writeSwitches)
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
// Notify the parent of the resolved permissions. onChange is expected to be stable.
useEffect(() => {
onChange({ read: resolvedRead, write: resolvedWrite });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedRead, resolvedWrite]);
const noReadAccess = resolvedRead === '-';
return (
<Panel.Indent>
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
<div>
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
<div>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
<RadioGroup
value={readPermissions}
onValueChange={handleReadModeChange}
@@ -132,8 +155,18 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
{ value: 'custom', label: 'Custom read' },
]}
/>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
</div>
</div>
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
<div className={style.twoCols}>
<div className={style.grid}>
<Panel.Description>Ontime columns</Panel.Description>
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
<Fragment key={column.value}>
<div>{column.label}</div>
<Switch
checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
checked={isReadOn(column.value)}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
<Switch
checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
checked={isWriteOn(column.value)}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
<Panel.Description>Write</Panel.Description>
{customFieldColumns.map((column) => (
<Fragment key={column.value}>
{column.label}
<div>{column.label}</div>
<Switch
checked={Boolean(readSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
checked={isReadOn(column.value)}
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
<Switch
checked={Boolean(writeSwitches[column.value])}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
checked={isWriteOn(column.value)}
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
@@ -28,6 +28,7 @@ export const langDe: TranslationObject = {
'timeline.done': 'Beendet',
'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von',
'timeline.standby': 'Bereitschaft',
'project.title': 'Titel',
'project.description': 'Beschreibung',
'project.info': 'Projektinfo',
@@ -28,6 +28,7 @@ export const langEs: TranslationObject = {
'timeline.done': 'Terminado',
'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por',
'timeline.standby': 'En espera',
'project.title': 'Título',
'project.description': 'Descripción',
'project.info': 'Información del proyecto',
@@ -28,6 +28,7 @@ export const langFr: TranslationObject = {
'timeline.done': 'Terminé',
'timeline.due': 'dû',
'timeline.followedby': 'Suivi de',
'timeline.standby': 'En attente',
'project.title': 'Titre',
'project.description': 'Description',
'project.info': 'Informations du projet',
@@ -28,6 +28,7 @@ export const langIt: TranslationObject = {
'timeline.done': 'Terminato',
'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da',
'timeline.standby': 'In attesa',
'project.title': 'Titolo',
'project.description': 'Descrizione',
'project.info': 'Informazioni sul progetto',
@@ -28,6 +28,7 @@ export const langPt: TranslationObject = {
'timeline.done': 'Concluído',
'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por',
'timeline.standby': 'Em espera',
'project.title': 'Título',
'project.description': 'Descrição',
'project.info': 'Informações do projeto',
@@ -20,6 +20,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
options: {
read: 'full',
write: '-',
@@ -41,6 +42,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-flag',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
options: {
read: 'full',
write: 'flag',
@@ -62,6 +64,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-default',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
};
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
@@ -76,6 +79,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-granular',
target: OntimeView.Cuesheet,
search: '',
displayInNav: false,
options: {
read: 'cue,title',
write: 'title',
@@ -1,6 +1,8 @@
@use '@/theme/viewerDefs' as *;
$timeline-height: 1rem;
$group-band-height: 0.5rem;
$group-band-gap: 0.25rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timelineContainer {
@@ -17,7 +19,21 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
position: relative;
font-weight: 600;
height: 100%;
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
// reserve the top region (progress bar + gap + group band) with the card background
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
var(--card-background-color-override, $viewer-card-bg-color);
}
.groupBand {
height: $group-band-height;
margin-top: $group-band-gap;
width: 100%;
background: var(--group-colour, transparent);
display: flex;
align-items: center;
position: relative;
z-index: 2;
overflow: visible;
}
.column {
@@ -92,7 +108,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
text-transform: capitalize;
white-space: normal;
&[data-status='done'] {
@@ -119,6 +134,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.status {
width: fit-content;
text-transform: uppercase;
}
.status.due {
+1 -4
View File
@@ -55,10 +55,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
if (totalDuration === 0) {
return null;
}
// Pre-calculate event statuses
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
const statusMap: Record<string, ProgressStatus> = {};
@@ -105,6 +101,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
title={displayTitle}
cue={event.cue}
width={position.width}
groupColour={event.groupColour}
/>
);
})}
@@ -26,6 +26,7 @@ interface TimelineEntryProps {
title: string;
width: number;
cue: string;
groupColour?: string;
ref?: RefObject<HTMLDivElement | null>;
}
@@ -48,6 +49,7 @@ export function TimelineEntry({
title,
width,
cue,
groupColour,
ref,
}: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions);
@@ -67,6 +69,7 @@ export function TimelineEntry({
{
'--color': colour,
'--lighter': lighterColour ?? '',
'--group-colour': groupColour ?? 'transparent',
left: `${left}px`,
width: `${width}px`,
} as CSSProperties
@@ -74,6 +77,7 @@ export function TimelineEntry({
data-testid={cue}
>
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div className={style.groupBand} />
<div
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status}
@@ -153,6 +157,8 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
}
const isDue = status === 'future' && timeToStart <= 0;
@@ -38,6 +38,8 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId();
const { mainSource, timeformat } = useTimelineOptions();
const { getLocalizedString } = useTranslation();
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -50,6 +52,8 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
const hasContent = totalDuration > 0;
return (
<div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
@@ -61,12 +65,16 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedEventId}
totalDuration={totalDuration}
/>
{hasContent ? (
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedEventId}
totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div>
);
}
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
const state = useExpectedStartData();
// gather card data
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby');
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
@@ -96,63 +96,77 @@ export function useScopedRundown(
): ScopedRundownData {
const { hidePast } = useTimelineOptions();
const data = useMemo(() => {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [hidePast, rundown, selectedEventId]);
const data = useMemo(
() => computeScopedRundown(rundown, selectedEventId, hidePast),
[hidePast, rundown, selectedEventId],
);
return data;
}
/**
* Pure computation behind useScopedRundown: filters to playable events and derives timers
*/
export function computeScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
hidePast: boolean,
): ScopedRundownData {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart: number | null = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
// we only deal with playableEvents
const currentEntry = rundown[i];
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
continue;
}
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}
type UpcomingEvents = {
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.9.0",
"version": "4.10.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": "@getontime/resolver",
"version": "4.9.0",
"version": "4.10.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.9.0",
"version": "4.10.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -110,14 +110,13 @@ type old_URLPreset = {
/**
* migrates a url presets from v3 to v4
* - pathAndParams split into a target and search
*
*/
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, options: {} } as URLPreset;
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
});
return newURLPreset;
}
@@ -208,6 +208,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer,
search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {},
},
{
@@ -216,6 +217,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer,
search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {},
},
];
@@ -704,11 +704,14 @@ export async function renameRundown(id: string, title: string) {
await dataProvider.setRundown(id, { ...rundown, title });
/**
* If loaded we re-init the rundown
* If we are modifying the loaded rundown we re-init it
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
// init rundown does its own refetch
await initRundown(rundown, customField);
} else {
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
@@ -12,7 +12,9 @@ describe('parseUrlPresets()', () => {
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
const urlPresets = [
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
] as URLPreset[];
const result = parseUrlPresets({ urlPresets }, errorEmitter);
expect(result.length).toEqual(1);
expect(result.at(0)).toMatchObject({
@@ -36,6 +38,7 @@ describe('parseUrlPresets()', () => {
alias: 'testalias',
target: 'timer',
search: 'testpathAndParams',
displayInNav: false,
},
],
} as unknown as DatabaseModel;
@@ -26,6 +26,7 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
alias: preset.alias,
target: preset.target,
search: preset.search ?? '',
displayInNav: preset.displayInNav ?? false,
options: preset?.options,
};
newPresets.push(newPreset);
@@ -21,6 +21,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
alias: req.body.alias,
target: req.body.target,
search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options,
};
@@ -55,6 +56,7 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
alias: req.body.alias,
target: req.body.target,
search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options ?? existingPreset.options,
};
@@ -12,6 +12,7 @@ export const validateNewPreset = [
body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets
body('options').optional().isObject(),
@@ -27,6 +28,7 @@ export const validateUpdatePreset = [
body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets
body('options').optional().isObject(),
-65
View File
@@ -1,65 +0,0 @@
# Ontime MCP Server
Ontime exposes an MCP server over Streamable HTTP.
## Endpoint
Default local URL:
```text
http://localhost:4001/mcp
```
If Ontime is running on another host or port, replace `localhost:4001` with that address.
If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example:
```text
http://localhost:4001/stage/mcp
```
The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and
`DELETE` are not used.
## Authentication
If Ontime has no session password configured, no MCP authentication is required.
If a session password is configured, authenticate with the hashed Ontime token.
The settings UI generates an endpoint URL with the token in the query string:
```text
http://localhost:4001/mcp?token=<hashed-token>
```
If your MCP client supports request headers, you can send the same token as a
bearer token instead:
```http
Authorization: Bearer <hashed-token>
```
This is the same token used in authenticated Ontime share URLs as the `token` query
parameter. The raw session password is not accepted as the bearer token.
## Client Configuration
Use a Streamable HTTP MCP client and point it at the MCP endpoint:
```json
{
"mcpServers": {
"ontime": {
"url": "http://localhost:4001/mcp?token=<hashed-token>"
}
}
}
```
Omit the token when Ontime is not password protected. If you prefer headers, use
`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the
same bearer token.
## Quick Check
The server should respond to MCP initialization requests at `/mcp`. A plain browser
`GET` request will return `405 Method not allowed`, which is expected.
+43
View File
@@ -33,6 +33,17 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
},
],
},
{
name: 'manage_custom_fields',
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
arguments: [
{
name: 'instruction',
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
required: true,
},
],
},
];
function userPrompt(description: string, text: string): GetPromptResult {
@@ -79,6 +90,7 @@ Count to end (countToEnd):
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
End action (endAction):
- Controls what happens when the event's timer reaches zero and can have surprising effect. Use only if the user explicitly asks for it.
- \`none\` (default): stops at end; operator must manually start the next event.
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
@@ -203,5 +215,36 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
);
}
if (name === 'manage_custom_fields') {
return userPrompt(
'List, create, rename, recolour, or delete custom field definitions',
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
Steps:
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras".
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
Creating a field:
- Call ontime_create_custom_field with { label, type, colour }.
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating.
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified.
- Returns: { key, customFields } — use the returned key when setting values on entries.
Renaming or recolouring a field:
- Call ontime_update_custom_field with { key, label?, colour? }.
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
- The field type cannot be changed.
Deleting a field:
- Call ontime_delete_custom_field with { key }.
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`,
);
}
throw new Error(`Unknown prompt: ${name}`);
}
+15 -2
View File
@@ -17,9 +17,20 @@ mcpRouter.post('/', async (req, res) => {
sessionIdGenerator: undefined, // stateless: no session tracking
enableJsonResponse: true,
});
res.on('close', () => transport.close());
const server = createMcpServer();
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
transport.close();
await server.close?.();
};
res.on('close', () => void cleanup());
res.on('finish', () => void cleanup());
try {
const server = createMcpServer();
await server.connect(transport);
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
} catch (error) {
@@ -28,6 +39,8 @@ mcpRouter.post('/', async (req, res) => {
if (!res.headersSent) {
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
}
} finally {
await cleanup();
}
});
+14 -5
View File
@@ -177,8 +177,15 @@ Examples:
## Custom fields
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
Only use existing field keys when setting \`custom\` values. Adding, renaming, or deleting custom field definitions is a separate project-level operation.
When the user wants to assign custom values, show the existing custom field list first if there is any ambiguity, so you do not create duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
Managing field definitions:
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`.
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first.
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
## Targeting rundowns
Entry read/write tools accept an optional \`rundownId\`.
@@ -225,8 +232,10 @@ Main site: https://docs.getontime.no
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
- HTTP Integration: https://docs.getontime.no/api/http/
- OSC Integration: https://docs.getontime.no/api/osc/
- Make custom views: https://docs.getontime.no/features/custom-views/
## API reference
- REST API overview: https://docs.getontime.no/api/
- WebSocket events: https://docs.getontime.no/api/websocket/
## Helpful references
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
- Import data from spreadsheets: https://docs.getontime.no/features/import-spreadsheet/
`;
+31 -2
View File
@@ -17,7 +17,10 @@ import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '
import {
addEntry,
batchEditEntries,
createCustomField,
deleteCustomField,
deleteEntries,
editCustomField,
editEntry,
groupEntries,
reorderEntry,
@@ -103,8 +106,16 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
}
if (unknownKeys.size > 0) {
const keys = [...unknownKeys].join(', ');
throw new Error(`Unknown custom field key(s): ${keys}. Call ontime_get_custom_fields to list available keys.`);
const missing = [...unknownKeys].join(', ');
const available = Object.keys(customFields);
const hint =
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
throw new Error(
`Unknown custom field key(s): ${missing}. ${hint} ` +
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
`Call ontime_get_custom_fields to list existing keys.`,
);
}
}
@@ -320,3 +331,21 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
}
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour });
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
return { key, customFields: updated };
}
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
return { customFields: updated };
}
export async function deleteCustomFieldForMcp(args: { key: string }) {
const projectRundowns = getDataProvider().getProjectRundowns();
const updated = await deleteCustomField(args.key, projectRundowns);
return { customFields: updated };
}
+74 -1
View File
@@ -25,7 +25,9 @@ import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
import {
batchCreateEntriesForMcp,
batchUpdateEntriesForMcp,
createCustomFieldForMcp,
createEntryForMcp,
deleteCustomFieldForMcp,
deleteEntriesForMcp,
findEntry,
getRundownById,
@@ -33,6 +35,7 @@ import {
reorderEntryForMcp,
toRundownList,
ungroupEntryForMcp,
updateCustomFieldForMcp,
updateEntryForMcp,
type BatchCreateEntryArgs,
type CreateEntryArgs,
@@ -371,6 +374,64 @@ export const TOOL_DEFINITIONS = [
inputSchema: { type: 'object', properties: {} },
annotations: READ,
},
{
name: 'ontime_create_custom_field',
description:
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.',
inputSchema: {
type: 'object',
required: ['label', 'type', 'colour'],
properties: {
label: {
type: 'string',
description:
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".',
},
type: {
type: 'string',
enum: ['text', 'image'],
description:
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
},
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
},
},
},
annotations: WRITE,
},
{
name: 'ontime_update_custom_field',
description:
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
label: {
type: 'string',
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
},
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
},
},
annotations: WRITE_IDEM,
},
{
name: 'ontime_delete_custom_field',
description:
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
inputSchema: {
type: 'object',
required: ['key'],
properties: {
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
},
},
annotations: WRITE_DESTRUCTIVE,
},
// --- Project file management ---
{
name: 'ontime_list_projects',
@@ -468,7 +529,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
const serialised = text(data);
if (serialised.length > CHARACTER_LIMIT) {
return ok({
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`,
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`,
truncated: true,
rundownId: rundown.id,
order: rundown.order,
@@ -571,6 +632,18 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
ontime_create_custom_field: async (args) => {
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
},
ontime_update_custom_field: async (args) => {
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
},
ontime_delete_custom_field: async (args) => {
return ok(await deleteCustomFieldForMcp(args as { key: string }));
},
ontime_list_projects: async () => ok(await getProjectList()),
ontime_load_project: async (args) => {
@@ -87,8 +87,8 @@ describe('safeMerge', () => {
it('should merge the urlPresets key when present', () => {
const newData = {
urlPresets: [
{ enabled: true, alias: 'alias1', search: '' },
{ enabled: true, alias: 'alias2', search: '' },
{ enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false },
{ enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false },
] as URLPreset[],
};
+2
View File
@@ -43,6 +43,7 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer,
search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
},
{
enabled: true,
@@ -50,6 +51,7 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer,
search:
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
displayInNav: false,
},
],
customFields: {
@@ -0,0 +1,251 @@
import { MessageTag, OffsetMode, TimerLifeCycle } from 'ontime-types';
/**
* Characterisation tests for the broadcast contract of the RuntimeService
*
* These tests lock down WHICH top-level keys of the runtime store are
* emitted to clients for each action and tick. This is the wire contract
* consumed by the client (which shallow-merges top-level keys) and by
* user-facing automation templates.
*
* The tests drive the real production path: fake timers fire the
* EventTimer interval, which runs update() and the broadcastResult
* decorator diffing, down to the (spied) websocket fan-out.
*/
import type { MockInstance } from 'vitest';
import { makeFlatRundown } from '../../../stores/__tests__/harness/scenario.fixtures.js';
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
vi.mock('../../restore-service/restore.service.js', () => {
return {
restoreService: {
save: vi.fn().mockResolvedValue(undefined),
load: vi.fn(),
clear: vi.fn(),
create: vi.fn(),
shutdown: vi.fn(),
},
};
});
vi.mock('../../../api-data/automation/automation.service.js', () => {
return {
triggerAutomations: vi.fn(),
testOutput: vi.fn(),
testConditions: vi.fn(),
};
});
vi.mock('../../../api-data/report/report.service.js', () => {
return {
generate: vi.fn(),
clear: vi.fn(),
triggerReportEntry: vi.fn(),
};
});
type RuntimeServiceModule = typeof import('../runtime.service.js');
type AutomationModule = typeof import('../../../api-data/automation/automation.service.js');
type RestoreModule = typeof import('../../restore-service/restore.service.js');
let runtimeService: RuntimeServiceModule['runtimeService'];
let triggerAutomations: AutomationModule['triggerAutomations'];
let restoreService: RestoreModule['restoreService'];
let sendSpy: MockInstance;
/** RuntimeData patches sent over the websocket since the last spy reset */
function runtimePatches(): Record<string, unknown>[] {
return sendSpy.mock.calls
.filter(([tag]) => tag === MessageTag.RuntimeData)
.map(([, payload]) => payload as Record<string, unknown>);
}
/** the sorted top-level keys of every emitted patch */
function emittedKeys(): string[][] {
return runtimePatches().map((patch) => Object.keys(patch).sort());
}
/** drains the process.nextTick queue where automation triggers are scheduled */
async function flushNextTicks() {
await new Promise((resolve) => process.nextTick(resolve));
}
beforeEach(async () => {
// the runtime service starts its EventTimer interval at import time:
// reset modules and install fake timers BEFORE importing, so the
// interval is fake and controlled by the test
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime('jan 5 09:59');
const websocketModule = await import('../../../adapters/WebsocketAdapter.js');
sendSpy = vi.spyOn(websocketModule.socket, 'sendAsJson');
const automationModule = await import('../../../api-data/automation/automation.service.js');
triggerAutomations = automationModule.triggerAutomations;
const restoreModule = await import('../../restore-service/restore.service.js');
restoreService = restoreModule.restoreService;
const serviceModule = await import('../runtime.service.js');
runtimeService = serviceModule.runtimeService;
runtimeService.init(null);
const { initRundown } = await import('../../../api-data/rundown/rundown.service.js');
await initRundown(makeFlatRundown(), {});
// flush the scheduled rundown side effects
// (we cannot use runAllTimers here, the EventTimer interval never stops)
vi.runOnlyPendingTimers();
sendSpy.mockClear();
vi.mocked(restoreService.save).mockClear();
});
afterEach(() => {
runtimeService.shutdown();
vi.useRealTimers();
vi.clearAllMocks();
});
/**
* !!! characterised bug: the decorator combines the entry diffing with
* `entryChanged ||= updateMaybeEntryIfChanged(key)`. The logical-or
* assignment short-circuits: once one entry has changed, the remaining
* entries are neither diffed nor emitted in that broadcast. Changed
* entries therefore drip out one per broadcast cycle
* (load emits eventNow; eventNext rides along on the next broadcast,
* eventFlag on the one after)
*/
describe('characterisation: broadcast contract', () => {
test('loading an event emits eventNow, timer, clock and rundown', () => {
runtimeService.loadById('flat1');
// eventNext and eventFlag also changed, but are held back by the short-circuit
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'rundown', 'timer']]);
// an immediate change also saves a restore point
expect(restoreService.save).toHaveBeenCalledTimes(1);
});
test('starting emits timer, clock, offset and rundown', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
sendSpy.mockClear();
runtimeService.start();
// offset is included because the expected times are computed on start
// eventNext is the pending entry dripping out from the load
expect(emittedKeys()).toStrictEqual([['clock', 'eventNext', 'offset', 'rundown', 'timer']]);
});
test('ticks within the same second only flush the pending entry', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
sendSpy.mockClear();
// the EventTimer interval fires every 32ms: run 5 production ticks
vi.advanceTimersByTime(5 * 32);
// nothing tick-related is emitted within the same second: the only
// patch is the eventFlag entry still dripping out from the load
expect(emittedKeys()).toStrictEqual([['eventFlag']]);
});
test('a tick crossing the second boundary emits timer and clock together', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
sendSpy.mockClear();
// 32 interval ticks: the seconds boundary is crossed at the 1024ms tick
vi.advanceTimersByTime(1024);
// first tick flushes the dripping eventFlag; the boundary tick emits
// timer and clock; offset is not included since it did not change
expect(emittedKeys()).toStrictEqual([['eventFlag'], ['clock', 'timer']]);
});
test('addTime emits timer, clock and offset immediately', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
sendSpy.mockClear();
vi.mocked(restoreService.save).mockClear();
runtimeService.addTime(60_000);
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'offset', 'timer']]);
expect(restoreService.save).toHaveBeenCalledTimes(1);
});
test('setOffsetMode emits the offset mid-second', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
sendSpy.mockClear();
vi.mocked(restoreService.save).mockClear();
runtimeService.setOffsetMode(OffsetMode.Relative);
expect(emittedKeys()).toStrictEqual([['eventFlag', 'offset']]);
// the mode change counts as an immediate change and saves a restore point
expect(restoreService.save).toHaveBeenCalledTimes(1);
});
test('pause emits timer and clock; stop emits the clearing state', () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
sendSpy.mockClear();
runtimeService.pause();
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'timer']]);
sendSpy.mockClear();
runtimeService.stop();
// eventNow clears to null; the other cleared entries are again held
// back by the entry short-circuit
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'offset', 'rundown', 'timer']]);
});
test('integration automations are throttled to the seconds rollover', async () => {
runtimeService.loadById('flat1');
vi.setSystemTime('jan 5 10:00');
runtimeService.start();
await flushNextTicks();
vi.mocked(triggerAutomations).mockClear();
// the first tick initialises the integration trackers and fires both cycles
vi.advanceTimersByTime(32);
await flushNextTicks();
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
[TimerLifeCycle.onUpdate],
[TimerLifeCycle.onClock],
]);
vi.mocked(triggerAutomations).mockClear();
// ticks within the same second do not trigger integrations
vi.advanceTimersByTime(5 * 32);
await flushNextTicks();
expect(triggerAutomations).not.toHaveBeenCalled();
// crossing into the next second triggers onUpdate and onClock once
vi.advanceTimersByTime(832);
await flushNextTicks();
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
[TimerLifeCycle.onUpdate],
[TimerLifeCycle.onClock],
]);
});
});
@@ -0,0 +1,247 @@
/**
* Characterisation tests for the change-detection predicates that gate
* what gets broadcast to clients on every tick
*/
import { Offset, OffsetMode, PlayableEvent, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { makeOntimeEvent } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import type { RuntimeState } from '../../../stores/runtimeState.js';
import {
collectRuntimeStateChanges,
getShouldClockUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
} from '../runtime.utils.js';
const baseTimer: TimerState = {
addedTime: 0,
current: 10_000,
duration: 60_000,
elapsed: 50_000,
expectedFinish: 100_000,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 1000,
};
function makeTimer(patch: Partial<TimerState>): TimerState {
return { ...baseTimer, ...patch };
}
const baseOffset: Offset = {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
};
describe('isNewSecond()', () => {
describe('count down (default): seconds are rounded up', () => {
test.each([
// [previous, current, expected]
[1500, 1200, false], // both round to 2
[1500, 1000, true], // 2 -> 1
[1000, 999, false], // both round to 1
[999, 1, false], // both round to 1
[1, 0, true], // 1 -> 0
[0, -999, false], // both round to 0
[-1, -1000, true], // 0 -> -1
])('%s -> %s is a new second: %s', (previous, current, expected) => {
expect(isNewSecond(previous, current)).toBe(expected);
});
test('null and undefined values are coerced to second 0', () => {
expect(isNewSecond(undefined, 0)).toBe(false);
expect(isNewSecond(null, 0)).toBe(false);
expect(isNewSecond(undefined, 1000)).toBe(true);
expect(isNewSecond(0, null)).toBe(false);
});
});
describe('count up: seconds are rounded down', () => {
test.each([
[999, 1, false], // both round to 0
[999, 1000, true], // 0 -> 1
[1000, 1999, false], // both round to 1
])('%s -> %s is a new second: %s', (previous, current, expected) => {
expect(isNewSecond(previous, current, TimerType.CountUp)).toBe(expected);
});
});
});
describe('getShouldClockUpdate()', () => {
test('updates when the clock rolls into a new second', () => {
expect(getShouldClockUpdate(500, 999)).toBe(false);
expect(getShouldClockUpdate(999, 1000)).toBe(true);
expect(getShouldClockUpdate(1000, 1032)).toBe(false);
});
test('updates when the clock wraps around midnight', () => {
expect(getShouldClockUpdate(86_399_999, 0)).toBe(true);
});
});
describe('getShouldTimerUpdate()', () => {
test('always updates when there is no previous state', () => {
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
});
test('does not update when nothing changed', () => {
expect(getShouldTimerUpdate(baseTimer, makeTimer({}))).toBe(false);
});
test('current triggers only on a new second', () => {
// 10_000 and 9_001 both round up to second 10
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9001 }))).toBe(false);
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9000 }))).toBe(true);
});
test('secondaryTimer triggers only on a new second', () => {
const previous = makeTimer({ secondaryTimer: 5000 });
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4001 }))).toBe(false);
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4000 }))).toBe(true);
});
test.each([
['addedTime', { addedTime: 1000 }],
['duration', { duration: 61_000 }],
['phase', { phase: TimerPhase.Warning }],
['playback', { playback: Playback.Pause }],
['startedAt', { startedAt: 2000 }],
] as const)('%s triggers on any change', (_field, patch) => {
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(true);
});
test.each([
['elapsed', { elapsed: 50_500 }],
['expectedFinish', { expectedFinish: 100_500 }],
] as const)('%s is deliberately not checked', (_field, patch) => {
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(false);
});
});
describe('getShouldOffsetUpdate()', () => {
test('always updates when there is no previous state', () => {
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
});
test('mode change triggers regardless of dependencies', () => {
const current = { ...baseOffset, mode: OffsetMode.Relative };
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(true);
});
test('value changes are gated by the dependency update', () => {
const current = { ...baseOffset, absolute: 1000 };
// a changed offset without a timer/clock update is not emitted
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(false);
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
});
test('does not update when deep-equal, even with a dependency update', () => {
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
});
test('expected times participate in the deep comparison', () => {
const current = { ...baseOffset, expectedRundownEnd: 1000 };
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
});
});
describe('collectRuntimeStateChanges()', () => {
test('the very first run emits everything and counts as an immediate change', () => {
const previousState = {} as RuntimeState;
const state = makeRuntimeStateData();
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'offset', 'rundown', 'timer']);
expect(hasImmediateChanges).toBe(true);
// the emitted keys are persisted into the previous state for the next diff
expect(previousState.timer).toStrictEqual(state.timer);
expect(previousState.offset).toStrictEqual(state.offset);
});
test('an unchanged state emits nothing', () => {
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData();
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
expect(patch).toStrictEqual({});
expect(hasImmediateChanges).toBe(false);
});
test('a playback change emits timer and clock together', () => {
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData({ timer: { playback: Playback.Armed } });
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
expect(hasImmediateChanges).toBe(true);
});
test('an offset change is gated on a timer/clock dependency', () => {
// nothing else changed: the offset is held back
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData({ offset: { absolute: 1000 } });
const gated = collectRuntimeStateChanges(previousState, state);
expect(gated.patch).toStrictEqual({});
// with a clock rollover, the offset rides along
const stateWithClock = makeRuntimeStateData({ clock: 1000, offset: { absolute: 1000 } });
const emitted = collectRuntimeStateChanges(previousState, stateWithClock);
expect(Object.keys(emitted.patch).sort()).toStrictEqual(['clock', 'offset']);
});
test('!!! characterised bug: changed entries drip out one per call', () => {
const previousState = makeRuntimeStateData();
const eventNow = makeOntimeEvent({ id: 'now' }) as PlayableEvent;
const eventNext = makeOntimeEvent({ id: 'next' }) as PlayableEvent;
const state = makeRuntimeStateData({ eventNow, eventNext });
// both entries changed, but the ||= short-circuit only diffs the first
const first = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(first.patch).sort()).toStrictEqual(['eventNow']);
// the second call flushes the next pending entry
const second = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(second.patch).sort()).toStrictEqual(['eventNext']);
// from here on, nothing is pending
const third = collectRuntimeStateChanges(previousState, state);
expect(third.patch).toStrictEqual({});
});
test('rundown data changes are emitted on deep difference', () => {
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData({ rundown: { actualStart: 1000 } });
const { patch } = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(patch).sort()).toStrictEqual(['rundown']);
});
test('addedTime changes count as immediate and emit the timer', () => {
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData({ timer: { addedTime: 60_000 } });
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
expect(hasImmediateChanges).toBe(true);
});
test('an offset mode change is immediate and bypasses the dependency gate', () => {
const previousState = makeRuntimeStateData();
const state = makeRuntimeStateData({ offset: { mode: OffsetMode.Relative } });
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
expect(Object.keys(patch).sort()).toStrictEqual(['offset']);
expect(hasImmediateChanges).toBe(true);
});
});
@@ -1,4 +1,3 @@
import { deepEqual } from 'fast-equals';
import {
EndAction,
EntryId,
@@ -30,13 +29,12 @@ import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js';
import { skippedOutOfEvent } from '../timerUtils.js';
import {
collectRuntimeStateChanges,
findNextPlayableId,
findNextPlayableWithCue,
findPreviousPlayableId,
getEventAtIndex,
getShouldClockUpdate,
getShouldOffsetUpdate,
getShouldTimerUpdate,
isNewSecond,
} from './runtime.utils.js';
@@ -651,8 +649,6 @@ const eventTimer = new EventTimer({
});
export const runtimeService = new RuntimeService(eventTimer);
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
/**
* Decorator manages side effects from updating the runtime
* This should only be applied to functions that are exposed for consumption
@@ -665,90 +661,13 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// call the original method and get the state
const result = originalMethod.apply(this, args);
const state = runtimeState.getState();
// diff against the previously broadcast state to find what to send
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(RuntimeService.previousState, state);
const batch = eventStore.createBatch();
// we do the comparison by explicitly for each property
// to apply custom logic for different datasets
// Update the entry if they have changed
let entryChanged = false;
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
// for the very fist run there will be nothing in the previousState so we force an update
const justStarted = !RuntimeService.previousState?.timer;
// offset mode has been changed
const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
// if playback changes most things should update
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
const addedTimeChanged = !justStarted && RuntimeService.previousState?.timer.addedTime !== state.timer.addedTime;
// combine all big changes
const hasImmediateChanges =
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
/**
* if any values have changed.
* values that have the possibility to tick are updated when the seconds roll over
*/
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
if (updateTimer) {
batch.add('timer', state.timer);
RuntimeService.previousState.timer = { ...state.timer };
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
*/
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
if (updateClock) {
batch.add('clock', state.clock);
RuntimeService.previousState.clock = state.clock;
}
/**
* if any values have changed.
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
*/
const updateRuntime = getShouldOffsetUpdate(
RuntimeService.previousState?.offset,
state.offset,
updateClock || hasImmediateChanges,
);
if (updateRuntime) {
batch.add('offset', state.offset);
RuntimeService.previousState.offset = structuredClone(state.offset);
}
/**
* if any values have changed.
*/
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
if (updateRundownData) {
batch.add('rundown', state.rundown);
RuntimeService.previousState.rundown = structuredClone(state.rundown);
}
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
const previousEntry = RuntimeService.previousState[key];
const currentEntry = state[key];
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
// if they have the same id the check if the contents have changed
if (previousEntry?.id === currentEntry?.id) {
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
}
// at this point we know that either the id or the contents has changed
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
RuntimeService.previousState[key] = structuredClone(currentEntry);
return true;
for (const key of Object.keys(patch) as (keyof RuntimeStore)[]) {
batch.add(key, patch[key] as RuntimeStore[typeof key]);
}
// save the restore state
@@ -5,6 +5,7 @@ import {
Offset,
OntimeEvent,
Rundown,
RuntimeStore,
TimerState,
TimerType,
isOntimeEvent,
@@ -12,6 +13,8 @@ import {
} from 'ontime-types';
import { millisToSeconds } from 'ontime-utils';
import type { RuntimeState } from '../../stores/runtimeState.js';
export function isNewSecond(
previousValue: MaybeNumber | undefined,
currentValue: MaybeNumber | undefined,
@@ -68,6 +71,104 @@ export function getShouldOffsetUpdate(
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
}
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
/**
* Diffs a runtime state snapshot against the previously broadcast state
* and collects the store keys that should be sent to clients
*
* !!! mutates previousState in place: emitted keys are persisted so the
* next diff compares against what clients last received
*/
export function collectRuntimeStateChanges(
previousState: RuntimeState,
state: Readonly<RuntimeState>,
): { patch: Partial<RuntimeStore>; hasImmediateChanges: boolean } {
const patch: Partial<RuntimeStore> = {};
// we do the comparison by explicitly for each property
// to apply custom logic for different datasets
// Update the entry if they have changed
let entryChanged = false;
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
// for the very fist run there will be nothing in the previousState so we force an update
const justStarted = !previousState?.timer;
// offset mode has been changed
const offsetModeChanged = previousState?.offset?.mode !== state.offset.mode;
// if playback changes most things should update
const hasChangedPlayback = previousState.timer?.playback !== state.timer.playback;
const addedTimeChanged = !justStarted && previousState?.timer.addedTime !== state.timer.addedTime;
// combine all big changes
const hasImmediateChanges =
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
/**
* if any values have changed.
* values that have the possibility to tick are updated when the seconds roll over
*/
const updateTimer = getShouldTimerUpdate(previousState?.timer, state.timer);
if (updateTimer) {
patch.timer = state.timer;
previousState.timer = { ...state.timer };
}
/**
* clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync
*/
const updateClock = updateTimer || getShouldClockUpdate(previousState.clock, state.clock);
if (updateClock) {
patch.clock = state.clock;
previousState.clock = state.clock;
}
/**
* if any values have changed.
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
*/
const updateRuntime = getShouldOffsetUpdate(previousState?.offset, state.offset, updateClock || hasImmediateChanges);
if (updateRuntime) {
patch.offset = state.offset;
previousState.offset = structuredClone(state.offset);
}
/**
* if any values have changed.
*/
const updateRundownData = !deepEqual(previousState.rundown, state.rundown);
if (updateRundownData) {
patch.rundown = state.rundown;
previousState.rundown = structuredClone(state.rundown);
}
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
const previousEntry = previousState[key];
const currentEntry = state[key];
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
// if they have the same id the check if the contents have changed
if (previousEntry?.id === currentEntry?.id) {
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
}
// at this point we know that either the id or the contents has changed
patch[key] = currentEntry as RuntimeStore[K]; // we know that there is the necessary overlap in the types to cast this
previousState[key] = structuredClone(currentEntry);
return true;
}
return { patch, hasImmediateChanges };
}
/**
* finds the previous playable event, if it exists
*/
@@ -0,0 +1,313 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`characterisation: playback across midnight > playing across midnight increments currentDay and keeps offsets 1`] = `
{
"_end": {
"accumulatedGap": 0,
"event": {
"dayOffset": 1,
"delay": 0,
"duration": 1800000,
"gap": 0,
"id": "night3",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 3600000,
"timeStart": 1800000,
"type": "event",
},
"isLinkedToLoaded": undefined,
},
"_flag": null,
"_group": null,
"_rundown": {
"totalDelay": 0,
},
"_startDayOffset": 0,
"_startEpoch": 978734100000,
"_timer": {
"forceFinish": null,
"hasFinished": false,
"pausedAt": null,
"secondaryTarget": null,
},
"clock": 600000,
"eventFlag": null,
"eventNext": {
"dayOffset": 1,
"delay": 0,
"duration": 1800000,
"gap": 0,
"id": "night3",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 3600000,
"timeStart": 1800000,
"type": "event",
},
"eventNow": {
"dayOffset": 0,
"delay": 0,
"duration": 3600000,
"gap": 0,
"id": "night2",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 1800000,
"timeStart": 84600000,
"type": "event",
},
"groupNow": null,
"offset": {
"absolute": 300000,
"expectedFlagStart": null,
"expectedGroupEnd": null,
"expectedRundownEnd": 3900000,
"mode": "absolute",
"relative": -5400000,
},
"rundown": {
"actualGroupStart": null,
"actualStart": 84900000,
"currentDay": 1,
"numEvents": 3,
"plannedEnd": 90000000,
"plannedStart": 79200000,
"selectedEventIndex": 1,
},
"timer": {
"addedTime": 0,
"current": 1500000,
"duration": 3600000,
"elapsed": 2100000,
"expectedFinish": 2100000,
"phase": "default",
"playback": "play",
"secondaryTimer": null,
"startedAt": 84900000,
},
}
`;
exports[`characterisation: roll mode > roll into overnight event after midnight backdates start metadata 1`] = `
{
"_end": {
"accumulatedGap": 0,
"event": {
"dayOffset": 1,
"delay": 0,
"duration": 1800000,
"gap": 0,
"id": "night3",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 3600000,
"timeStart": 1800000,
"type": "event",
},
"isLinkedToLoaded": undefined,
},
"_flag": null,
"_group": null,
"_rundown": {
"totalDelay": 0,
},
"_startDayOffset": 0,
"_startEpoch": 978733800000,
"_timer": {
"forceFinish": null,
"hasFinished": false,
"pausedAt": null,
"secondaryTarget": null,
},
"clock": 300032,
"eventFlag": null,
"eventNext": {
"dayOffset": 1,
"delay": 0,
"duration": 1800000,
"gap": 0,
"id": "night3",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 3600000,
"timeStart": 1800000,
"type": "event",
},
"eventNow": {
"dayOffset": 0,
"delay": 0,
"duration": 3600000,
"gap": 0,
"id": "night2",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 1800000,
"timeStart": 84600000,
"type": "event",
},
"groupNow": null,
"offset": {
"absolute": 0,
"expectedFlagStart": null,
"expectedGroupEnd": null,
"expectedRundownEnd": 3600000,
"mode": "absolute",
"relative": -5400000,
},
"rundown": {
"actualGroupStart": null,
"actualStart": 84600000,
"currentDay": 1,
"numEvents": 3,
"plannedEnd": 90000000,
"plannedStart": 79200000,
"selectedEventIndex": 1,
},
"timer": {
"addedTime": 0,
"current": 1499968,
"duration": 3600000,
"elapsed": 2100032,
"expectedFinish": 1800000,
"phase": "default",
"playback": "roll",
"secondaryTimer": null,
"startedAt": 84600000,
},
}
`;
exports[`characterisation: timed playback > late start: absolute and relative offsets with gap compensation 1`] = `
{
"_end": {
"accumulatedGap": 1200000,
"event": {
"dayOffset": 0,
"delay": 0,
"duration": 600000,
"gap": 600000,
"id": "flat4",
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 39600000,
"timeStart": 39000000,
"type": "event",
},
"isLinkedToLoaded": undefined,
},
"_flag": {
"accumulatedGap": 0,
"event": {
"dayOffset": 0,
"delay": 0,
"duration": 600000,
"flag": true,
"gap": 0,
"id": "flat2",
"linkStart": true,
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 37200000,
"timeStart": 36600000,
"type": "event",
},
"isLinkedToLoaded": true,
},
"_group": null,
"_rundown": {
"totalDelay": 0,
},
"_startDayOffset": 0,
"_startEpoch": 978685500000,
"_timer": {
"forceFinish": null,
"hasFinished": false,
"pausedAt": null,
"secondaryTarget": null,
},
"clock": 36300000,
"eventFlag": {
"dayOffset": 0,
"delay": 0,
"duration": 600000,
"flag": true,
"gap": 0,
"id": "flat2",
"linkStart": true,
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 37200000,
"timeStart": 36600000,
"type": "event",
},
"eventNext": {
"dayOffset": 0,
"delay": 0,
"duration": 600000,
"flag": true,
"gap": 0,
"id": "flat2",
"linkStart": true,
"parent": null,
"revision": 1,
"skip": false,
"timeEnd": 37200000,
"timeStart": 36600000,
"type": "event",
},
"eventNow": {
"dayOffset": 0,
"delay": 0,
"duration": 600000,
"gap": 0,
"id": "flat1",
"parent": null,
"revision": 1,
"skip": false,
"timeDanger": 60000,
"timeEnd": 36600000,
"timeStart": 36000000,
"timeWarning": 120000,
"type": "event",
},
"groupNow": null,
"offset": {
"absolute": 300000,
"expectedFlagStart": 36900000,
"expectedGroupEnd": null,
"expectedRundownEnd": 39900000,
"mode": "relative",
"relative": 0,
},
"rundown": {
"actualGroupStart": null,
"actualStart": 36300000,
"currentDay": 0,
"numEvents": 4,
"plannedEnd": 39600000,
"plannedStart": 36000000,
"selectedEventIndex": 0,
},
"timer": {
"addedTime": 0,
"current": 600000,
"duration": 600000,
"elapsed": 0,
"expectedFinish": 36900000,
"phase": "default",
"playback": "play",
"secondaryTimer": null,
"startedAt": 36300000,
},
}
`;
@@ -0,0 +1,191 @@
import { Rundown } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import {
makeOntimeDelay,
makeOntimeEvent,
makeOntimeGroup,
makeRundown,
} from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
const h = MILLIS_PER_HOUR;
const m = MILLIS_PER_MINUTE;
export const time = { h, m };
/**
* Flat rundown exercising gaps, linked events and flags
* - flat1: 10:00 - 10:10
* - flat2: 10:10 - 10:20 (linked to flat1, flagged)
* - flat3: 10:30 - 10:40 (10min gap, flagged)
* - flat4: 10:50 - 11:00 (10min gap)
*
* total gap from flat1: 20min, planned 10:00 - 11:00
*/
export function makeFlatRundown(): Rundown {
return makeRundown({
entries: {
flat1: makeOntimeEvent({
id: 'flat1',
timeStart: 10 * h,
timeEnd: 10 * h + 10 * m,
duration: 10 * m,
timeWarning: 2 * m,
timeDanger: 1 * m,
parent: null,
}),
flat2: makeOntimeEvent({
id: 'flat2',
timeStart: 10 * h + 10 * m,
timeEnd: 10 * h + 20 * m,
duration: 10 * m,
linkStart: true,
flag: true,
parent: null,
}),
flat3: makeOntimeEvent({
id: 'flat3',
timeStart: 10 * h + 30 * m,
timeEnd: 10 * h + 40 * m,
duration: 10 * m,
flag: true,
parent: null,
}),
flat4: makeOntimeEvent({
id: 'flat4',
timeStart: 10 * h + 50 * m,
timeEnd: 11 * h,
duration: 10 * m,
parent: null,
}),
},
order: ['flat1', 'flat2', 'flat3', 'flat4'],
});
}
/**
* Rundown with a group and a trailing event
* - group: [grouped1: 10:00 - 10:30, grouped2: 10:30 - 11:00]
* - after: 11:30 - 12:00 (30min gap)
*/
export function makeGroupedRundown(): Rundown {
return makeRundown({
entries: {
group: makeOntimeGroup({ id: 'group', entries: ['grouped1', 'grouped2'] }),
grouped1: makeOntimeEvent({
id: 'grouped1',
timeStart: 10 * h,
timeEnd: 10 * h + 30 * m,
duration: 30 * m,
parent: 'group',
}),
grouped2: makeOntimeEvent({
id: 'grouped2',
timeStart: 10 * h + 30 * m,
timeEnd: 11 * h,
duration: 30 * m,
parent: 'group',
}),
after: makeOntimeEvent({
id: 'after',
timeStart: 11 * h + 30 * m,
timeEnd: 12 * h,
duration: 30 * m,
parent: null,
}),
},
order: ['group', 'after'],
});
}
/**
* Overnight rundown
* - night1: 22:00 - 23:30
* - night2: 23:30 - 00:30 (crosses midnight)
* - night3: 00:30 - 01:00 (next day)
*/
export function makeOvernightRundown(): Rundown {
return makeRundown({
entries: {
night1: makeOntimeEvent({
id: 'night1',
timeStart: 22 * h,
timeEnd: 23 * h + 30 * m,
duration: 90 * m,
parent: null,
}),
night2: makeOntimeEvent({
id: 'night2',
timeStart: 23 * h + 30 * m,
timeEnd: 30 * m,
duration: 60 * m,
parent: null,
}),
night3: makeOntimeEvent({
id: 'night3',
timeStart: 30 * m,
timeEnd: 1 * h,
duration: 30 * m,
parent: null,
}),
},
order: ['night1', 'night2', 'night3'],
});
}
/**
* Rundown with a count-to-end event
* - lead: 10:00 - 10:30
* - toEnd: 10:30 - 11:00 (countToEnd)
*/
export function makeCountToEndRundown(): Rundown {
return makeRundown({
entries: {
lead: makeOntimeEvent({
id: 'lead',
timeStart: 10 * h,
timeEnd: 10 * h + 30 * m,
duration: 30 * m,
parent: null,
}),
toEnd: makeOntimeEvent({
id: 'toEnd',
timeStart: 10 * h + 30 * m,
timeEnd: 11 * h,
duration: 30 * m,
countToEnd: true,
parent: null,
}),
},
order: ['lead', 'toEnd'],
});
}
/**
* Rundown with a delay entry
* - delayed1: 10:00 - 10:10
* - (delay 5min)
* - delayed2: 10:20 - 10:30 (10min gap)
*/
export function makeDelayedRundown(): Rundown {
return makeRundown({
entries: {
delayed1: makeOntimeEvent({
id: 'delayed1',
timeStart: 10 * h,
timeEnd: 10 * h + 10 * m,
duration: 10 * m,
parent: null,
}),
delay: makeOntimeDelay({ id: 'delay', duration: 5 * m, parent: null }),
delayed2: makeOntimeEvent({
id: 'delayed2',
timeStart: 10 * h + 20 * m,
timeEnd: 10 * h + 30 * m,
duration: 10 * m,
parent: null,
}),
},
order: ['delayed1', 'delay', 'delayed2'],
});
}
@@ -0,0 +1,174 @@
import { EntryId, MaybeString, OffsetMode, PlayableEvent, Rundown } from 'ontime-types';
import { vi } from 'vitest';
import { rundownCache } from '../../../api-data/rundown/rundown.dao.js';
import { initRundown } from '../../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../../api-data/rundown/rundown.types.js';
import { timerConfig } from '../../../setup/config.js';
import {
type RuntimeState,
type UpdateResult,
addTime,
clearState,
getState,
load,
pause,
roll,
setOffsetMode,
start,
stop,
update,
updateAll,
updateLoaded,
updateRundownData,
} from '../../runtimeState.js';
/**
* A compact, review-friendly projection of the runtime state
* This is the primary characterisation contract: it contains every field
* whose semantics are consumed by clients (offsets, expected times, playback)
* plus the private fields that drive those calculations
*/
export function digest() {
const state = getState();
return {
clock: state.clock,
playback: state.timer.playback,
phase: state.timer.phase,
current: state.timer.current,
duration: state.timer.duration,
elapsed: state.timer.elapsed,
addedTime: state.timer.addedTime,
startedAt: state.timer.startedAt,
secondaryTimer: state.timer.secondaryTimer,
expectedFinish: state.timer.expectedFinish,
absolute: state.offset.absolute,
relative: state.offset.relative,
mode: state.offset.mode,
expectedGroupEnd: state.offset.expectedGroupEnd,
expectedFlagStart: state.offset.expectedFlagStart,
expectedRundownEnd: state.offset.expectedRundownEnd,
plannedStart: state.rundown.plannedStart,
plannedEnd: state.rundown.plannedEnd,
actualStart: state.rundown.actualStart,
actualGroupStart: state.rundown.actualGroupStart,
currentDay: state.rundown.currentDay,
selectedEventIndex: state.rundown.selectedEventIndex,
eventNow: state.eventNow?.id ?? null,
eventNext: state.eventNext?.id ?? null,
eventFlag: state.eventFlag?.id ?? null,
groupNow: state.groupNow?.id ?? null,
pausedAt: state._timer.pausedAt,
secondaryTarget: state._timer.secondaryTarget,
hasFinished: state._timer.hasFinished,
startDayOffset: state._startDayOffset,
};
}
export type Scenario = {
/** the processed rundown, after going through the rundown cache */
rundown: Rundown;
metadata: RundownMetadata;
load: (eventId: EntryId) => boolean;
start: () => boolean;
pause: () => boolean;
stop: () => boolean;
addTime: (amount: number) => boolean;
/** calls roll, optionally passing the current offset for roll-continuation */
roll: (keepOffset?: boolean) => { eventId: MaybeString; didStart: boolean };
setOffsetMode: (mode: OffsetMode) => void;
updateRundownData: (data: Parameters<typeof updateRundownData>[0]) => void;
/**
* emulates a rundown edit while playback continues: re-processes the
* rundown through the cache and hot-reloads the loaded events, as the
* rundown service does on a committed mutation
*/
hotReload: (newRundown: Rundown) => void;
/** re-arms the loaded event, resetting timer progress, as used by reload() */
reloadLoaded: () => string | undefined;
/**
* advances fake time and calls update() in chunks, exactly as EventTimer would
* @param ms total time to advance
* @param stepMs chunk size, defaults to the production update rate (32ms)
* @returns the result of every update call, so tests can assert on finish flags
*/
tick: (ms?: number, stepMs?: number) => UpdateResult[];
/** jumps the wall clock to an absolute time and runs a single update */
setTime: (time: string) => UpdateResult;
digest: () => ReturnType<typeof digest>;
state: () => Readonly<RuntimeState>;
};
/**
* Creates a test scenario around the real runtimeState singleton
* - initialises the rundown through the production rundown service
* - resets playback state and offset mode between scenarios
*
* Requires vi.useFakeTimers() to be active
*/
export async function createScenario(initialRundown: Rundown, startTime: string): Promise<Scenario> {
vi.setSystemTime(startTime);
clearState();
// clearState intentionally preserves offset mode, reset it for test isolation
setOffsetMode(OffsetMode.Absolute);
await initRundown(initialRundown, {});
// flush the debounced rundown processing
vi.runAllTimers();
const { rundown, metadata } = rundownCache.get();
const getPlayableEvent = (eventId: EntryId): PlayableEvent => {
const event = rundown.entries[eventId];
if (event === undefined) {
throw new Error(`Test scenario: event ${eventId} not found in rundown`);
}
return event as PlayableEvent;
};
return {
rundown,
metadata,
load: (eventId) => load(getPlayableEvent(eventId), rundown, metadata),
start,
pause,
stop,
addTime,
roll: (keepOffset) => roll(rundown, metadata, keepOffset ? getState().offset : undefined),
setOffsetMode,
updateRundownData,
hotReload: (newRundown) => {
rundownCache.init(newRundown, {});
const { rundown: processedRundown, metadata: processedMetadata } = rundownCache.get();
updateAll(processedRundown, processedMetadata);
},
reloadLoaded: () => updateLoaded(),
tick: (ms = timerConfig.updateRate, stepMs = timerConfig.updateRate) => {
const results: UpdateResult[] = [];
let remaining = ms;
while (remaining > 0) {
const step = Math.min(stepMs, remaining);
vi.advanceTimersByTime(step);
results.push(update());
remaining -= step;
}
return results;
},
setTime: (time) => {
vi.setSystemTime(time);
return update();
},
digest,
state: getState,
};
}
/**
* Utility to assert on the update results collected during a tick
*/
export function countFinishes(results: UpdateResult[]) {
return {
timerFinished: results.filter((result) => result.hasTimerFinished).length,
secondaryFinished: results.filter((result) => result.hasSecondaryTimerFinished).length,
};
}
@@ -0,0 +1,688 @@
/**
* Characterisation tests for the runtimeState machinery
*
* These tests lock down the CURRENT behaviour of the runtime state hot path:
* playback transitions, tick updates, absolute and relative offsets,
* expected times (rundown end, group end, flag start) and roll mode.
*
* They drive the real runtimeState singleton through scripted scenarios
* with fake timers, exactly as the EventTimer would in production.
*
* !!! These tests are a behavioural baseline for refactors: if one of these
* assertions fails, production behaviour has changed !!!
*/
import { OffsetMode, Playback, TimerPhase } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import {
makeCountToEndRundown,
makeDelayedRundown,
makeFlatRundown,
makeGroupedRundown,
makeOvernightRundown,
time,
} from './harness/scenario.fixtures.js';
import { countFinishes, createScenario } from './harness/scenario.utils.js';
const { h, m } = time;
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
describe('characterisation: loading and rundown data', () => {
test('initialising a rundown populates planned times', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:00');
expect(scenario.digest()).toMatchObject({
playback: Playback.Stop,
plannedStart: 10 * h,
plannedEnd: 11 * h,
absolute: 0,
relative: 0,
expectedRundownEnd: null,
expectedFlagStart: null,
expectedGroupEnd: null,
});
});
test('loading an event arms the timer and resolves now/next/flag', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
scenario.load('flat1');
expect(scenario.digest()).toMatchObject({
playback: Playback.Armed,
eventNow: 'flat1',
eventNext: 'flat2',
// the loaded event cannot be the flag, so the first flag after flat1 is flat2
eventFlag: 'flat2',
groupNow: null,
current: 10 * m,
duration: 10 * m,
selectedEventIndex: 0,
actualStart: null,
currentDay: null,
});
});
test('setOffsetMode while stopped only changes the mode', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
scenario.setOffsetMode(OffsetMode.Relative);
expect(scenario.digest()).toMatchObject({
mode: OffsetMode.Relative,
expectedRundownEnd: null,
expectedFlagStart: null,
});
});
});
describe('characterisation: timed playback', () => {
test('on-time start ticks through phases and finishes exactly once', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
scenario.load('flat1');
scenario.setTime('jan 5 10:00');
expect(scenario.start()).toBe(true);
expect(scenario.digest()).toMatchObject({
playback: Playback.Play,
phase: TimerPhase.Default,
startedAt: 10 * h,
actualStart: 10 * h,
currentDay: 0,
startDayOffset: 0,
current: 10 * m,
expectedFinish: 10 * h + 10 * m,
absolute: 0,
relative: 0,
// flag (flat2) is linked to the loaded event: it follows the offset directly
expectedFlagStart: 10 * h + 10 * m,
// rundown ends on schedule
expectedRundownEnd: 11 * h,
expectedGroupEnd: null,
});
// tick to the warning threshold (2min)
let results = scenario.tick(8 * m, 1000);
expect(countFinishes(results).timerFinished).toBe(0);
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Warning, current: 2 * m });
// tick to the danger threshold (1min)
results = scenario.tick(1 * m, 1000);
expect(countFinishes(results).timerFinished).toBe(0);
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Danger, current: 1 * m });
// tick to the end: the finish flag is raised exactly once, when current <= triggerAhead
results = scenario.tick(1 * m, 1000);
expect(countFinishes(results).timerFinished).toBe(1);
expect(scenario.digest()).toMatchObject({ current: 0, hasFinished: true });
// continuing into overtime does not re-trigger the finish
results = scenario.tick(30 * 1000, 1000);
expect(countFinishes(results).timerFinished).toBe(0);
expect(scenario.digest()).toMatchObject({
phase: TimerPhase.Overtime,
current: -30 * 1000,
// overtime pushes the rundown behind schedule
absolute: 30 * 1000,
relative: 30 * 1000,
});
});
test('late start: absolute and relative offsets with gap compensation', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
scenario.load('flat1');
scenario.setTime('jan 5 10:05');
scenario.start();
expect(scenario.digest()).toMatchObject({
startedAt: 10 * h + 5 * m,
actualStart: 10 * h + 5 * m,
// positive offset: we are 5min behind schedule
absolute: 5 * m,
// relative to our actual start, we are on time
relative: 0,
// flat2 is linked to the loaded event, so it follows the offset: 10:10 + 5min
expectedFlagStart: 10 * h + 15 * m,
// the accumulated gap (20min) is larger than the offset (5min): rundown still ends on schedule
expectedRundownEnd: 11 * h,
});
// switching to relative mode re-anchors the schedule to the actual start
scenario.setOffsetMode(OffsetMode.Relative);
expect(scenario.digest()).toMatchObject({
mode: OffsetMode.Relative,
absolute: 5 * m,
relative: 0,
// linked flag: same wall time as in absolute mode
expectedFlagStart: 10 * h + 15 * m,
// in relative mode the whole schedule shifts by the late start: gaps are kept, not consumed
expectedRundownEnd: 11 * h + 5 * m,
});
// full state checkpoint
expect(scenario.state()).toMatchSnapshot();
});
test('early start in relative mode', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:50');
scenario.load('flat1');
scenario.setTime('jan 5 09:56');
scenario.start();
expect(scenario.digest()).toMatchObject({
// negative offset: we are 4min ahead of schedule
absolute: -4 * m,
relative: 0,
// linked flag follows the offset
expectedFlagStart: 10 * h + 6 * m,
// ahead of schedule: unlinked events are still expected at their scheduled time
expectedRundownEnd: 11 * h,
});
scenario.setOffsetMode(OffsetMode.Relative);
expect(scenario.digest()).toMatchObject({
// the schedule shifts 4min earlier
expectedFlagStart: 10 * h + 6 * m,
expectedRundownEnd: 10 * h + 56 * m,
});
});
test('partial gap compensation consumes the gap', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat2');
// start flat2 15min late (planned 10:10)
scenario.setTime('jan 5 10:25');
scenario.start();
expect(scenario.digest()).toMatchObject({
eventNow: 'flat2',
eventNext: 'flat3',
// first flag after the loaded event is flat3
eventFlag: 'flat3',
absolute: 15 * m,
// relative to the actual start, flat2 began 10min early
// (the rundown started at flat2, skipping flat1's 10min)
relative: -10 * m,
// flag flat3: gap (10min) only partially compensates the offset (15min)
// expected start = scheduled (10:30) + offset (15min) - gap (10min) = 10:35
expectedFlagStart: 10 * h + 35 * m,
// rundown end: gap (20min) fully compensates the offset (15min)
expectedRundownEnd: 11 * h,
});
});
test('addTime adjusts offsets, can force finish and un-finish', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.start();
scenario.tick(1 * m, 1000);
expect(scenario.digest()).toMatchObject({ current: 9 * m });
// adding time puts us behind schedule
scenario.addTime(2 * m);
expect(scenario.digest()).toMatchObject({
addedTime: 2 * m,
current: 11 * m,
absolute: 2 * m,
relative: 2 * m,
expectedFinish: 10 * h + 12 * m,
expectedRundownEnd: 11 * h,
});
// removing more time than the timer has forces a finish
scenario.addTime(-12 * m);
expect(scenario.digest()).toMatchObject({
addedTime: -10 * m,
current: -1 * m,
hasFinished: false,
});
// the finish is reported on the next update
let results = scenario.tick();
expect(countFinishes(results).timerFinished).toBe(1);
expect(scenario.digest()).toMatchObject({ hasFinished: true, phase: TimerPhase.Overtime });
// !!! characterised quirk: the forced finish flag (_timer.forceFinish) is never
// cleared by update(), so every subsequent update keeps reporting a finished timer
results = scenario.tick();
expect(countFinishes(results).timerFinished).toBe(1);
// adding time back over zero un-finishes the timer
scenario.addTime(5 * m);
expect(scenario.digest()).toMatchObject({
addedTime: -5 * m,
hasFinished: false,
// net added time: we are now 5min ahead of schedule
absolute: -5 * m,
});
});
test('pause freezes the timer and accumulates paused time into the offset', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.start();
scenario.tick(2 * m, 1000);
expect(scenario.pause()).toBe(true);
expect(scenario.digest()).toMatchObject({
playback: Playback.Pause,
pausedAt: 10 * h + 2 * m,
current: 8 * m,
});
// while paused, the timer freezes but the offset grows with the pause
scenario.setTime('jan 5 10:04');
expect(scenario.digest()).toMatchObject({
current: 8 * m,
absolute: 2 * m,
relative: 2 * m,
expectedFinish: 10 * h + 12 * m,
});
// restarting folds the paused time into addedTime
expect(scenario.start()).toBe(true);
expect(scenario.digest()).toMatchObject({
playback: Playback.Play,
pausedAt: null,
addedTime: 2 * m,
current: 8 * m,
absolute: 2 * m,
});
// pause then stop clears everything
scenario.pause();
expect(scenario.stop()).toBe(true);
expect(scenario.digest()).toMatchObject({
playback: Playback.Stop,
eventNow: null,
current: null,
startedAt: null,
actualStart: null,
absolute: 0,
relative: 0,
expectedRundownEnd: null,
currentDay: null,
});
});
test('countToEnd: absolute offset is overtime only', async () => {
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:35');
scenario.load('toEnd');
// start the count-to-end event 10min late
scenario.setTime('jan 5 10:40');
scenario.start();
expect(scenario.digest()).toMatchObject({
// !!! characterised: start() does not recompute current, the value
// set at load time (11:00 - 10:35) sticks until the next update()
current: 25 * m,
expectedFinish: 11 * h,
// count-to-end absorbs the late start: absolute offset is overtime only
absolute: 0,
// the relative offset still reflects the raw event start offset
relative: -30 * m,
});
// the next update recalculates current against the scheduled end
scenario.tick(1000, 1000);
expect(scenario.digest()).toMatchObject({ current: 20 * m - 1000 });
// going into overtime
scenario.setTime('jan 5 11:05');
expect(scenario.digest()).toMatchObject({
current: -5 * m,
phase: TimerPhase.Overtime,
absolute: 5 * m,
relative: -25 * m,
expectedFinish: 11 * h,
});
});
test('group: expectedGroupEnd and actualGroupStart', async () => {
const scenario = await createScenario(makeGroupedRundown(), 'jan 5 10:00');
scenario.load('grouped1');
scenario.setTime('jan 5 10:05');
scenario.start();
expect(scenario.digest()).toMatchObject({
groupNow: 'group',
actualGroupStart: 10 * h + 5 * m,
absolute: 5 * m,
// group end: grouped2 is not linked and has no gap to absorb the 5min offset
// expected start = 10:30 + 5min, group ends 30min later
expectedGroupEnd: 11 * h + 5 * m,
// rundown end: the 30min gap before 'after' absorbs the offset
expectedRundownEnd: 12 * h,
});
});
test('updateRundownData recomputes expected times while playing', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.setTime('jan 5 10:05');
scenario.start();
scenario.setOffsetMode(OffsetMode.Relative);
expect(scenario.digest()).toMatchObject({ expectedRundownEnd: 11 * h + 5 * m });
// a rundown edit moves the planned start earlier
scenario.updateRundownData({
numEvents: 4,
firstStart: 9 * h + 30 * m,
lastEnd: 10 * h + 30 * m,
totalDelay: 0,
totalDuration: 1 * h,
});
expect(scenario.digest()).toMatchObject({
plannedStart: 9 * h + 30 * m,
plannedEnd: 10 * h + 30 * m,
// in relative mode the expected end shifts with the (actualStart - plannedStart) delta
expectedRundownEnd: 11 * h + 35 * m,
});
});
test('updateRundownData while stopped does not produce expected times', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.updateRundownData({
numEvents: 4,
firstStart: 9 * h + 30 * m,
lastEnd: 10 * h + 30 * m,
totalDelay: 0,
totalDuration: 1 * h,
});
expect(scenario.digest()).toMatchObject({
plannedStart: 9 * h + 30 * m,
plannedEnd: 10 * h + 30 * m,
expectedRundownEnd: null,
absolute: 0,
});
});
test('delay entries push the expected start of unlinked events', async () => {
const scenario = await createScenario(makeDelayedRundown(), 'jan 5 10:00');
scenario.load('delayed1');
scenario.start();
expect(scenario.digest()).toMatchObject({
absolute: 0,
// delayed2 is delayed by 5min: expected start 10:25, ends 10min later
expectedRundownEnd: 10 * h + 35 * m,
});
});
});
describe('characterisation: hot reload during playback', () => {
test('editing the running event recomputes the timer without interrupting playback', async () => {
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
scenario.load('lead');
scenario.start();
scenario.tick(2 * m, 1000);
// the running event (10:00 - 10:30) is shortened to 10:00 - 10:20
const editedRundown = makeCountToEndRundown();
// @ts-expect-error -- fixture entries are events
editedRundown.entries.lead.timeEnd = 10 * h + 20 * m;
// @ts-expect-error -- fixture entries are events
editedRundown.entries.lead.duration = 20 * m;
scenario.hotReload(editedRundown);
expect(scenario.digest()).toMatchObject({
eventNow: 'lead',
// playback is not interrupted
playback: Playback.Play,
startedAt: 10 * h,
actualStart: 10 * h,
// the timer is recomputed against the new duration
duration: 20 * m,
current: 18 * m,
expectedFinish: 10 * h + 20 * m,
// expected times are recomputed: the 10min gap before toEnd absorbs
// nothing (offset 0), the rundown still ends on schedule
expectedRundownEnd: 11 * h,
});
});
test('removing the running event slides the selection to the event at the same index', async () => {
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
scenario.load('lead');
scenario.start();
scenario.tick(2 * m, 1000);
// the running event is deleted from the rundown
const editedRundown = makeCountToEndRundown();
delete editedRundown.entries.lead;
editedRundown.order = ['toEnd'];
scenario.hotReload(editedRundown);
expect(scenario.digest()).toMatchObject({
// the event at the previously selected index is loaded in its place
eventNow: 'toEnd',
eventNext: null,
selectedEventIndex: 0,
playback: Playback.Play,
// !!! characterised: the original start time is kept, the replacement
// event plays as if it had started with the removed event
startedAt: 10 * h,
actualStart: 10 * h,
duration: 30 * m,
// toEnd counts to its scheduled end: 11:00 - 10:02
current: 58 * m,
expectedFinish: 11 * h,
});
});
test('reloading the loaded event re-arms the timer and clears progress', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.start();
scenario.tick(2 * m, 1000);
scenario.addTime(1 * m);
const reloadedId = scenario.reloadLoaded();
expect(reloadedId).toBe('flat1');
expect(scenario.digest()).toMatchObject({
eventNow: 'flat1',
playback: Playback.Armed,
startedAt: null,
addedTime: 0,
pausedAt: null,
current: 10 * m,
duration: 10 * m,
elapsed: null,
expectedFinish: null,
hasFinished: false,
});
});
});
describe('characterisation: playback across midnight', () => {
test('playing across midnight increments currentDay and keeps offsets', async () => {
const scenario = await createScenario(makeOvernightRundown(), 'jan 5 23:30');
scenario.load('night2');
scenario.setTime('jan 5 23:35');
scenario.start();
expect(scenario.digest()).toMatchObject({
startedAt: 23 * h + 35 * m,
actualStart: 23 * h + 35 * m,
currentDay: 0,
absolute: 5 * m,
// relative offset re-anchors to the rundown start (planned 22:00, actual 23:35)
relative: -90 * m,
// before midnight, next-day events are normalised over 24h:
// night3 expected start = 24:30 + 5min offset, ends 30min later
expectedRundownEnd: 25 * h + 5 * m,
});
// cross midnight in 1min steps
scenario.tick(35 * m, 1 * m);
expect(scenario.digest()).toMatchObject({
clock: 10 * m,
currentDay: 1,
// started 23:35 with 60min duration: ends 00:35, 25min left at 00:10
current: 25 * m,
absolute: 5 * m,
relative: -90 * m,
// after midnight the same expected end is expressed in today's time
expectedRundownEnd: 1 * h + 5 * m,
});
// full state checkpoint
expect(scenario.state()).toMatchSnapshot();
});
});
describe('characterisation: roll mode', () => {
test('roll: pending then start on schedule', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:55');
const result = scenario.roll();
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
expect(scenario.digest()).toMatchObject({
playback: Playback.Roll,
phase: TimerPhase.Pending,
secondaryTimer: 5 * m,
secondaryTarget: 10 * h,
actualStart: null,
currentDay: null,
});
// the secondary timer counts down to the event start
const results = scenario.tick(5 * m, 1000);
expect(countFinishes(results).secondaryFinished).toBe(1);
expect(scenario.digest()).toMatchObject({ secondaryTimer: 0 });
// the runtime service reacts to the secondary timer finishing by calling roll again
const startResult = scenario.roll();
expect(startResult).toStrictEqual({ eventId: 'flat1', didStart: true });
expect(scenario.digest()).toMatchObject({
playback: Playback.Roll,
startedAt: 10 * h,
actualStart: 10 * h,
currentDay: 0,
absolute: 0,
secondaryTimer: null,
});
});
test('roll: takeover from play keeps the running state', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.setTime('jan 5 10:05');
scenario.start();
const result = scenario.roll();
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
expect(scenario.digest()).toMatchObject({
playback: Playback.Roll,
startedAt: 10 * h + 5 * m,
absolute: 5 * m,
});
});
test('roll: continuation carries the offset to the next event', async () => {
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
scenario.load('flat1');
scenario.setTime('jan 5 10:05');
scenario.start();
scenario.roll();
// flat1 (started 10:05, 10min duration) is finished at 10:16
// the update pushes flat1 1min into overtime, growing the offset to 6min
scenario.setTime('jan 5 10:16');
expect(scenario.digest()).toMatchObject({ absolute: 6 * m });
// the runtime service rolls into the next event passing the current offset
const result = scenario.roll(true);
// with a 6min offset, the offset-clock is 10:10 which is inside flat2 (10:10-10:20)
expect(result).toStrictEqual({ eventId: 'flat2', didStart: true });
expect(scenario.digest()).toMatchObject({
eventNow: 'flat2',
// start times are backdated to the planned start
startedAt: 10 * h + 10 * m,
// the offset passed by the service is kept
absolute: 6 * m,
// !!! characterised: unlike the pending-roll branch, the roll-continuation
// branch overwrites actualStart with the new event's planned start
actualStart: 10 * h + 10 * m,
});
});
test('roll into overnight event after midnight backdates start metadata', async () => {
const scenario = await createScenario(makeOvernightRundown(), 'jan 6 00:05');
const result = scenario.roll();
expect(result).toStrictEqual({ eventId: 'night2', didStart: true });
scenario.tick();
expect(scenario.digest()).toMatchObject({
eventNow: 'night2',
// started mid-event: times are backdated to the planned start
startedAt: 23 * h + 30 * m,
actualStart: 23 * h + 30 * m,
startDayOffset: 0,
// the backdated epoch is yesterday: we are on day 1
currentDay: 1,
absolute: 0,
// night3 is expected on schedule
expectedRundownEnd: 1 * h,
});
// full state checkpoint
expect(scenario.state()).toMatchSnapshot();
});
test('roll: pending across midnight renormalises the secondary target', async () => {
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 23:50');
// all events are in the past: roll pends for tomorrow's first event (10:00)
const result = scenario.roll();
expect(result).toStrictEqual({ eventId: 'lead', didStart: false });
expect(scenario.digest()).toMatchObject({
phase: TimerPhase.Pending,
// 10h10min until tomorrow 10:00, target normalised over 24h
secondaryTimer: 10 * h + 10 * m,
secondaryTarget: dayInMs + 10 * h,
currentDay: null,
});
// crossing midnight renormalises the target to today's time
scenario.setTime('jan 6 00:10');
expect(scenario.digest()).toMatchObject({
secondaryTimer: 9 * h + 50 * m,
secondaryTarget: 10 * h,
currentDay: null,
});
});
});
@@ -0,0 +1,320 @@
/**
* Direct tests for the parameterized runtimeState cores
*
* These run the calculation logic against plain state objects: no module
* mocks, no singleton, no rundown cache. They document the arithmetic of
* the tick, the added-time edge cases and the expected-times projection.
*/
import { OffsetMode, OntimeGroup, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { makeOntimeEvent, makeOntimeGroup } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStateData } from '../__mocks__/runtimeState.mocks.js';
import { type RuntimeState, addTimeCore, getExpectedTimesCore, updateCore } from '../runtimeState.js';
const h = MILLIS_PER_HOUR;
const m = MILLIS_PER_MINUTE;
/** a 10min event, planned 10:00 - 10:10 */
function makeRunningEvent(): PlayableEvent {
return makeOntimeEvent({
id: 'running',
timeStart: 10 * h,
timeEnd: 10 * h + 10 * m,
duration: 10 * m,
dayOffset: 0,
delay: 0,
gap: 0,
countToEnd: false,
}) as PlayableEvent;
}
/** state as it would be after starting the running event on time at 10:00 */
function makePlayingState(): RuntimeState {
return makeRuntimeStateData({
eventNow: makeRunningEvent(),
timer: {
playback: Playback.Play,
duration: 10 * m,
current: 10 * m,
elapsed: 0,
startedAt: 10 * h,
expectedFinish: 10 * h + 10 * m,
},
rundown: {
selectedEventIndex: 0,
numEvents: 1,
plannedStart: 10 * h,
plannedEnd: 10 * h + 10 * m,
actualStart: 10 * h,
currentDay: 0,
},
_startDayOffset: 0,
_startEpoch: new Date('2026-01-05T10:00:00').getTime(),
});
}
describe('updateCore()', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('recomputes the timer against the wall clock', () => {
const state = makePlayingState();
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
const result = updateCore(state);
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
expect(state.timer.current).toBe(6 * m);
expect(state.timer.elapsed).toBe(4 * m);
expect(state.timer.expectedFinish).toBe(10 * h + 10 * m);
expect(state.offset.absolute).toBe(0);
});
test('the finish triggers once, when current enters the trigger-ahead window (10ms)', () => {
const state = makePlayingState();
// 11ms left on the timer: not finished yet
vi.setSystemTime(new Date('2026-01-05T10:09:59.989'));
expect(updateCore(state).hasTimerFinished).toBe(false);
expect(state._timer.hasFinished).toBe(false);
// 10ms left: the finish is triggered and latched
vi.setSystemTime(new Date('2026-01-05T10:09:59.990'));
expect(updateCore(state).hasTimerFinished).toBe(true);
expect(state._timer.hasFinished).toBe(true);
// subsequent updates do not re-trigger, the timer runs into overtime
vi.setSystemTime(new Date('2026-01-05T10:10:30'));
expect(updateCore(state).hasTimerFinished).toBe(false);
expect(state.timer.current).toBe(-30_000);
expect(state.timer.phase).toBe(TimerPhase.Overtime);
// overtime pushes the rundown behind schedule
expect(state.offset.absolute).toBe(30_000);
});
test('a pending forceFinish reports the timer as finished', () => {
const state = makePlayingState();
state._timer.forceFinish = 10 * h + 2 * m;
vi.setSystemTime(new Date('2026-01-05T10:02:00'));
expect(updateCore(state).hasTimerFinished).toBe(true);
expect(state._timer.hasFinished).toBe(true);
});
test('does nothing but update the clock when playback is idle', () => {
const state = makeRuntimeStateData();
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
const result = updateCore(state);
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
expect(state.clock).toBe(10 * h + 4 * m);
expect(state.timer.current).toBeNull();
});
});
describe('addTimeCore()', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-05T10:05:00'));
});
afterEach(() => {
vi.useRealTimers();
});
test('added time moves the offset and the expected finish', () => {
const state = makePlayingState();
state.timer.current = 5 * m; // 10:05, halfway through
expect(addTimeCore(state, 2 * m)).toBe(true);
expect(state.timer.addedTime).toBe(2 * m);
expect(state.timer.current).toBe(7 * m);
// positive offset: we are now 2min behind schedule
expect(state.offset.absolute).toBe(2 * m);
expect(state.timer.expectedFinish).toBe(10 * h + 12 * m);
});
test('removing more time than remains forces a finish', () => {
const state = makePlayingState();
state.timer.current = 5 * m;
addTimeCore(state, -8 * m);
// the force flag is raised for the next update to report
expect(state._timer.forceFinish).not.toBeNull();
expect(state.timer.current).toBe(-3 * m);
// 3min overtime minus 8min removed: 5min ahead of schedule
expect(state.offset.absolute).toBe(-5 * m);
});
test('adding time back over zero un-finishes the timer', () => {
const state = makePlayingState();
state.timer.current = -1 * m;
state.timer.addedTime = -6 * m;
state._timer.hasFinished = true;
addTimeCore(state, 5 * m);
expect(state.timer.current).toBe(4 * m);
expect(state._timer.hasFinished).toBe(false);
});
test('refuses when there is no timer', () => {
const state = makeRuntimeStateData();
expect(addTimeCore(state, 1 * m)).toBe(false);
});
});
describe('getExpectedTimesCore()', () => {
/** a downstream 10min event, planned 11:00 - 11:10 */
function makeDownstreamEvent(patch?: Record<string, unknown>) {
return makeOntimeEvent({
id: 'downstream',
timeStart: 11 * h,
timeEnd: 11 * h + 10 * m,
duration: 10 * m,
dayOffset: 0,
delay: 0,
...patch,
});
}
/**
* playing state with a downstream rundown end
* @param offset current absolute offset (positive = behind schedule)
* @param accumulatedGap total gap between the running and the downstream event
* @param isLinkedToLoaded whether the downstream event links back to the running one
*/
function makeStateWithEnd(args: {
offset: number;
accumulatedGap: number;
isLinkedToLoaded: boolean;
mode?: OffsetMode;
actualStart?: number;
downstream?: ReturnType<typeof makeOntimeEvent>;
}): RuntimeState {
const state = makePlayingState();
state.offset.absolute = args.offset;
state.offset.mode = args.mode ?? OffsetMode.Absolute;
if (args.actualStart !== undefined) state.rundown.actualStart = args.actualStart;
state._end = {
event: args.downstream ?? makeDownstreamEvent(),
accumulatedGap: args.accumulatedGap,
isLinkedToLoaded: args.isLinkedToLoaded,
};
return state;
}
test('on schedule: the rundown ends at the planned time', () => {
const state = makeStateWithEnd({ offset: 0, accumulatedGap: 0, isLinkedToLoaded: false });
getExpectedTimesCore(state);
// 11:00 + 10min duration
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
});
test('behind schedule: a larger gap absorbs the whole offset', () => {
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: false });
getExpectedTimesCore(state);
// gap (20min) > offset (5min): the downstream event still starts as scheduled
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
});
test('behind schedule: a smaller gap absorbs part of the offset', () => {
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 2 * m, isLinkedToLoaded: false });
getExpectedTimesCore(state);
// expected start 11:00 + 5min offset - 2min gap = 11:03, ends 10min later
expect(state.offset.expectedRundownEnd).toBe(11 * h + 13 * m);
});
test('behind schedule: linked events follow the offset, gaps do not apply', () => {
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: true });
getExpectedTimesCore(state);
// expected start 11:00 + 5min offset = 11:05, ends 10min later
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
});
test('ahead of schedule: unlinked events wait for their scheduled time', () => {
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: false });
getExpectedTimesCore(state);
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
});
test('ahead of schedule: linked events pull in with the offset', () => {
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: true });
getExpectedTimesCore(state);
// expected start 10:56, ends 10min later
expect(state.offset.expectedRundownEnd).toBe(11 * h + 6 * m);
});
test('relative mode: the schedule is re-anchored to the actual start', () => {
// started 5min late (actual 10:05, planned 10:00) with no accrued drift
const state = makeStateWithEnd({
offset: 0,
accumulatedGap: 20 * m,
isLinkedToLoaded: false,
mode: OffsetMode.Relative,
actualStart: 10 * h + 5 * m,
});
state.offset.relative = 0;
getExpectedTimesCore(state);
// the whole schedule shifts by the 5min late start: 11:05 + 10min
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
});
test('delays push the expected start', () => {
const state = makeStateWithEnd({
offset: 0,
accumulatedGap: 10 * m,
isLinkedToLoaded: false,
downstream: makeDownstreamEvent({ delay: 5 * m }),
});
getExpectedTimesCore(state);
// delayed start 11:05, ends 10min later
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
});
test('events on a later day are normalised over 24h', () => {
const state = makeStateWithEnd({
offset: 0,
accumulatedGap: 0,
isLinkedToLoaded: false,
downstream: makeDownstreamEvent({ dayOffset: 1 }),
});
getExpectedTimesCore(state);
// tomorrow 11:00 relative to today: 24h + 11:00, ends 10min later
expect(state.offset.expectedRundownEnd).toBe(dayInMs + 11 * h + 10 * m);
});
test('flag start and group end use the same projection', () => {
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 0, isLinkedToLoaded: false });
const downstream = makeDownstreamEvent() as PlayableEvent;
state.eventFlag = downstream;
state._flag = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
state.groupNow = makeOntimeGroup({ id: 'group' }) as OntimeGroup;
state._group = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
getExpectedTimesCore(state);
// no gap to absorb the 5min offset: expected start 11:05
expect(state.offset.expectedFlagStart).toBe(11 * h + 5 * m);
// the group ends when its last event finishes: 11:05 + 10min
expect(state.offset.expectedGroupEnd).toBe(11 * h + 15 * m);
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
});
test('without a loaded event there are no expected times', () => {
const state = makeRuntimeStateData();
getExpectedTimesCore(state);
expect(state.offset.expectedRundownEnd).toBeNull();
expect(state.offset.expectedFlagStart).toBeNull();
expect(state.offset.expectedGroupEnd).toBeNull();
});
});
@@ -11,11 +11,12 @@ import {
clearState,
getState,
load,
loadGroupFlagAndEnd,
loadGroupFlagAndEndCore,
pause,
resume,
roll,
start,
startCore,
stop,
update,
} from '../runtimeState.js';
@@ -82,7 +83,7 @@ describe('mutation on runtimeState', () => {
await initRundown(makeRundown({}), {});
vi.runAllTimers();
let success = start(mockState);
let success = startCore(mockState);
expect(success).toBe(false);
success = pause();
@@ -868,7 +869,7 @@ describe('loadGroupFlagAndEnd()', () => {
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 2, state);
loadGroupFlagAndEndCore(state, rundown, metadata, 2);
expect(state).toMatchObject({
groupNow: rundown.entries[1],
@@ -896,7 +897,7 @@ describe('loadGroupFlagAndEnd()', () => {
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 1, state);
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
expect(state).toMatchObject({
groupNow: rundown.entries[2],
@@ -924,7 +925,7 @@ describe('loadGroupFlagAndEnd()', () => {
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 1, state);
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
expect(state).toMatchObject({
groupNow: null,
@@ -949,7 +950,7 @@ describe('loadGroupFlagAndEnd()', () => {
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
loadGroupFlagAndEndCore(state, rundown, metadata, 0);
expect(state).toMatchObject({
groupNow: null,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.9.0",
"version": "4.10.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.9.0",
"version": "4.10.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -20,6 +20,7 @@ type BaseURLPreset = {
enabled: boolean;
alias: string;
search: string;
displayInNav: boolean;
options?: Record<string, string>;
};
@@ -28,6 +29,7 @@ type CuesheetUrlPreset = {
enabled: boolean;
alias: string;
search: string;
displayInNav: boolean;
options?: {
read?: string;
write?: string;
+1
View File
@@ -26,6 +26,7 @@ export const langEn = {
'timeline.done': 'done',
'timeline.due': 'due',
'timeline.followedby': 'Followed by',
'timeline.standby': 'Standby',
'project.title': 'Title',
'project.description': 'Description',
'project.info': 'Project Info',