Compare commits

..

21 Commits

Author SHA1 Message Date
arc-alex 905db382cd refactor: titlecard component 2026-07-17 19:37:42 +02:00
Carlos Valente c191786e50 refactor(navigation): clear params from editor drawer 2026-07-17 15:50:42 +02:00
Carlos Valente 6911e37c18 refactor(automation): improve automation UX 2026-07-17 10:53:45 +02:00
Claude 86b2132cdc feat(automation): expose group title and allow setting secondary text
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
2026-07-17 10:53:45 +02:00
Carlos Valente 25f0dba83a chore: add codegraph to gitignore 2026-07-16 10:46:10 +02:00
Carlos Valente daa032e92c ux(finder): add navigation shortcuts 2026-07-14 12:04:44 +02:00
Carlos Valente ae65ec97d6 fix(shortcut): correct shortcut to delete 2026-07-14 12:04:44 +02:00
Carlos Valente e7e4302100 refactor(shortcuts): improve readability of info element 2026-07-14 12:04:44 +02:00
Carlos Valente 7e7d35980a refactor(op): improve status recognition 2026-07-14 11:58:59 +02:00
Carlos Valente 5001dda284 feat(op): add group visual relation 2026-07-14 11:58:59 +02:00
Carlos Valente a75d35bdc0 refactor(op): improve delay indicator styles 2026-07-14 11:58:59 +02:00
Claude 2e631194e1 fix(ui): improve progress bar with local interpolation
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-14 11:25:22 +02:00
Carlos Valente f39892dc6e feat(navigation): persist view params customisation 2026-07-13 18:44:38 +02:00
Carlos Valente 6bcfd87851 refactor(rundown): show locked duration group indicator 2026-07-13 17:34:11 +02:00
Claude 4addb50ca4 feat(countdown): allow counting down to a group 2026-07-13 17:29:56 +02:00
Carlos Valente 161ab3fe13 refactor(timer): elapsed is active time from start 2026-07-13 15:34:31 +02:00
Carlos Valente 4f4626a163 refactor: allow larger settings panels 2026-07-13 15:33:23 +02:00
Claude 82d5f02ea0 refactor(mcp): guide agents through creating clean rundowns 2026-07-13 15:33:23 +02:00
Claude 05aaeddf22 resolver: drop dead ignoreDeprecations from tsconfig.dts.json
Confirmed empirically that it now suppresses nothing under TS 7.0.2:
compiling tsconfig.dts.json directly with this flag set to "6.0",
"7.0", or removed entirely all produce identical (clean) output. It
was a leftover suppression the previous tsdown mode never actually
needed once nothing in the config triggers a deprecation warning.
2026-07-13 14:27:31 +02:00
Claude 949ab2b7dc Upgrade TypeScript to 7.0.2 (native Go compiler)
TypeScript 7 ships as a native Go binary and drastically trims the
package's public JS API surface (no more `main`, and `exports['.']`
now only resolves to lib/version.cjs) since the programmatic compiler
API isn't stable again until 7.1. This broke apps/resolver's tsdown
build, which used rolldown-plugin-dts' tsc-program mode (import ts
from 'typescript'; ts.createProgram(...)) for declaration generation.

Fix by switching that build to isolatedDeclarations, which lets
rolldown-plugin-dts generate .d.ts via its oxc-based path instead of
the TS compiler API entirely. That surfaced two isolatedDeclarations
violations in ontime-types (array/Object.keys initializers lacking
explicit annotations), fixed with explicit `string[]` types matching
their existing inferred type. Also bumped tsdown to pick up its latest
rolldown-plugin-dts, and disabled allowJs in tsconfig.dts.json since
it's incompatible with isolatedDeclarations.

Full workspace typecheck, lint, build, and test suite (1186 tests)
pass under TypeScript 7.0.2.
2026-07-13 14:27:31 +02:00
Alex Christoffer Rasmussen f12d3ca59c Small cleanup (#2114)
* refactor: propper memo for `ScheduleContext` value

* chore: removed unused file `cuesheet.utils.ts`

* fix: memoize event in ScheduleProvider
2026-07-11 22:20:32 +02:00
109 changed files with 3771 additions and 3577 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
@@ -0,0 +1,14 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -38,8 +38,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
@@ -1,5 +1,6 @@
import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
@@ -34,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - getProgress(now, complete);
const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -1,6 +1,8 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -17,6 +19,9 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([
[
@@ -46,6 +51,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/>
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</>
@@ -1,4 +1,29 @@
.linkIcon {
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon {
@include rotate-fourty-five;
}
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,9 +1,17 @@
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { MouseEvent, PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
@@ -16,32 +24,104 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) {
return (
<NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
<ElectronNavigationItem current={current} to={to} postAction={postAction}>
{children}
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
</ElectronNavigationItem>
);
}
return (
<NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
<BrowserNavigationItem current={current} to={to} postAction={postAction}>
{children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem>
);
}
@@ -22,3 +22,19 @@
top: 0;
z-index: $zindex-nav;
}
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu?: () => void;
toggleSettings?: () => void;
hasSavedChanges?: boolean;
}
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true);
return (
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
{toggleMenu && (
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
<div className={style.buttonWithIndicator}>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)}
{toggleSettings && (
<IconButton
@@ -14,6 +14,4 @@ $progress-bar-br: 3px;
.progress-bar__indicator {
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
}
@@ -1,6 +1,6 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import './ProgressBar.scss';
@@ -12,7 +12,7 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = getProgress(current, duration);
const progress = useAnimatedProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -4,7 +4,9 @@
color: $white-10;
.empty {
width: 100%;
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
}
@@ -13,6 +15,6 @@
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: 600px;
max-width: min(100%, 600px);
}
}
@@ -4,40 +4,60 @@
position: relative;
display: flex;
flex-direction: column;
}
.title-card__title,
.title-card__placeholder {
font-weight: 600;
font-size: $title-font-size;
line-height: 1.2em;
}
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
border-left: 1vw solid;
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__title:empty::before {
color: var(--label-color-override, $viewer-label-color);
content: attr(data-placeholder);
}
.title-card__secondary {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
.title-card__title {
font-weight: 600;
line-height: 1.4em;
padding-right: 1em;
color: var(--color-override, $viewer-color);
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
text-transform: uppercase;
.title-card__secondary {
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
&--accent {
color: var(--accent-color-override, $accent-color);
&.md {
.title-card__title {
font-size: $title-font-size;
}
.title-card__secondary {
font-size: $base-font-size;
}
}
&.lg {
.title-card__title {
font-size: $large-font-size;
}
.title-card__secondary {
font-size: $title-font-size;
}
.schedule__ {
font-size: $base-font-size;
}
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
text-transform: uppercase;
&--accent {
color: var(--accent-color-override, $accent-color);
}
}
}
@@ -1,33 +1,60 @@
import { ForwardedRef, forwardRef } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import { ExtendedEntry } from '../../utils/rundownMetadata';
import { cx, enDash } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
type TitleCardMainProps = {
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
}
colour?: string;
textAlign?: 'left' | 'right' | 'center';
size?: 'md' | 'lg';
placeholder?: string;
};
type TitleCardExpectedProps = TitleCardMainProps & {
event: ExtendedEntry<OntimeEvent>;
expectedStart: number;
showExpected: boolean;
};
type TitleCardNoExpectedProps = TitleCardMainProps & {
event?: undefined;
expectedStart?: undefined;
showExpected?: false;
};
type TitleCardProps = TitleCardExpectedProps | TitleCardNoExpectedProps;
export default function TitleCard({
label,
title,
secondary,
className = '',
colour = 'transparent',
textAlign = 'left',
size = 'md',
placeholder = enDash,
}: TitleCardProps) {
'use memo';
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
return (
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<div className={cx(['title-card', className, size])} style={{ borderColor: colour }}>
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
{title === '' ? null : title}
</span>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
});
TitleCard.displayName = 'TitleCard';
export default TitleCard;
}
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -36,6 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
};
const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams());
};
@@ -0,0 +1,41 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
@@ -0,0 +1,87 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -0,0 +1,62 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { linkToOtherHost } from '../linkUtils';
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => {
it('should handle electron links', () => {
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
});
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
+20 -1
View File
@@ -37,7 +37,12 @@ export function handleLinks(
event?.preventDefault();
const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
// split off any query string so it is not encoded into the pathname
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString());
}
@@ -54,3 +59,17 @@ export function linkToOtherHost(
}
return destination.toString();
}
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section {
position: relative;
margin-top: 2rem;
max-width: 850px;
max-width: 1024px;
}
.indent {
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label>
<label>
Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}>
<label>
Target URL
<Input
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' },
{ value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message' },
]}
/>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<>
<label>
Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
)}
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
<>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Next up: keynote'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Timer secondary source
<Select<SecondarySource | 'no-change' | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// no-change -> leave the current secondary source untouched
if (value === 'no-change') {
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
return;
}
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)}
<div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create an automation to attach triggers to'
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
@@ -1,29 +1,67 @@
.wrapper {
.inputShell {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
.fluid {
width: 100%;
margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem;
}
max-height: 200px;
overflow-y: auto;
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250;
color: $label-gray;
}
li {
padding: 0.25rem;
}
.list {
box-sizing: border-box;
max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
outline: 0;
}
li:hover {
.item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] {
color: $ui-white;
background: $blue-700;
}
}
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,69 +1,258 @@
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import { forwardRef, useMemo, useState } from 'react';
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props;
interface TemplateEditorModalProps {
autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false));
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateInputValue = useCallback(
(nextValue: string) => {
setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const { setCursorForValue } = autocomplete;
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
onChange?.(event);
const closeExpandedEditor = () => {
setIsExpanded(false);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
const saveExpandedEditor = (nextValue: string) => {
updateInputValue(nextValue);
autocomplete.setCursorForValue(nextValue, nextValue.length);
setIsExpanded(false);
};
return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
});
}
export default TemplateInput;
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
};
const handleSave = () => {
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
);
}
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { matchRemaining } from '../templateInput.utils';
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe('');
});
});
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
return '';
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
}
@@ -0,0 +1,72 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -2,33 +2,23 @@ import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import { serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => {
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;
})();
// the page origin is reachable wherever the user is browsing from,
// and follows the deployment (cloud, reverse proxy, port mappings)
// we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl('');
});
}, [infoData]);
}, []);
const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
@@ -40,14 +30,19 @@ export default function McpSection() {
<Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Divider />
<Panel.Field
title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
);
@@ -180,7 +180,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return (
<Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} />
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
@@ -217,6 +223,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
@@ -22,17 +22,39 @@
background-color: $gray-1250;
}
&.grouped {
position: relative;
padding-left: 0.35rem;
background:
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
$viewer-card-bg-color;
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green);
}
&.grouped.running {
background:
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
var(--operator-running-bg-override, $active-green);
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
}
}
.groupRail {
position: absolute;
inset-block: 0;
left: 0;
width: 0.35rem;
background-color: var(--group-colour, $gray-500);
}
.binder {
grid-area: binder;
color: $section-white;
@@ -74,6 +96,9 @@
.plannedStart,
.timeUntil,
.runningTime {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem;
line-height: 1;
@@ -95,6 +120,21 @@
letter-spacing: 1px;
}
.live {
color: $ui-black;
background-color: $ui-white;
}
.due {
color: $ui-black;
background-color: $orange-500;
}
.done {
color: $white-60;
background-color: $white-7;
}
.runningTime {
grid-area: running;
font-size: 1.25rem;
@@ -25,6 +25,7 @@ interface OperatorEventProps {
isLinkedToLoaded: boolean;
isSelected: boolean;
isPast: boolean;
groupColour?: string;
selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean;
subscribed: Subscribed;
@@ -46,6 +47,7 @@ function OperatorEvent({
isLinkedToLoaded,
isSelected,
isPast,
groupColour,
selectedRef,
showStart,
subscribed,
@@ -68,7 +70,12 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
const operatorClasses = cx([
style.event,
groupColour && style.grouped,
isSelected && style.running,
isPast && style.past,
]);
const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
@@ -85,8 +92,10 @@ function OperatorEvent({
data-testid={cue}
ref={selectedRef}
onContextMenu={handleLongPress}
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
{...mouseHandlers}
>
{groupColour && <div className={style.groupRail} />}
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
@@ -153,11 +162,11 @@ function OperatorEventSchedule({
isLinkedToLoaded,
}: OperatorEventScheduleProps) {
if (isPast) {
return <span className={style.timeUntil}>DONE</span>;
return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
}
if (isSelected) {
return <span className={style.timeUntil}>LIVE</span>;
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
}
return (
@@ -186,7 +195,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return (
<span className={style.timeUntil} data-testid='time-until'>
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
{timeUntilString}
</span>
);
@@ -1,13 +1,33 @@
.group {
width: 100%;
padding: 0.25rem 0.5rem;
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
}
// tablet
@media (min-width: $min-tablet) {
.group {
padding: 0.25rem 1rem;
}
.title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.875rem;
font-weight: 500;
}
@@ -1,12 +1,29 @@
import { memo } from 'react';
import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss';
interface OperatorGroup {
title: string;
colour: string;
count: number;
duration: number;
}
export default memo(OperatorGroup);
function OperatorGroup({ title }: OperatorGroup) {
return <div className={style.group}>{title}</div>;
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
);
}
@@ -3,58 +3,94 @@
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.shortcutSection {
flex: 1;
margin-top: 15vh;
width: min(100%, 48rem);
margin-top: clamp(1.5rem, 8vh, 5rem);
margin-inline: auto;
gap: 1rem;
}
.shortcuts {
font-size: calc(1rem - 3px);
border-collapse: separate;
border-spacing: 4rem 0;
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
tr {
white-space: nowrap;
td:nth-child(odd) {
text-align: left;
}
td:nth-child(even) {
text-align: right;
}
.shortcutGroup {
h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.spacer {
height: 1rem;
.shortcutList {
display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
}
.prompt {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white;
border-radius: 2px;
font-weight: 400;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
}
@media (max-width: 680px) {
.entryEditor {
padding: 0.75rem 1rem;
}
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
}
@@ -1,6 +1,7 @@
import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
@@ -12,216 +13,124 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}>
<tbody>
<tr>
<td>Find in rundown</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>F</Kbd>
</td>
</tr>
<tr>
<td>Open Settings</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>,</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Select entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Select group</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Jump to top / bottom</td>
<td>
<Kbd>Home</Kbd>
<AuxKey>/</AuxKey>
<Kbd>End</Kbd>
</td>
</tr>
<tr>
<td>Page up / down</td>
<td>
<Kbd>PgUp</Kbd>
<AuxKey>/</AuxKey>
<Kbd>PgDn</Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
<Kbd>Esc</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Reorder selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Copy selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>C</Kbd>
</td>
</tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add group below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add group above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add milestone below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add milestone above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
<div className={style.shortcuts}>
<ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} />
</Shortcut>
<Shortcut label='Open settings'>
<Combo keys={[deviceMod, ',']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Select entry'>
<Combo keys={[deviceAlt, '↑']} />
<Separator />
<Combo keys={[deviceAlt, '↓']} />
</Shortcut>
<Shortcut label='Select group'>
<Combo keys={[deviceAlt, 'Shift', '↑']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', '↓']} />
</Shortcut>
<Shortcut label='Jump to top / bottom'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Page up / down'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Deselect entry'>
<Combo keys={['Esc']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Editing'>
<Shortcut label='Reorder selected entry'>
<Combo keys={[deviceAlt, deviceMod, '↑']} />
<Separator />
<Combo keys={[deviceAlt, deviceMod, '↓']} />
</Shortcut>
<Shortcut label='Copy selected entry'>
<Combo keys={[deviceMod, 'C']} />
</Shortcut>
<Shortcut label='Cut selected entry'>
<Combo keys={[deviceMod, 'X']} />
</Shortcut>
<Shortcut label='Paste below'>
<Combo keys={[deviceMod, 'V']} />
</Shortcut>
<Shortcut label='Paste above'>
<Combo keys={[deviceMod, 'Shift', 'V']} />
</Shortcut>
<Shortcut label='Clone selected entry'>
<Combo keys={[deviceMod, 'D']} />
</Shortcut>
<Shortcut label='Delete selected entry'>
<Combo keys={[deviceAlt, 'Backspace']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Insert'>
<Shortcut label='Add event below / above'>
<Combo keys={[deviceAlt, 'E']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'E']} />
</Shortcut>
<Shortcut label='Add group below / above'>
<Combo keys={[deviceAlt, 'G']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'G']} />
</Shortcut>
<Shortcut label='Add milestone below / above'>
<Combo keys={[deviceAlt, 'M']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'M']} />
</Shortcut>
<Shortcut label='Add delay below / above'>
<Combo keys={[deviceAlt, 'D']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut>
</ShortcutGroup>
</div>
</div>
</div>
);
}
function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>;
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Kbd({ children }: PropsWithChildren) {
return <span className={style.kbd}>{children}</span>;
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -156,7 +156,7 @@ export default function RundownEvent({
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
clearSelectedEvents();
deleteEntry(Array.from(selectedEvents));
@@ -202,7 +202,7 @@ export default function RundownEvent({
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
deleteEntry([eventId]);
unselect(eventId);
@@ -3,7 +3,5 @@
width: 0;
border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200;
}
@@ -1,12 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() {
const timer = useTimer();
const progress = getProgress(timer.current, timer.duration);
const progress = useAnimatedProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
}
@@ -82,6 +82,17 @@
margin-right: 0.25rem;
color: $ui-white;
}
.duration {
display: flex;
align-items: center;
gap: 0.25rem;
}
.lockIcon {
opacity: 0.6;
}
.over {
color: $playback-over;
.strike {
@@ -8,6 +8,7 @@ import {
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo,
IoTrash,
} from 'react-icons/io5';
@@ -17,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
@@ -65,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item',
label: 'Delete Group',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([data.id]),
},
]);
@@ -176,14 +177,17 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
</div>
<div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div>
{planOffset === null ? (
<div>{formatDuration(data.duration)}</div>
) : (
<div className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<Tag className={style.offsetLabel}>{planOffset}</Tag>
</div>
)}
<div className={style.duration}>
{planOffset === null ? (
formatDuration(data.duration)
) : (
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<Tag className={style.offsetLabel}>{planOffset}</Tag>
</span>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div>
</div>
</div>
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([entryId]),
},
]);
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import { hostToBaseUrl } from '../../common/utils/linkUtils';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
@@ -21,7 +22,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
*/
const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({
value: `http://${nif.address}:${infoData.serverPort}`,
value: hostToBaseUrl(nif.address, infoData.serverPort),
label: `${nif.name} - ${nif.address}`,
}));
}, [infoData.networkInterfaces, infoData.serverPort]);
+1
View File
@@ -12,6 +12,7 @@ $viewer-opacity-disabled: 0.6;
$timer-label-size: clamp(12px, 1.25vw, 20px);
$base-font-size: clamp(15px, 1.5vw, 28px);
$title-font-size: clamp(18px, 2.25vw, 42px);
$large-font-size: clamp(40px, 4.5vw, 80px);
$timer-value-size: clamp(24px, 2.5vw, 48px);
$header-font-size: clamp(24px, 2.5vw, 48px);
@@ -18,6 +18,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft',
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start',
@@ -18,6 +18,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso',
'countdown.group_running': 'Evento en grupo en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar',
@@ -18,6 +18,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.group_running': 'Évènement du groupe en cours',
'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans',
@@ -18,6 +18,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.group_running': 'Evento nel gruppo in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza',
@@ -18,6 +18,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento',
'countdown.group_running': 'Evento em grupo em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar',
@@ -32,6 +32,11 @@
color: var(--label-color-override, $viewer-label-color);
}
.title-card {
// overwrite the title-card bg color so they don't stack as it is transparent
background-color: transparent;
}
/* =================== HEADER + EXTRAS ===================*/
.project-header {
@@ -1,6 +1,5 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../common/viewUtils';
/**
@@ -46,9 +45,9 @@ export function getCardData(
}
// if we are loaded, we show the upcoming event as next
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
@@ -7,6 +7,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -140,25 +141,23 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
return () => clearInterval(paginator.current);
}, [cycleInterval, numPages, stopCycle, visiblePage]);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current
const viewEvents = events.slice(selectedEventIndex + 1);
selectedEventIndex = 0;
const viewEvents = useMemo(() => {
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
return (events as ExtendedEntry<OntimeEvent>[]).slice(selectedEventIndex + 1);
}, [events, selectedEventId]);
return (
<ScheduleContext
value={{
events: viewEvents as ExtendedEntry<OntimeEvent>[],
selectedEventId,
numPages,
visiblePage,
containerRef,
}}
>
{children}
</ScheduleContext>
);
const value = useMemo(() => {
return {
events: viewEvents,
selectedEventId,
numPages,
visiblePage,
containerRef,
};
}, [viewEvents, selectedEventId, numPages, visiblePage, containerRef]);
return <ScheduleContext value={value}>{children}</ScheduleContext>;
};
export const useSchedule = () => {
+106 -3
View File
@@ -59,6 +59,7 @@ $item-height: 3.5rem;
right: $view-inline-padding;
display: flex;
align-items: center;
justify-content: end;
gap: 2rem;
@@ -72,14 +73,27 @@ $item-height: 3.5rem;
}
/* ========================= LIST ========================*/
.empty-container {
.empty-state {
min-height: min(56vh, 36rem);
padding-top: clamp(4rem, 14vh, 9rem);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 2rem;
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
}
@@ -88,7 +102,7 @@ $item-height: 3.5rem;
flex-direction: column;
overflow-y: auto;
padding-bottom: 60vh;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* ====================== LIST-ITEM ======================*/
@@ -104,6 +118,7 @@ $item-height: 3.5rem;
column-gap: 1rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem;
border: 1px solid transparent;
border-radius: $element-border-radius;
&:hover {
@@ -115,18 +130,41 @@ $item-height: 3.5rem;
.sub--selected {
background: $blue-700;
box-shadow: inset 0 0 0 1px $blue-500;
}
.sub--live {
background-color: $active-green;
box-shadow: inset 0 0 0 1px color-mix(in srgb, $active-green 75%, $ui-white);
}
.sub--armed {
background-color: $gray-1325;
}
.sub--in-group {
position: relative;
margin-left: 1rem;
&::before {
content: '';
position: absolute;
left: -0.5rem;
top: -0.25rem;
bottom: -0.25rem;
width: 2px;
border-radius: 1px;
background: var(--group-color, transparent);
}
.sub__binder {
box-shadow: inset 3px 0 0 var(--group-color, transparent);
}
}
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
border-radius: $element-border-radius 0 0 $element-border-radius;
grid-area: binder;
}
@@ -138,6 +176,8 @@ $item-height: 3.5rem;
gap: 0.25em;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.sub__schedule--delayed {
@@ -156,22 +196,73 @@ $item-height: 3.5rem;
color: $playback-under;
}
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
box-shadow: none;
}
.sub__title {
font-weight: 700;
}
.sub__schedule,
.sub__status,
.sub__label {
color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.sub--group.sub--selected {
box-shadow: inset 0 0 0 1px $blue-500;
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 22%, transparent), transparent 42%),
$blue-700;
.sub__schedule,
.sub__status,
.sub__label,
.sub__eyebrow {
color: $ui-white;
}
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
min-width: 0;
font-size: $title-font-size;
line-height: 1.1em;
overflow-wrap: anywhere;
}
.sub__eyebrow {
display: block;
margin-bottom: 0.15rem;
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: $timer-label-size;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
}
.sub__secondary {
grid-area: secondary;
padding-bottom: 0.5rem;
min-width: 0;
font-size: $base-font-size;
line-height: 1.1em;
color: var(--label-color-override, $viewer-label-color);
// allow multi-line text but trim before
white-space: pre-line;
overflow-wrap: anywhere;
}
.sub__status {
@@ -181,6 +272,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size;
text-align: right;
text-transform: uppercase;
white-space: nowrap;
}
.sub__label {
@@ -190,6 +282,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-align: right;
white-space: nowrap;
}
.sub__timer {
@@ -199,6 +292,16 @@ $item-height: 3.5rem;
line-height: 1.1em;
font-weight: 600;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.selection-count {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
/* ====================== MODIFIERS ======================*/
+42 -18
View File
@@ -1,4 +1,14 @@
import { EntryId, OntimeEvent, OntimeView, PlayableEvent, isOntimeEvent, isPlayableEvent } from 'ontime-types';
import {
EntryId,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeView,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
@@ -15,7 +25,7 @@ import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils';
import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown';
@@ -45,13 +55,19 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false);
// gather rundown data
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
return isOntimeEvent(entry) && isPlayableEvent(entry);
// gather subscribable items: playable events and groups that contain at least one playable child
const candidates = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent | OntimeGroup> => {
if (isOntimeEvent(entry)) {
return isPlayableEvent(entry);
}
if (isOntimeGroup(entry)) {
return rundownData.some((item) => isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id);
}
return false;
});
// gather presentation data
const hasEvents = playableEvents.length > 0;
const hasEvents = candidates.length > 0;
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -69,15 +85,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
<CountdownClock />
</div>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div>
)}
{hasEvents && editMode && (
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
<CountdownSelect events={candidates} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)}
{hasEvents && !editMode && (
<CountdownContents
playableEvents={playableEvents}
candidates={candidates}
rundownData={rundownData}
subscriptions={subscriptions}
goToEditMode={() => setEditMode(true)}
/>
@@ -87,19 +108,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
}
interface CountdownContentsProps {
playableEvents: ExtendedEntry<OntimeEvent>[];
candidates: ExtendedEntry<OntimeEvent | OntimeGroup>[];
rundownData: ExtendedEntry<OntimeEntry>[];
subscriptions: EntryId[];
goToEditMode: () => void;
}
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
function CountdownContents({ candidates, rundownData, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -107,13 +129,15 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
);
}
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
const subscribedEvents = getOrderedSubscriptions(subscriptions, candidates)
.map((entry) => resolveSubscriptionTarget(entry, rundownData))
.filter((target): target is NonNullable<typeof target> => target !== null);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -129,8 +153,8 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
if (eventsToShow.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div>
);
}
@@ -1,4 +1,4 @@
import { EntryId, PlayableEvent } from 'ontime-types';
import { EntryId, OntimeEvent, OntimeGroup, isOntimeGroup } from 'ontime-types';
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: ExtendedEntry<PlayableEvent>[];
events: ExtendedEntry<OntimeEvent | OntimeGroup>[];
subscriptions: EntryId[];
disableEdit: () => void;
}
@@ -52,13 +52,15 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
return (
<div className='list-container'>
{events.map((event, index) => {
{events.map((event) => {
const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id);
const isGroup = isOntimeGroup(event);
const isGroupedEvent = !isGroup && Boolean(event.parent);
return (
<div
key={index}
key={event.id}
role='button'
tabIndex={0}
onClick={() => toggleSelect(event.id)}
@@ -68,21 +70,33 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
e.stopPropagation();
}
}}
className={cx(['sub', isSelected && 'sub--selected'])}
className={cx([
'sub',
isSelected && 'sub--selected',
isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
>
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div
className='sub__binder'
style={{ '--user-color': event?.colour ?? '', '--group-color': event.groupColour ?? 'transparent' }}
/>
<div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div>
<div className='sub__title'>
{isGroup && <span className='sub__eyebrow'>Group</span>}
{title}
</div>
</div>
);
})}
<div className='fab-container'>
<div className='selection-count'>{selected.length} selected</div>
<Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back
</Button>
@@ -19,6 +19,7 @@ import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import {
CountdownEvent,
CountdownTarget,
extendEventData,
getIsLive,
isOutsideRange,
@@ -30,7 +31,7 @@ import {
import './Countdown.scss';
interface CountdownSubscriptionsProps {
subscribedEvents: ExtendedEntry<OntimeEvent>[];
subscribedEvents: CountdownTarget[];
goToEditMode: () => void;
}
@@ -98,22 +99,39 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
@@ -11,13 +11,13 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { useSubscriptionDisplayData } from './countdown.utils';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
interface SingleEventCountdownProps {
subscribedEvent: ExtendedEntry<OntimeEvent>;
subscribedEvent: CountdownTarget;
goToEditMode: () => void;
}
@@ -38,11 +38,13 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
// while a group is live, surface the running event's title as the secondary line
const liveTitle = subscribedEvent.isGroup && subscribedEvent.liveEntry ? subscribedEvent.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(subscribedEvent, secondarySource);
return (
<div className='single-container' data-testid='countdown-event'>
@@ -80,7 +80,7 @@ export const getCountdownOptions = (
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
description: 'The events and groups to follow',
values: persistedSubscriptions,
type: 'persist',
},
@@ -0,0 +1,128 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
* Only the fields exercised by resolveSubscriptionTarget are provided; the rest are cast away.
*/
function makeEvent(patch: Partial<ExtendedEntry<OntimeEvent>>): ExtendedEntry<OntimeEvent> {
return {
id: 'event',
type: SupportedEntry.Event,
title: 'event title',
cue: '1',
colour: '',
skip: false,
parent: null,
timeStart: 0,
timeEnd: 0,
duration: 0,
delay: 0,
dayOffset: 0,
gap: 0,
countToEnd: false,
custom: {},
note: '',
// metadata
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
isPast: false,
...patch,
} as ExtendedEntry<OntimeEvent>;
}
function makeGroup(patch: Partial<ExtendedEntry<OntimeGroup>>): ExtendedEntry<OntimeGroup> {
return {
id: 'group',
type: SupportedEntry.Group,
title: 'group title',
colour: '#abcdef',
note: 'group note',
entries: [],
duration: 0,
custom: {},
timeStart: 0,
timeEnd: 0,
isPast: false,
isLoaded: false,
...patch,
} as ExtendedEntry<OntimeGroup>;
}
describe('resolveSubscriptionTarget()', () => {
it('returns events unchanged', () => {
const event = makeEvent({ id: 'e1' });
const result = resolveSubscriptionTarget(event, [event]);
expect(result).toBe(event);
});
it('derives group timing from the first playable child while keeping the group identity', () => {
const group = makeGroup({ id: 'g1', title: 'Session A', colour: '#123456', duration: 5000 });
const child1 = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1000, delay: 100, dayOffset: 0, title: 'Pres 1' });
const child2 = makeEvent({ id: 'c2', parent: 'g1', timeStart: 3000, title: 'Pres 2' });
const flat: ExtendedEntry<OntimeEntry>[] = [group, child1, child2];
const result = resolveSubscriptionTarget(group, flat);
expect(result).not.toBeNull();
// group identity and display
expect(result?.id).toBe('g1');
expect(result?.title).toBe('Session A');
expect(result?.colour).toBe('#123456');
expect(result?.isGroup).toBe(true);
// timing comes from the first child, duration from the group
expect(result?.timeStart).toBe(1000);
expect(result?.delay).toBe(100);
expect(result?.duration).toBe(5000);
expect(result?.countToEnd).toBe(false);
// state checks target the first child before any child is loaded
expect(result?.targetId).toBe('c1');
// report lookup targets the last child (session end)
expect(result?.reportId).toBe('c2');
});
it('returns null for a group with no playable children', () => {
const group = makeGroup({ id: 'g1' });
const result = resolveSubscriptionTarget(group, [group]);
expect(result).toBeNull();
});
it('skips skipped children when picking the first child', () => {
const group = makeGroup({ id: 'g1' });
const skipped = {
...makeEvent({ id: 'c0', parent: 'g1', timeStart: 500 }),
skip: true,
} as ExtendedEntry<OntimeEntry>;
const playable = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1500 });
const result = resolveSubscriptionTarget(group, [group, skipped, playable]);
expect(result?.timeStart).toBe(1500);
expect(result?.reportId).toBe('c1');
});
it('is live while any child is loaded and not past', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isLoaded: true });
const child3 = makeEvent({ id: 'c3', parent: 'g1' });
const result = resolveSubscriptionTarget(group, [group, child1, child2, child3]);
expect(result?.isLoaded).toBe(true);
expect(result?.isPast).toBe(false);
expect(result?.liveEntry?.id).toBe('c2');
});
it('is past only once the last child is past and nothing is loaded', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isPast: true });
const result = resolveSubscriptionTarget(group, [group, child1, child2]);
expect(result?.isLoaded).toBe(false);
expect(result?.isPast).toBe(true);
expect(result?.targetId).toBe('c1');
expect(result?.liveEntry).toBeNull();
});
});
@@ -1,4 +1,17 @@
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import {
EntryId,
MaybeNumber,
OffsetMode,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeReport,
Playback,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
@@ -40,7 +53,7 @@ export const timerProgress: TimerMessage = {
* Handles events in different days but disregards whether an event has actually played
*/
export function useSubscriptionDisplayData(
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation();
@@ -67,7 +80,11 @@ export function useSubscriptionDisplayData(
return {
status: 'live',
statusDisplay: getLocalizedString(timerProgress['live']),
statusDisplay: getLocalizedString(
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
? 'countdown.group_running'
: timerProgress['live'],
),
timeDisplay: bigDuration(current ?? 0),
};
}
@@ -168,10 +185,75 @@ export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE;
}
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
/**
* A subscription target normalised to the event-shaped object the countdown display consumes.
* For events this is a pass-through; for groups it carries the group identity and display data
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
*/
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
isGroup?: boolean;
targetId?: EntryId; // child entry used for countdown/live/armed state checks
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
* - Events are returned unchanged.
* - Groups count down to their first playable child while displaying the group identity.
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
* Returns null when a group has no playable children (nothing to count down to).
*/
export function resolveSubscriptionTarget(
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
flatRundown: ExtendedEntry<OntimeEntry>[],
): CountdownTarget | null {
if (!isOntimeGroup(entry)) {
return entry;
}
const children = flatRundown.filter(
(item): item is ExtendedEntry<PlayableEvent> =>
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
);
if (children.length === 0) {
return null;
}
const firstChild = children[0];
const lastChild = children[children.length - 1];
const liveEntry = children.find((child) => child.isLoaded) ?? null;
const isLoaded = liveEntry !== null;
const isPast = !isLoaded && lastChild.isPast;
return {
// timing derives from the first playable child
...firstChild,
// group identity and display data
id: entry.id,
parent: null,
title: entry.title,
colour: entry.colour,
note: entry.note,
custom: entry.custom,
duration: entry.duration,
countToEnd: false,
// group-aware status
isLoaded,
isPast,
// group markers
isGroup: true,
targetId: firstChild.id,
reportId: lastChild.id,
liveEntry,
};
}
export function extendEventData(
event: ExtendedEntry<OntimeEvent>,
event: CountdownTarget,
currentDay: number,
actualStart: MaybeNumber,
plannedStart: MaybeNumber,
@@ -189,6 +271,6 @@ export function extendEventData(
offset,
mode,
});
const { endedAt } = reportData[event.id] ?? { endedAt: null };
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
}
@@ -1,32 +0,0 @@
import { parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000;
const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('duration', testData3)).toBe('00:10:00');
});
it('returns an empty string on undefined fields', () => {
// @ts-expect-error -- testing user data with missing fields
expect(parseField('title')).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
@@ -1,23 +0,0 @@
import { CustomFields, MaybeNumber, OntimeEntryCommonKeys } from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
* @param {*} data
* @return {string}
*/
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber, { fallback: '' });
}
if (field === 'skip') {
return data ? 'x' : '';
}
return String(data ?? '');
};
@@ -54,17 +54,51 @@
}
.footer {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
font-size: calc(1rem - 2px);
color: $label-gray;
}
.filterHint {
text-align: right;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.hints {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.hintItem {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.scrollContainer {
max-height: 70vh;
overflow: auto;
padding-top: 1rem;
}
@media (max-width: 680px) {
.footer {
align-items: flex-start;
flex-direction: column;
}
.filterHint {
text-align: left;
}
}
+20 -2
View File
@@ -3,6 +3,7 @@ import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder';
@@ -96,8 +97,25 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
}
footerElements={
<div className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search.
<div className={style.hints}>
<span className={style.hintItem}>
<Kbd></Kbd>
<Kbd></Kbd>
Navigate
</span>
<span className={style.hintItem}>
<Kbd>Enter</Kbd>
Go
</span>
<span className={style.hintItem}>
<Kbd>Esc</Kbd>
Close
</span>
</div>
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
</div>
}
/>
@@ -1,8 +1,8 @@
import { Day } from 'ontime-types';
import { CSSProperties, RefObject } from 'react';
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -169,7 +169,7 @@ function TimelineEntryStatus({
/** Generates a block level progress bar */
function ActiveBlock() {
const { current, duration } = useTimer();
const progress = getProgress(current, duration);
const progress = useAnimatedProgress(current, duration);
return (
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
);
-4
View File
@@ -65,10 +65,6 @@
/* =================== TITLES ===================*/
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
&.now {
grid-area: now;
}
+18 -2
View File
@@ -206,8 +206,24 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{!hideCards && (
<>
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
{showNow && (
<TitleCard
className='event now'
label='now'
title={nowMain}
secondary={nowSecondary}
colour={eventNow?.colour}
/>
)}
{showNext && (
<TitleCard
className='event next'
label='next'
title={nextMain}
secondary={nextSecondary}
colour={eventNext?.colour}
/>
)}
</>
)}
</div>
+5
View File
@@ -71,6 +71,11 @@ export default defineConfig({
changeOrigin: true,
configure: logProxyRequests,
},
'^/mcp': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': {
target: 'http://localhost:4001/',
changeOrigin: true,
+1 -1
View File
@@ -20,7 +20,7 @@
"license": "AGPL-3.0-only",
"devDependencies": {
"ontime-types": "workspace:^4.2.1",
"tsdown": "^0.21.5",
"tsdown": "^0.22.4",
"typescript": "catalog:"
},
"files": [
+2 -1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"ignoreDeprecations": "6.0"
"isolatedDeclarations": true,
"allowJs": false
}
}
@@ -163,5 +163,70 @@ describe('parseOutput', () => {
secondarySource: 'secondary',
});
});
it('parses message-secondary with a text value', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: undefined,
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'hello',
}),
).toMatchObject({
secondarySource: 'secondary',
text: 'hello',
});
// an empty text is treated as no change
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: '',
}),
).toMatchObject({
secondarySource: 'secondary',
text: undefined,
});
// text can be set while clearing the secondary source
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text: 'hello',
}),
).toMatchObject({
secondarySource: null,
text: 'hello',
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 123,
}),
).toThrow('Unexpected payload type:');
});
});
});
@@ -0,0 +1,113 @@
import { runtimeStorePlaceholder } from 'ontime-types';
import * as messageService from '../../../services/message-service/message.service.js';
import { toOntimeAction } from '../clients/ontime.client.js';
vi.mock('../../../services/message-service/message.service.js', () => ({
patch: vi.fn(),
}));
describe('toOntimeAction()', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('parses templates in primary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-set',
text: 'Current: {{timer.current}}',
visible: true,
},
{
...runtimeStorePlaceholder,
timer: {
...runtimeStorePlaceholder.timer,
current: 42,
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
text: 'Current: 42',
visible: true,
},
});
});
it('parses templates in secondary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
secondarySource: 'secondary',
},
secondary: 'Next: Keynote',
});
});
it('can set secondary message text without changing the secondary source', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
secondary: 'Next: Keynote',
});
});
});
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
toOntimeAction(payload, store);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
@@ -225,13 +225,28 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
}
if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
// the secondary text is optional, an empty string is treated as no change
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
assert.isString(maybeOntimeAction.text);
text = indeterminateText(maybeOntimeAction.text);
}
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
return {
type: 'ontime',
action: 'message-secondary',
text,
};
}
// null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) {
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text,
};
}
@@ -240,6 +255,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
};
}
@@ -1,12 +1,14 @@
import { LogOrigin, OntimeAction } from 'ontime-types';
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
import { parseTemplateNested } from '../automation.utils.js';
export function toOntimeAction(action: OntimeAction) {
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) {
const actionType = action.action;
switch (actionType) {
// Aux timer actions
@@ -55,18 +57,25 @@ export function toOntimeAction(action: OntimeAction) {
case 'message-set': {
messageService.patch({
timer: {
text: action.text,
text: action.text ? parseTemplateNested(action.text, store) : action.text,
visible: action.visible,
},
});
break;
}
case 'message-secondary': {
messageService.patch({
timer: {
secondarySource: action.secondarySource,
},
});
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
const patch =
action.secondarySource === undefined
? { secondary }
: {
timer: {
secondarySource: action.secondarySource,
},
secondary,
};
messageService.patch(patch);
break;
}
@@ -6,11 +6,13 @@ const editEntryMock = vi.hoisted(() => vi.fn());
const groupEntriesMock = vi.hoisted(() => vi.fn());
const ungroupEntriesMock = vi.hoisted(() => vi.fn());
const getCurrentRundownMock = vi.hoisted(() => vi.fn());
const getProjectCustomFieldsMock = vi.hoisted(() => vi.fn());
const createCustomFieldMock = vi.hoisted(() => vi.fn());
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: getCurrentRundownMock,
getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
getProjectCustomFields: vi.fn(() => ({})),
getProjectCustomFields: getProjectCustomFieldsMock,
}));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
@@ -20,6 +22,7 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
addEntry: addEntryMock,
batchEditEntries: vi.fn(),
createCustomField: createCustomFieldMock,
deleteEntries: vi.fn(),
editEntry: editEntryMock,
groupEntries: groupEntriesMock,
@@ -27,7 +30,8 @@ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
ungroupEntries: ungroupEntriesMock,
}));
const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js');
const { batchCreateEntriesForMcp, createCustomFieldForMcp, createEntryForMcp, groupEntriesForMcp, ungroupEntryForMcp } =
await import('../mcp.service.js');
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
return {
@@ -44,6 +48,7 @@ describe('mcp.service', () => {
beforeEach(() => {
vi.clearAllMocks();
getCurrentRundownMock.mockReturnValue(makeRundown({}));
getProjectCustomFieldsMock.mockReturnValue({});
let id = 0;
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
@@ -252,4 +257,42 @@ describe('mcp.service', () => {
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
});
describe('createCustomFieldForMcp', () => {
it('creates a field and returns the derived key', async () => {
createCustomFieldMock.mockResolvedValue({
Camera_Angle: { label: 'Camera Angle', type: 'text', colour: '#3E75E8' },
});
const result = await createCustomFieldForMcp({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(createCustomFieldMock).toHaveBeenCalledWith({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
expect(result.key).toBe('Camera_Angle');
});
it('rejects labels the editor UI would not accept', async () => {
await expect(createCustomFieldForMcp({ label: 'Camera/GFX', type: 'text', colour: '#000000' })).rejects.toThrow(
'Invalid label',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
it('rejects case-insensitive duplicates and points at the existing key', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createCustomFieldForMcp({ label: 'camera', type: 'text', colour: '#000000' })).rejects.toThrow(
'A custom field with key "Camera" (label "Camera") already exists.',
);
expect(createCustomFieldMock).not.toHaveBeenCalled();
});
});
it('suggests the correctly cased key when custom values use the wrong casing', async () => {
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
await expect(createEntryForMcp({ title: 'Talk', custom: { camera: 'CAM 2' } })).rejects.toThrow(
'Keys are case-sensitive — did you mean: "camera" → "Camera"?',
);
expect(addEntryMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
getProjectData: vi.fn(() => ({ title: 'Test project' })),
}));
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
getCurrentRundown: vi.fn(() => ({
id: 'rundown-1',
title: 'Test',
order: [],
flatOrder: [],
entries: {},
revision: 0,
})),
getProjectCustomFields: vi.fn(() => ({})),
}));
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
}));
const { PROMPT_DEFINITIONS, handleGetPrompt } = await import('../mcp.prompts.js');
const { RESOURCE_DEFINITIONS, handleReadResource } = await import('../mcp.resources.js');
describe('MCP resource wiring', () => {
it('serves non-empty content for every listed resource', () => {
for (const resource of RESOURCE_DEFINITIONS) {
const result = handleReadResource(resource.uri);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].uri).toBe(resource.uri);
expect(result.contents[0].mimeType).toBe(resource.mimeType);
expect((result.contents[0].text as string).length).toBeGreaterThan(0);
}
});
it('rejects unknown resource URIs', () => {
expect(() => handleReadResource('ontime://nope')).toThrow('Unknown resource URI: ontime://nope');
});
});
describe('MCP prompt wiring', () => {
it('resolves every listed prompt', () => {
for (const prompt of PROMPT_DEFINITIONS) {
const args = Object.fromEntries((prompt.arguments ?? []).map((arg) => [arg.name, 'test value']));
const result = handleGetPrompt(prompt.name, args);
expect(result.messages.length).toBeGreaterThan(0);
const content = result.messages[0].content;
expect(content.type).toBe('text');
}
});
it('rejects unknown prompts', () => {
expect(() => handleGetPrompt('nope', {})).toThrow('Unknown prompt: nope');
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Agent-facing Ontime guidance documents.
*
* These markdown resources teach agents rundown craft (ontime://style-guide)
* and Ontime's view ecosystem (ontime://views). Guidance should be concise and
* clear verbosity distracts agents and users alike. Keep each document short.
*/
// The palette mirrors the editor colour picker swatches in
// apps/client/src/common/components/input/colour-input/SwatchSelect.tsx — keep in sync.
export const ONTIME_STYLE_GUIDE_MARKDOWN = `# Ontime rundown style guide
Conventions for tidy, scannable rundowns. Read before creating or restyling a rundown. Follow the user's own conventions when they have them; otherwise prefer these. When communicating with the user, be concise short proposals beat long explanations.
## Structure
- Group events into blocks matching the show's segments; 10+ ungrouped events is a wall propose groups.
- Set \`targetDuration\` on groups so operators can track time to block completion.
- Use \`milestone\` for non-timed markers (doors open, broadcast start), not zero-duration events.
- Use \`delay\` only to model schedule drift, never as padding.
- Schedule changeovers and buffers as real events (grey; orange when technical), not invisible gaps 510 min between major segments stops one overrun cascading through the day.
- Model contingency segments as \`skip: true\` events: visible in the rundown, excluded from playback, one edit to activate.
- Before show day, duplicate the rundown for rehearsal so the show copy stays clean.
## Colours
Prefer Ontime's default palette — the editor's swatches so colours look native. Each hue has a dark/light tone pair:
| Hue | Dark | Light | Use |
| --- | --- | --- | --- |
| Red | \`#ED3333\` | \`#FF7878\` | attention: curfews, hard deadlines, broadcast-critical |
| Orange | \`#FFAB33\` | \`#FFCC78\` | technical changeovers, resets |
| Green | \`#339E4E\` | \`#77C785\` | normal show content |
| Blue | \`#3E75E8\` | \`#779BE7\` | normal show content |
| Violet | \`#8064E1\` | \`#A790F5\` | normal show content |
| Grey | \`#9d9d9d\` | \`#ececec\` | breaks, housekeeping |
- Normal segments use the calm hues (green, blue, violet). Reserve red for what genuinely needs attention and orange for changeovers.
- Use tones for hierarchy: dark tone on a group, the light tone of the same hue on its child events.
- One convention per project, applied consistently.
## Cues and titles
- Cues follow ONE convention per rundown: numeric (\`1, 2, 3\` or \`10, 20, 30\`, leaving room for insertions), semantic (\`Preset\`, \`Key 1\`, \`Lunch\`), or a deliberate mix. Dense technical shows often prefix by department (\`LX1\`, \`SND2\`, \`VID3\`). Match what the rundown already uses; ask if nothing is established.
- Cues are automation handles external systems (Companion, OSC/HTTP) trigger events by cue name. Keep them unique and stable; warn before renaming cues on an existing show.
- Title = what happens on stage, under ~50 characters. Department/tech data goes in custom fields; \`note\` is for free-text human context.
## Custom fields
- Structured per-department data (camera, graphics, audio, speaker) belongs in custom fields one per department, coloured to match. This powers the cuesheet and operator views.
- Reuse existing fields; never create near-duplicates (\`Cam\`, \`camera\`, \`Cameras\`).
- Data repeated in titles or notes ("CAM 2") is a sign it belongs in a custom field.
- Write values to be scannable mid-show: "CAM 2 close-up", not sentences.
## Timing
- \`linkStart\` events inside a block so schedule edits propagate.
- \`flag\` sparingly — it marks critical moments and dilutes with overuse.
- Set \`timeWarning\` where a moderator would signal the speaker to wrap (commonly 5 min); \`timeDanger\` for the final push (e.g. 1 min). Scale both down for short items.
- Hard out? Backtime: build the closing items from the end with \`timeEnd\` + \`duration\` (Ontime calculates \`timeStart\`), protect their ends with \`timeStrategy: lock-end\`, and use \`countToEnd\` for true curfew items.
After tidying, read \`ontime://views\` and suggest views that use the new structure.
`;
export const ONTIME_VIEWS_MARKDOWN = `# Ontime views and sharing guide
Ontime renders the rundown into role-specific views at \`http://<host>:<port>/<view>\`. Recommend the right view when the user mentions a role, device, or sharing need. Prefer sharing live view links over exporting static documents — everyone sees the current version, ending the conflicting-copies problem of circulated spreadsheets. The agent cannot create views or presets — give exact URLs and point to the editor settings.
| View | Path | For |
| --- | --- | --- |
| Timer | \`/timer\` | speakers/presenters on stage |
| Backstage | \`/backstage\` | crew following schedule + notes |
| Operator | \`/op\` | department operators on phone/tablet |
| Cuesheet | \`/cuesheet\` | cross-department table editing |
| Timeline | \`/timeline\` | producers, whole-show overview |
| Countdown | \`/countdown\` | following selected events or a group |
| Studio clock | \`/studio\` | on-air studio environments |
| Project info | \`/info\` | sharing event logistics widely |
Most views are configured via URL parameters (editor and cuesheet in-app); the view's cog icon exposes the options and encodes them into the URL.
## Operator view parameters
- \`main\` / \`secondary-src\`: the two text lines — \`title\`, \`note\`, or \`custom-<fieldKey>\`.
- \`subscribe=<fieldKey>\`: highlight a custom field (repeatable).
- \`shouldEdit=true\`: long-press editing of the highlighted field.
- \`hidePast=true\`, \`showStart=true\`: trim and annotate the list.
Example for a camera operator:
\`/op?main=title&secondary-src=custom-Camera&subscribe=Camera&hidePast=true\`
Offer a link like this after creating a department custom field.
## URL presets
Presets bundle a configured view under a short alias (e.g. \`/camera-op\`), optionally shown in the nav menu. Managed in **Editor → Settings → URL presets**. When recommending a configured view, spell out the view and query string and suggest saving it as a preset.
Docs: https://docs.getontime.no/features/url-presets/
## Custom views
For branding or layouts beyond the built-in options: files in Ontime's \`external\` folder are served at \`/external/…\` and can use Ontime's APIs for live data. Recommend for sponsor branding, unusual layouts, or LED wall graphics.
Docs: https://docs.getontime.no/features/custom-views/
`;
+63 -11
View File
@@ -33,6 +33,18 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
},
],
},
{
name: 'tidy_rundown',
description:
'Audit the rundown for readability (colours, grouping, cue naming, custom field usage) and propose improvements',
arguments: [
{
name: 'focus',
description: 'Optional area to focus on, e.g. "colours only" or "grouping"',
required: false,
},
],
},
{
name: 'manage_custom_fields',
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
@@ -105,15 +117,17 @@ Flags (flag):
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
- The operator view shows a countdown to the next flagged event use sparingly for maximum impact.
Colours:
- Ask the user what colour convention they use before applying any colours.
- Common pattern: one colour per event type (keynotes, panels, breaks, meals).
- Colours are hex strings: \`#RRGGBB\`.
Structure and colours:
- Read the ontime://style-guide resource before deciding on structure or colours.
- Propose a group per agenda section (morning block, keynotes, breaks) rather than a flat list in ontime_batch_create_entries this is just a group entry with \`children\`.
- Ask the user what colour convention they use; if they have none, propose one from the default Ontime palette in the style guide: calm hues for normal content, red only for attention items, orange for changeovers, dark tone on a group with the light tone of the same hue on its children. Colours are hex strings: \`#RRGGBB\`.
Custom fields (custom):
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
- Store values per entry at \`custom: { <fieldKey>: <value> }\`only use keys that exist in the project.
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
- Store values per entry at \`custom: { <fieldKey>: <value> }\`keys are case-sensitive.
- Reuse an existing field when it covers the concept (avoid duplicates such as \`Cam\`, \`camera\`, \`Cameras\`); when the agenda carries structured data with no matching field (speakers, locations), create it with ontime_create_custom_field and use the returned key — no confirmation needed.
After creating the rundown, read ontime://views and suggest how the team can follow it — e.g. a timer view for speakers, a backstage view for crew, or an operator view subscribed to a department custom field.
Agenda:
${args.agenda}`,
@@ -162,6 +176,7 @@ Schedule integrity:
- Events with \`duration\` of 0 or negative
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
- Long segments scheduled back-to-back with no changeover or buffer between them: one overrun will cascade suggest a buffer event
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
@@ -181,6 +196,13 @@ Skipped events:
Totals:
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
Readability (report at INFO level; see ontime://style-guide for the conventions):
- Colour usage without a consistent convention, or key segments left uncoloured
- Long runs of related ungrouped events that would read better as groups
- Duplicate cues (cues are automation handles and should be unique) or mixed cue naming conventions
- Structured department data in titles/notes that belongs in custom fields
- If several readability issues surface, suggest running the tidy_rundown prompt to fix them systematically
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
);
}
@@ -215,6 +237,34 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
);
}
if (name === 'tidy_rundown') {
const focus = args.focus ? `\nFocus specifically on: "${args.focus}".` : '';
return userPrompt(
'Audit and improve rundown readability',
`Audit the current Ontime rundown for readability and tidiness, then propose improvements.${focus}
Steps:
1. Read the ontime://style-guide resource — it defines the conventions to audit against, including the standard colour palette.
2. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
3. Call ontime_get_rundown with the chosen \`rundownId\`, and ontime_get_custom_fields for the project field definitions.
4. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it.
Audit for:
- Colour consistency: entries coloured outside a discernible convention, missing colours on key segments, red/orange used for normal content (the style guide reserves them for attention items and changeovers), or group/children colours that ignore the dark/light tone pairing. Prefer the default Ontime palette.
- Grouping: long runs (roughly 4+) of related consecutive top-level events that would read better as a named group; groups missing \`targetDuration\`.
- Buffers: long segments back-to-back with no changeover event between them; propose short buffer events so one overrun cannot cascade.
- Cue naming: mixed conventions (numeric vs semantic) without apparent intent, duplicate cues, or gaps that suggest mistakes. Remember cues are automation handles renaming them can break external triggers, so flag renames explicitly.
- Data in the wrong place: structured department data buried in titles or notes (e.g. "CAM 2", "GFX: lower third") that belongs in a custom field; overlong titles (over ~50 chars).
- Entry types: zero-duration events that should be milestones; milestones that carry timing expectations and should be events.
- Flags: \`flag: true\` on many events dilutes its meaning — critical markers only.
Then:
5. Present the findings as a short, scannable proposal (what changes, on which entries, and why) and WAIT for the user to approve before mutating anything. Be concise a tight list beats prose.
6. Apply approved changes with the cheapest calls: ontime_group_entries to group existing entries, ontime_batch_update_entries when several entries get the same values (e.g. recolouring), ontime_update_entry for per-entry changes, ontime_create_custom_field when moving data into a new field.
7. Finish by reading ontime://views and suggesting views that put the improved structure to work — e.g. an operator view subscribed to a department custom field, or a cuesheet for cross-department editing.`,
);
}
if (name === 'manage_custom_fields') {
return userPrompt(
'List, create, rename, recolour, or delete custom field definitions',
@@ -223,14 +273,14 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
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.
1. Call ontime_get_custom_fields to see what fields already exist. Reuse an existing field when it covers the concept avoid duplicates such as "Cam", "camera", and "Cameras".
2. Carry out the instruction using the tools below. Creating a field is non-destructive do it directly. 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.
- label: human-readable name (letters, numbers and spaces). The key is auto-derived: spaces underscores (e.g. "Camera Angle" "Camera_Angle").
- 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.
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. If unspecified, pick one from the default Ontime palette matching the department.
- Returns: { key, customFields } use the returned key when setting values on entries.
Renaming or recolouring a field:
@@ -242,7 +292,9 @@ 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.`,
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.
When a field was created for a department (camera, graphics, audio), read ontime://views and offer the views that surface it: the cuesheet shows the field as a column for cross-department editing, and the operator view can display or highlight it for that department, e.g. /op?main=title&secondary-src=custom-<key>&subscribe=<key>.`,
);
}
+19
View File
@@ -4,6 +4,7 @@ import { getProjectData } from '../api-data/project-data/projectData.dao.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
import { ONTIME_STYLE_GUIDE_MARKDOWN, ONTIME_VIEWS_MARKDOWN } from './mcp.guides.js';
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
@@ -45,6 +46,22 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
mimeType: 'application/json',
},
{
uri: 'ontime://style-guide',
name: 'ontime-style-guide',
title: 'Rundown style guide',
description:
'Best practices for tidy, easy-to-read rundowns: grouping, colour conventions and the default Ontime palette, cue naming, custom fields vs notes, timing hygiene. Read before creating or restyling a rundown.',
mimeType: 'text/markdown',
},
{
uri: 'ontime://views',
name: 'ontime-views',
title: 'Views and sharing guide',
description:
'What each Ontime view (timer, backstage, operator, cuesheet, timeline, countdown, studio clock, project info) is for, key URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.',
mimeType: 'text/markdown',
},
{
uri: 'ontime://docs',
name: 'ontime-docs',
@@ -57,6 +74,8 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
'ontime://style-guide': { mimeType: 'text/markdown', read: () => ONTIME_STYLE_GUIDE_MARKDOWN },
'ontime://views': { mimeType: 'text/markdown', read: () => ONTIME_VIEWS_MARKDOWN },
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
'ontime://rundown/current': {
mimeType: 'application/json',
+17 -5
View File
@@ -49,7 +49,8 @@ export const EVENT_WRITABLE_FIELDS = {
note: { type: 'string', description: 'Free-text note for production notes or references' },
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
description:
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
},
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
flag: {
@@ -60,7 +61,7 @@ export const EVENT_WRITABLE_FIELDS = {
type: 'object',
additionalProperties: { type: 'string' },
description:
'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.',
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
},
...EVENT_TIMER_FIELDS,
} as const;
@@ -180,12 +181,11 @@ Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
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 }\`.
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). 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\`.
Keys are case-sensitive. When setting \`custom\` values, reuse an existing field when it covers the concept; otherwise create the missing field — creation is non-destructive and needs no confirmation. Rename and delete are the destructive operations: confirm those with the user.
## Targeting rundowns
Entry read/write tools accept an optional \`rundownId\`.
@@ -199,6 +199,8 @@ When playback is not \`stop\`, mutations to the loaded rundown affect the live r
## Available resources
- \`ontime://schema\` — this document
- \`ontime://style-guide\` — rundown design best practices: grouping, colour palette, cue naming, custom field usage. Read before creating or restyling a rundown.
- \`ontime://views\` — Ontime's role-specific views, URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
- \`ontime://rundowns\` — all rundowns in the project (JSON)
- \`ontime://project/info\` — project metadata (JSON)
@@ -234,6 +236,16 @@ Main site: https://docs.getontime.no
- OSC Integration: https://docs.getontime.no/api/osc/
- Make custom views: https://docs.getontime.no/features/custom-views/
## Views
- Configuring views: https://docs.getontime.no/quick-tips/configuring-views/
- Operator view: https://docs.getontime.no/interface/production/operator/
- Cuesheet: https://docs.getontime.no/interface/production/cuesheet/
- Render views in third-party apps: https://docs.getontime.no/quick-tips/render-elsewhere/
## Quick tips
- Managing delays: https://docs.getontime.no/quick-tips/managing-delays/
- Smart time entry: https://docs.getontime.no/quick-tips/smart-time-entry/
## Helpful references
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
+31 -5
View File
@@ -12,6 +12,7 @@ import {
Rundown,
SupportedEntry,
} from 'ontime-types';
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import {
@@ -110,11 +111,21 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
const available = Object.keys(customFields);
const hint =
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
// keys are case-sensitive; a near-miss on casing is the most common mistake
const suggestions = [...unknownKeys]
.map((key) => {
const match = available.find((existing) => existing.toLowerCase() === key.toLowerCase());
return match ? `"${key}" → "${match}"` : null;
})
.filter(Boolean);
const caseHint =
suggestions.length > 0 ? ` Keys are case-sensitive — did you mean: ${suggestions.join(', ')}?` : '';
throw new Error(
`Unknown custom field key(s): ${missing}. ${hint} ` +
`Unknown custom field key(s): ${missing}. ${hint}${caseHint} ` +
`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.`,
`the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
);
}
}
@@ -333,8 +344,23 @@ export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids:
}
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;
const label = args.label?.trim();
// same constraint the HTTP route enforces in customFields.validation.ts
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
throw new Error('Invalid label: use letters, numbers and spaces only, e.g. "Camera Angle".');
}
const key = customFieldLabelToKey(label);
const existingFields = getProjectCustomFields();
const clash = Object.keys(existingFields).find((existing) => existing.toLowerCase() === key.toLowerCase());
if (clash) {
throw new Error(
`A custom field with key "${clash}" (label "${existingFields[clash].label}") already exists. ` +
`Reuse it in entry.custom["${clash}"] instead of creating a duplicate, or pick a clearly different label.`,
);
}
const updated = await createCustomField({ label, type: args.type, colour: args.colour });
return { key, customFields: updated };
}
+9 -4
View File
@@ -186,7 +186,11 @@ export const TOOL_DEFINITIONS = [
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
title: { type: 'string', description: 'Group title shown in the rundown and views' },
note: { type: 'string', description: 'Free-text group note for production notes or references' },
colour: { type: 'string', description: 'Hex colour (#RRGGBB) for the group' },
colour: {
type: 'string',
description:
'Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide',
},
custom: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -377,7 +381,7 @@ export const TOOL_DEFINITIONS = [
{
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.',
'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"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.',
inputSchema: {
type: 'object',
required: ['label', 'type', 'colour'],
@@ -385,7 +389,7 @@ export const TOOL_DEFINITIONS = [
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".',
'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
},
type: {
type: 'string',
@@ -395,7 +399,8 @@ export const TOOL_DEFINITIONS = [
},
colour: {
type: 'string',
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
description:
'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).',
},
},
},
@@ -5,6 +5,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getCurrent,
getElapsed,
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
@@ -15,6 +16,56 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
clock: 5 * MILLIS_PER_MINUTE,
timer: {
addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE,
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 0,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('subtracts accumulated pause time', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('uses the current pause start while paused', () => {
const state = {
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
});
});
describe('getExpectedFinish()', () => {
it('is null if we havent started', () => {
const state = {
@@ -47,6 +47,23 @@ describe('isRestorePoint()', () => {
expect(isRestorePoint(restorePoint)).toBe(true);
});
it('accepts optional paused duration', () => {
const restorePoint: RestorePoint = {
playback: Playback.Roll,
selectedEventId: '123',
startedAt: 1,
addedTime: 2,
pausedAt: null,
pausedDuration: 3000,
firstStart: 1,
startEpoch: asInstant(1),
currentDay: 0,
};
expect(isRestorePoint(restorePoint)).toBe(true);
expect(isRestorePoint({ ...restorePoint, pausedDuration: '3000' })).toBe(false);
});
describe('rejects a badly formatted file', () => {
it('with invalid playback value', () => {
const restorePoint = {
@@ -46,6 +46,10 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
return false;
}
if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) {
return false;
}
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
return false;
}
@@ -6,6 +6,7 @@ export type RestorePoint = {
startedAt: MaybeNumber;
addedTime: number;
pausedAt: MaybeNumber;
pausedDuration?: number;
firstStart: MaybeNumber;
startEpoch: Maybe<Instant>;
currentDay: MaybeNumber;
@@ -1,251 +0,0 @@
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],
]);
});
});
@@ -1,247 +0,0 @@
/**
* 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,3 +1,4 @@
import { deepEqual } from 'fast-equals';
import {
EndAction,
EntryId,
@@ -29,12 +30,13 @@ 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';
@@ -649,6 +651,8 @@ 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
@@ -661,13 +665,90 @@ 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();
for (const key of Object.keys(patch) as (keyof RuntimeStore)[]) {
batch.add(key, patch[key] as RuntimeStore[typeof key]);
// 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;
}
// save the restore state
@@ -679,6 +760,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart,
startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay,
@@ -5,7 +5,6 @@ import {
Offset,
OntimeEvent,
Rundown,
RuntimeStore,
TimerState,
TimerType,
isOntimeEvent,
@@ -13,8 +12,6 @@ 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,
@@ -36,7 +33,8 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
* Checks whether we should update the timer values
* - `current` and `secondaryTimer` trigger on seconds roll over
* - the rest trigger on any change
* - `elapsed` and `expectedFinish` is not checked
* - `elapsed` changes alongside `current`
* - `expectedFinish` is not checked
*/
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
if (previousValue === undefined) return true;
@@ -51,7 +49,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
previousValue.phase !== currentValue.phase ||
previousValue.playback !== currentValue.playback ||
previousValue.startedAt !== currentValue.startedAt
// elapsed - this would be the direct invert of current value so no need to check
// elapsed - this is derived from current / duration / addedTime, so the fields above cover it
// expectedFinish - this will be moved out by the current value going into over time, no need to check
);
}
@@ -71,104 +69,6 @@ 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
*/
+27
View File
@@ -92,6 +92,33 @@ export function getCurrent(state: RuntimeState): number {
return startedAt + duration + addedTime - clock - correctDay;
}
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
if (startedAt === null) {
return null;
}
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed);
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) {
return clock + dayInMs - startedAt;
}
return clock - startedAt;
}
/**
* Checks whether we have skipped out of the event
* @param {RuntimeState} state runtime state
@@ -40,6 +40,7 @@ const baseState: RuntimeState = {
_timer: {
forceFinish: null,
pausedAt: null,
pausedDuration: 0,
secondaryTarget: null,
hasFinished: false,
},
@@ -1,313 +0,0 @@
// 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,
},
}
`;
@@ -1,191 +0,0 @@
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'],
});
}
@@ -1,174 +0,0 @@
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,
};
}
@@ -1,688 +0,0 @@
/**
* 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,
});
});
});
@@ -1,320 +0,0 @@
/**
* 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();
});
});

Some files were not shown because too many files have changed in this diff Show More