Compare commits

...

23 Commits

Author SHA1 Message Date
Carlos Valente 2ef0b81743 refactor: extract midnight cross rules 2026-03-03 20:48:53 +01:00
Carlos Valente 5a5450d7ef refactor: use Day type in client 2026-03-03 20:48:53 +01:00
Carlos Valente 6f1f10ccff fix: backdate rundown start when rolling 2026-03-03 20:48:53 +01:00
Carlos Valente 5ff7861968 fix: use rundown day for calculations 2026-03-03 20:48:53 +01:00
Carlos Valente f1a1c358bc fix: infer day offset from epoch 2026-03-03 20:48:53 +01:00
Carlos Valente efd19353e9 fix: roll resets day 2026-03-03 20:48:53 +01:00
Carlos Valente a29a493678 fix: add current day when ticking over midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 27b179276d fix: skip handles events across midnight 2026-03-03 20:48:53 +01:00
Carlos Valente 155cf48a17 fix: roll loads events that cross midnight 2026-03-03 20:48:53 +01:00
Alex Christoffer Rasmussen 358ad79ae4 Automation should use event store for data (#1962)
* fix: automation should use RuntimeStore for data

* add: aux timer template help
2026-03-02 20:54:46 +01:00
Alex Christoffer Rasmussen c1fcdf7065 Remove serverport from project file (#1957)
* feat: get server port from app satate or env

optional startup port from env will always override

function for parsing port from env

populate default port in app state

add test for migration

* bump version
2026-03-02 06:02:29 -08:00
Carlos Valente 1fe58e21be fix: onStart triggered by pending roll start 2026-02-28 10:32:19 +01:00
Carlos Valente 3c0e7ba4d5 chore: fix import across packages 2026-02-27 20:32:02 +01:00
Carlos Valente e8f159d894 feat: create branded types to determine different handling of time 2026-02-27 20:32:02 +01:00
Carlos Valente 168d7103b1 fix: prevent settings reflow on small width screens 2026-02-23 20:20:07 +01:00
Carlos Valente 63d7250a70 refactor: consistent focus styling 2026-02-23 20:19:49 +01:00
Carlos Valente 88f21e2186 refactor: improve timer styling in playback controls 2026-02-23 20:19:49 +01:00
Carlos Valente d9a18b0c42 fix: path comparison considers core params 2026-02-23 15:23:49 +01:00
Carlos Valente df588b8845 refactor: extract cuesheet permissions 2026-02-23 15:23:49 +01:00
Carlos Valente 5be504ce8f fix: prevent presets losing params 2026-02-23 15:23:49 +01:00
Carlos Valente 6268e327b9 refactor: mask cuesheet presets 2026-02-23 15:23:49 +01:00
Carlos Valente 1c81b3a23c refactor: hide edit button for users with no write permissions 2026-02-23 15:23:49 +01:00
Carlos Valente d28895ce9c refactor: prevent import map collision across stages 2026-02-23 15:22:56 +01:00
93 changed files with 2221 additions and 466 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.2",
"version": "4.5.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.2",
"version": "4.5.0",
"private": true,
"type": "module",
"dependencies": {
+16 -1
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { Settings } from 'ontime-types';
import { PortInfo, Settings } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
@@ -27,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`);
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
@@ -15,6 +15,11 @@ $thumb-color-hover: $white-60;
height: 100%;
width: 100%;
overscroll-behavior: contain;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
.scrollbar {
+4 -4
View File
@@ -202,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 fo this use case to avoid null checks
// we can force these numbers to 0 for this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 fo this use case to avoid null checks
// we can force these numbers to 0 for this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
currentDay: state.eventNow?.dayOffset ?? 0,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
}));
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
});
describe('cuesheet presets', () => {
const cuesheetPreset: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: '-',
},
},
];
const cuesheetPresetWithoutOptions: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-basic',
target: OntimeView.Cuesheet,
search: '',
},
];
const cuesheetPresetWithNavLock: URLPreset[] = [
{
enabled: true,
alias: 'cuesheet-locked',
target: OntimeView.Cuesheet,
search: 'n=1',
},
];
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
const location = resolvePath('/cuesheet-4685d6');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
});
it('preserves feature params when redirecting masked cuesheet aliases', () => {
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
});
it('keeps cuesheet aliases masked even when preset options are absent', () => {
const location = resolvePath('/cuesheet-basic');
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
});
it('applies navigation lock from preset search params when alias is opened', () => {
const location = resolvePath('/cuesheet-locked');
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
});
});
});
describe('generatePathFromPreset()', () => {
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
});
it('distinguishes preset paths with different lock or token params', () => {
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
+40 -9
View File
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
// NOTE: verify that this resolves correctly in cloud
const currentPath = `${location.pathname}${location.search}`.substring(1);
const currentURL = getCurrentPath(location);
const token = new URLSearchParams(location.search).get('token');
const isLocked = location.search.includes('n=1');
const locationParams = new URLSearchParams(location.search);
const token = locationParams.get('token');
for (const preset of urlPresets) {
if (!preset.enabled) continue;
const presetParams = new URLSearchParams(preset.search);
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
/**
* If the page is a known alias it would be like
* /preset/{alias} <- locked to a preset
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
* we need to compare the saved preset to the current path to see if we need to redirect
*/
if (preset.alias === currentURL || preset.target === currentURL) {
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
const newPath = shouldMaskPresetPath(preset)
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
/**
* if the current path is equivalent to the new path, we return null
* this means we will not redirect
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
return `${path.pathname}?${searchParams}`.substring(1);
}
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
const searchParams = new URLSearchParams();
if (locked) {
searchParams.set('n', '1');
}
if (token) {
searchParams.set('token', token);
}
const path = `preset/${alias}`;
const search = searchParams.toString();
if (!search) {
return path;
}
return `${path}?${search}`;
}
function shouldMaskPresetPath(preset: URLPreset): boolean {
return preset.target === OntimeView.Cuesheet;
}
/**
* Utility checks if two paths are equivalent
* For preset paths, only compares the path (since params are stored in session)
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// For preset paths, only compare the path
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
return currentUrl.pathname === newUrl.pathname;
}
// For regular paths, compare path and search params (ignoring token)
if (currentUrl.pathname !== newUrl.pathname) {
return false;
}
// For preset paths, only n and token are meaningful — ignore everything else
if (currentUrl.pathname.startsWith('/preset/')) {
return (
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
);
}
// For regular paths, compare all search params except n and token
currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('n');
newUrl.searchParams.delete('token');
@@ -9,7 +9,8 @@
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
position: relative;
}
@@ -7,6 +7,8 @@ ul {
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
`{{auxtimer${index}.playback}}`,
`{{auxtimer${index}.direction}}`,
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
@@ -1,5 +1,7 @@
import { ImportCustom, ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
export type NamedImportMap = typeof namedImportMap;
// Record of label and import name
@@ -63,12 +65,14 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
};
}
const importMapKey = makeStageKey('import-map');
export function persistImportMap(options: NamedImportMap) {
localStorage.setItem('import-options', JSON.stringify(options));
localStorage.setItem(importMapKey, JSON.stringify(options));
}
function getPersistImportMap(): unknown {
const options = localStorage.getItem('import-options');
const options = localStorage.getItem(importMapKey);
if (!options) {
throw new Error('no import options found');
}
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput';
@@ -101,33 +98,6 @@ export default function GeneralSettings() {
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { PortInfo } from 'ontime-types';
import { getServerPort, postServerPort } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Tag from '../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
interface ServerPortForm {
serverPort: number;
}
export default function ServerPortSettings() {
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<ServerPortForm>({
mode: 'onChange',
defaultValues: { serverPort: 4001 },
});
const [pendingRestart, setPendingRestart] = useState<boolean>(false);
const setPort = useCallback((info: PortInfo) => {
reset({ serverPort: info.port });
setPendingRestart(info.pendingRestart);
}, []);
useEffect(() => {
getServerPort()
.then(setPort)
.catch(() => setError('root', { message: 'Failed to load server port' }));
}, [reset, setError, setPort]);
const onSubmit = async (formData: ServerPortForm) => {
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
setError('serverPort', { message: 'Port must be within range 1024 - 65535' });
return;
}
try {
await postServerPort(formData.serverPort);
setPort(await getServerPort());
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const onReset = async () => {
try {
setPort(await getServerPort());
} catch (error) {
setError('root', { message: 'Failed to load server port' });
}
};
return (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='server-port-settings'
>
<Panel.Card>
<Panel.SubHeader>
Server port
<Panel.InlineElements>
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='server-port-settings'
name='server-port-settings-submit'
loading={isSubmitting}
disabled={!isDirty || !isValid || isSubmitting}
variant='primary'
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,14 +1,17 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralSettings from './GeneralSettings';
import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={viewRef}>
<ViewSettings />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
</div>
)}
</>
);
}
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import useAppVersion from '../../common/hooks-query/useAppVersion';
import { isDocker } from '../../externals';
export type SettingsOption = {
id: string;
@@ -18,6 +19,7 @@ const staticOptions = [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__port', label: 'Server Port' },
],
},
{
@@ -102,6 +104,14 @@ export function useAppSettingsMenu() {
() =>
staticOptions.map((option) => ({
...option,
// if we are in docker don't show the port option
secondary:
'secondary' in option
? isDocker && option.id === 'settings'
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
: [...option.secondary]
: undefined,
// if there is an update then highlight the about setting
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
})),
[data],
@@ -42,7 +42,11 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
<div className={style.indicatorNegative} data-active={isOvertime} />
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
<TimerDisplay
className={style.timerDisplay}
time={isWaiting ? timer.secondaryTimer : timer.current}
phase={timer.phase}
/>
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
@@ -1,4 +1,5 @@
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
import { Day } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
@@ -20,7 +21,7 @@ interface OperatorEventProps {
timeStart: number;
duration: number;
delay: number;
dayOffset: number;
dayOffset: Day;
isLinkedToLoaded: boolean;
isSelected: boolean;
isPast: boolean;
@@ -138,7 +139,7 @@ interface OperatorEventScheduleProps {
isPast: boolean;
isSelected: boolean;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
@@ -173,7 +174,7 @@ function OperatorEventSchedule({
interface TimeUntilProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
@@ -12,7 +12,7 @@ import {
import { TbFlagFilled } from 'react-icons/tb';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { Day, EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
@@ -55,7 +55,7 @@ interface RundownEventProps {
isRolling: boolean;
gap: number;
isNextDay: boolean;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
hasTriggers: boolean;
@@ -10,7 +10,7 @@ import {
IoTime,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { Day, EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils';
@@ -44,7 +44,7 @@ interface RundownEventInnerProps {
loaded: boolean;
playback?: Playback;
isRolling: boolean;
dayOffset: number;
dayOffset: Day;
isPast: boolean;
totalGap: number;
isLinkedToLoaded: boolean;
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { IoCheckmarkCircle } from 'react-icons/io5';
import { Day } from 'ontime-types';
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
@@ -14,7 +15,7 @@ interface RundownEventChipProps {
id: string;
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
isPast: boolean;
isLoaded: boolean;
className: string;
@@ -68,7 +69,7 @@ export default function RundownEventChip({
interface EventUntilProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
}
@@ -1,49 +1,19 @@
import { memo, use, useEffect, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { memo, use, useMemo } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from './useTablePermissions';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const preset = use(PresetContext);
// set permissions based on preset
useEffect(() => {
if (preset) {
const fullWrite = preset.options?.write === 'full';
setPermissions({
canChangeMode: preset.options?.write !== '-',
canCreateEntries: fullWrite,
canEditEntries: fullWrite,
canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')),
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
});
} else {
setPermissions({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: sessionScope === 'rw',
});
}
}, [preset, setPermissions]);
const [cuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
@@ -54,7 +24,16 @@ function CuesheetTableWrapper() {
return (
<CuesheetDnd columns={columns}>
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
/>
)}
</CuesheetDnd>
);
}
@@ -0,0 +1,94 @@
import { OntimeView, URLPreset } from 'ontime-types';
import { AppMode } from '../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy, getCuesheetPermissionsPolicy } from '../cuesheet.policies';
describe('getCuesheetPermissionsPolicy()', () => {
test('returns full permissions when there is no preset', () => {
expect(getCuesheetPermissionsPolicy(undefined, true)).toEqual({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: true,
});
});
test('returns run-only permissions when write is disabled', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: '-',
},
};
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
canChangeMode: false,
canCreateEntries: false,
canEditEntries: false,
canFlag: false,
canShare: false,
});
});
test('allows flag changes when write includes flag only', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-flag',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'full',
write: 'flag',
},
};
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
canChangeMode: true,
canCreateEntries: false,
canEditEntries: false,
canFlag: true,
canShare: false,
});
});
test('defaults to full read and write when cuesheet options are absent', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-default',
target: OntimeView.Cuesheet,
search: '',
};
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
expect(policy.canRead('title')).toBe(true);
expect(policy.canWrite('title')).toBe(true);
});
test('column access honors granular permissions and mode', () => {
const preset: URLPreset = {
enabled: true,
alias: 'cuesheet-granular',
target: OntimeView.Cuesheet,
search: '',
options: {
read: 'cue,title',
write: 'title',
},
};
const editPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
const runPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Run);
expect(editPolicy.canRead('cue')).toBe(true);
expect(editPolicy.canRead('duration')).toBe(false);
expect(editPolicy.canWrite('title')).toBe(true);
expect(editPolicy.canWrite('cue')).toBe(false);
expect(runPolicy.canWrite('title')).toBe(false);
});
});
@@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
tableRoot?: 'editor' | 'cuesheet';
}
};
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
const { data, status } = useFlatRundownWithMetadata();
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
return <EmptyPage text='Loading...' />;
}
// control components need different implementations for handling permissions
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
return (
<>
<TableRootSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
{tableRoot === 'editor' ? (
<EditorTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
) : (
<CuesheetTableSettings
columns={allLeafColumns}
cuesheetMode={cuesheetMode}
setCuesheetMode={setCuesheetMode}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<TableVirtuoso
ref={virtuosoRef}
data={data}
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
const fullRead = preset ? preset.options?.read === 'full' : true;
const fullWrite = preset ? preset.options?.write === 'full' : true;
const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set<string>();
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
// helpers to check read/write for a given key
const canRead = (key: string) => fullRead || canReadKeys.has(key);
const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key));
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) {
columnsDef.push({
@@ -1,20 +1,18 @@
import { ReactNode, use } from 'react';
import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import type { Column } from '@tanstack/react-table';
import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import { PresetContext } from '../../../../common/context/PresetContext';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { AppMode } from '../../../../ontimeConfig';
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetPermissions } from '../../useTablePermissions';
@@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -42,19 +42,16 @@ export interface ColumnSettingsProps {
export default function CuesheetTableSettings({
columns,
cuesheetMode,
setCuesheetMode,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: CuesheetTableSettingsProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare);
const preset = use(PresetContext);
const options = usePersistedCuesheetOptions();
const [cuesheetMode, setCuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const toggleCuesheetMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0);
@@ -71,14 +68,20 @@ export default function CuesheetTableSettings({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
{canChangeMode && (
<ToggleGroup
value={[cuesheetMode]}
onValueChange={toggleCuesheetMode}
className={cx([style.group, style.apart])}
>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
)}
{canShare && (
<>
@@ -0,0 +1,65 @@
import { URLPreset } from 'ontime-types';
import { AppMode } from '../../ontimeConfig';
import type { CuesheetPermissions } from './useTablePermissions';
function getPermissionKeys(permission: string | undefined): Set<string> {
return permission ? new Set(permission.split(',')) : new Set<string>();
}
function getCuesheetPermissions(readPermission: string | undefined, writePermission: string | undefined) {
const readKeys = getPermissionKeys(readPermission);
const writeKeys = getPermissionKeys(writePermission);
const fullRead = readPermission == null || readPermission === 'full';
const fullWrite = writePermission == null || writePermission === 'full';
return {
writePermission,
readKeys,
writeKeys,
fullRead,
fullWrite,
};
}
export function getCuesheetPermissionsPolicy(
preset: URLPreset | undefined,
canShareInSession: boolean,
): CuesheetPermissions {
if (!preset) {
return {
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: canShareInSession,
};
}
const { writePermission, writeKeys, fullWrite } = getCuesheetPermissions(
preset?.options?.read,
preset?.options?.write,
);
return {
canChangeMode: writePermission !== '-',
canCreateEntries: fullWrite,
canEditEntries: fullWrite,
canFlag: fullWrite || writeKeys.has('flag'),
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
};
}
export function getCuesheetColumnAccessPolicy(preset: URLPreset | undefined, cuesheetMode: AppMode) {
const { readKeys, writeKeys, fullRead, fullWrite } = getCuesheetPermissions(
preset?.options?.read,
preset?.options?.write,
);
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
return {
canRead: (key: string) => fullRead || readKeys.has(key),
canWrite: (key: string) => modeAllowsWrite && (fullWrite || writeKeys.has(key)),
};
}
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { URLPreset } from 'ontime-types';
import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
import { useCuesheetPermissions } from './useTablePermissions';
/**
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
*/
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void;
} {
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const canShareInSession = sessionScope === 'rw';
const permissions = useMemo(
() => getCuesheetPermissionsPolicy(preset, canShareInSession),
[preset, canShareInSession],
);
const [storedCuesheetMode, setStoredCuesheetMode] = useSessionStorage({
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
const setCuesheetMode = useCallback(
(mode: AppMode) => {
if (!permissions.canChangeMode) {
return;
}
setStoredCuesheetMode(mode);
},
[permissions.canChangeMode, setStoredCuesheetMode],
);
// Keep the shared permissions store aligned with the active preset policy.
useEffect(() => {
setPermissions(permissions);
}, [permissions, setPermissions]);
// Force Run mode whenever the policy forbids mode switching.
useEffect(() => {
if (!permissions.canChangeMode) {
setStoredCuesheetMode((mode) => (mode === AppMode.Run ? mode : AppMode.Run));
}
}, [permissions.canChangeMode, setStoredCuesheetMode]);
return { cuesheetMode, setCuesheetMode };
}
@@ -6,9 +6,11 @@ interface CuesheetPermissionsStore {
canEditEntries: boolean;
canFlag: boolean;
canShare: boolean;
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
setPermissions: (permissions: CuesheetPermissions) => void;
}
export type CuesheetPermissions = Omit<CuesheetPermissionsStore, 'setPermissions'>;
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
canChangeMode: false,
canCreateEntries: false,
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react';
import { OntimeEvent } from 'ontime-types';
import { Day, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
@@ -78,7 +78,7 @@ function TitleListItem({
interface TitleListTimeUntilChipProps {
timeStart: number;
delay: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
isLoaded: boolean;
@@ -1,4 +1,5 @@
import { RefObject } from 'react';
import { Day } from 'ontime-types';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
@@ -20,7 +21,7 @@ interface TimelineEntryProps {
left: number;
status: ProgressStatus;
start: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
title: string;
@@ -119,7 +120,7 @@ export function TimelineEntry({
interface TimelineEntryStatusProps {
delay: number;
start: number;
dayOffset: number;
dayOffset: Day;
totalGap: number;
isLinkedToLoaded: boolean;
status: ProgressStatus;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.2",
"version": "4.5.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.2",
"version": "4.5.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.2",
"version": "4.5.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -1,7 +1,6 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
@@ -10,6 +9,7 @@ import * as httpClient from '../clients/http.client.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -40,9 +40,20 @@ describe('triggerAction()', () => {
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
beforeAll(() => {
vi.mock('../../../stores/EventStore.js', () => {
// Create a small mock store
return {
eventStore: {
poll: vi.fn().mockImplementation(() => makeRuntimeStoreData()),
},
};
});
})
beforeEach(async () => {
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { });
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { });
await deleteAllTriggers();
const oscAutomation = await addAutomation({
@@ -70,26 +81,25 @@ describe('triggerAction()', () => {
});
it('should trigger automations for a given action', () => {
const state = makeRuntimeStateData();
triggerAutomations(TimerLifeCycle.onLoad, state);
triggerAutomations(TimerLifeCycle.onLoad);
expect(oscSpy).toHaveBeenCalledTimes(1);
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStart, state);
triggerAutomations(TimerLifeCycle.onStart);
expect(oscClient.emitOSC).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onFinish, state);
triggerAutomations(TimerLifeCycle.onFinish);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).toHaveBeenCalledTimes(1);
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStop, state);
triggerAutomations(TimerLifeCycle.onStop);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
});
@@ -540,7 +550,7 @@ describe('testConditions()', () => {
describe('for all filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -560,7 +570,7 @@ describe('testConditions()', () => {
});
it('should return false if any filters are false', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -582,7 +592,7 @@ describe('testConditions()', () => {
describe('for any filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
@@ -602,7 +612,7 @@ describe('testConditions()', () => {
});
it('should return true if any filters are true', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'not-test',
@@ -622,7 +632,7 @@ describe('testConditions()', () => {
});
it('should return false if all filters are false', () => {
const mockStore = makeRuntimeStateData({
const mockStore = makeRuntimeStoreData({
clock: 10,
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
});
@@ -3,6 +3,7 @@ import {
isOntimeAction,
isOSCOutput,
LogOrigin,
RuntimeStore,
TimerLifeCycle,
type AutomationFilter,
type AutomationOutput,
@@ -10,29 +11,30 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
import { logger } from '../../classes/Logger.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { eventStore } from '../../stores/EventStore.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
*/
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
export function triggerAutomations(cycle: TimerLifeCycle) {
if (!getAutomationsEnabled()) {
return;
}
const store = eventStore.poll();
let triggers = getAutomationTriggers();
// get triggers from event
if (state.eventNow?.triggers) {
triggers = triggers.concat(state.eventNow.triggers);
if (store.eventNow?.triggers) {
triggers = triggers.concat(store.eventNow.triggers);
}
// note: there are no onStop triggers in event
@@ -51,9 +53,9 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
if (!automation || automation.outputs.length === 0) {
return;
}
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
if (shouldSend) {
send(automation.outputs, state);
send(automation.outputs, store);
}
});
}
@@ -62,7 +64,8 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
* Exposes a method for bypassing the condition check and testing the sending of an output
*/
export function testOutput(payload: AutomationOutput) {
send([payload]);
const store = eventStore.poll();
send([payload], store);
}
/**
@@ -71,7 +74,7 @@ export function testOutput(payload: AutomationOutput) {
export function testConditions(
filters: AutomationFilter[],
filterRule: FilterRule,
state: Partial<RuntimeState>,
store: Partial<RuntimeStore>,
): boolean {
if (filters.length === 0) {
return true;
@@ -86,7 +89,7 @@ export function testConditions(
function evaluateCondition(filter: AutomationFilter): boolean {
const { field, operator, value } = filter;
const lowerCasedValue = value.toLowerCase();
const fieldValue = getPropertyFromPath(field, state);
const fieldValue = getPropertyFromPath(field, store);
// if value is empty string, the user could be meaning to check if the value does not exist
// we use loose equality to be able to check for converted values (eg '10' == 10)
@@ -115,13 +118,12 @@ export function testConditions(
* Handles preparing and sending of the data
* Returns a boolean indicating whether a message was sent
*/
function send(output: AutomationOutput[], state?: RuntimeState) {
const stateSnapshot = state ?? getState();
function send(output: AutomationOutput[], store: RuntimeStore) {
output.forEach((payload) => {
if (isOSCOutput(payload) && !isOntimeCloud) {
emitOSC(payload, stateSnapshot);
emitOSC(payload, store);
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, stateSnapshot);
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
} else {
@@ -1,20 +1,20 @@
import { HTTPOutput, LogOrigin } from 'ontime-types';
import { DeepReadonly } from 'ts-essentials';
import { HTTPOutput, LogOrigin, RuntimeStore } from 'ontime-types';
import { logger } from '../../../classes/Logger.js';
import type { RuntimeState } from '../../../stores/runtimeState.js';
import { parseTemplateNested } from '../automation.utils.js';
/**
* Expose possibility to send a message using HTTP protocol
*/
export function emitHTTP(output: HTTPOutput, state: RuntimeState) {
const url = preparePayload(output, state);
export function emitHTTP(output: HTTPOutput, store: DeepReadonly<RuntimeStore>) {
const url = preparePayload(output, store);
emit(url);
}
/** Parses the state and prepares payload to be emitted */
function preparePayload(output: HTTPOutput, state: RuntimeState): string {
function preparePayload(output: HTTPOutput, state: DeepReadonly<RuntimeStore>): string {
const parsedUrl = parseTemplateNested(output.url, state);
return parsedUrl;
}
@@ -1,29 +1,29 @@
import { LogOrigin, OSCOutput } from 'ontime-types';
import { LogOrigin, OSCOutput, RuntimeStore } from 'ontime-types';
import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min';
import * as dgram from 'node:dgram';
import { logger } from '../../../classes/Logger.js';
import { type RuntimeState } from '../../../stores/runtimeState.js';
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
import { DeepReadonly } from 'ts-essentials';
const udpClient = dgram.createSocket('udp4');
/**
* Expose possibility to send a message using OSC protocol
*/
export function emitOSC(output: OSCOutput, state: RuntimeState) {
const message = preparePayload(output, state);
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
const message = preparePayload(output, store);
emit(output.targetIP, output.targetPort, message);
}
/** Parses the state and prepares payload to be emitted */
function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput {
function preparePayload(output: OSCOutput, store: DeepReadonly<RuntimeStore>): OscPacketInput {
// check for templates in the address
const parsedAddress = parseTemplateNested(output.address, state);
const parsedAddress = parseTemplateNested(output.address, store);
// check for templates in the arguments
const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined;
const parsedArguments = output.args ? parseTemplateNested(output.args, store) : undefined;
// check we have the correct type
const oscArguments = stringToOSCArgs(parsedArguments);
return { address: parsedAddress, args: oscArguments };
+29 -3
View File
@@ -10,6 +10,9 @@ import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import * as v3 from './migration/db.migration.v3.js';
import * as v4 from './migration/db.migration.v4.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
import { getErrorMessage } from 'ontime-utils';
type ParsingError = {
context: string;
@@ -21,21 +24,45 @@ type ParsingError = {
* @param {object} jsonData - project file to be parsed
* @returns {object} parsed object
*/
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
export function parseDatabaseModel(
jsonData: Partial<DatabaseModel>,
initialLoad = false,
): {
data: DatabaseModel;
errors: ParsingError[];
migrated: boolean;
} {
let migrated = false;
let migratedData = jsonData;
const errors: ParsingError[] = [];
if (v3.shouldUseThisMigration(jsonData)) {
try {
migrated = true;
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
migratedData = v3.migrateAllData(jsonData);
} catch (_error) {
} catch (error) {
logger.error(LogOrigin.Server, 'Failed to migrate the data');
errors.push({ context: 'v3 migration', message: getErrorMessage(error) });
migratedData = jsonData;
};
}
if (v4.shouldMigrateServerPort(migratedData)) {
try {
migrated = true;
logger.warning(LogOrigin.Server, 'Migrating serverPort from settings to AppState');
const { db, serverPort } = v4.migrateServerPort(migratedData);
if (initialLoad && serverPort) portManager.migratePortFromProjectFile(serverPort);
migratedData = db;
} catch (error) {
logger.error(LogOrigin.Server, 'Failed to migrate serverPort');
errors.push({ context: 'v4 migration', message: getErrorMessage(error) });
migratedData = jsonData;
}
}
@@ -43,7 +70,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
// this may throw
const settings = parseSettings(migratedData);
const errors: ParsingError[] = [];
const makeEmitError = (context: string) => (message: string) => {
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
errors.push({ context, message });
@@ -16,6 +16,7 @@ import {
Trigger,
URLPreset,
ViewSettings,
Day,
} from 'ontime-types';
import {
customFieldLabelToKey,
@@ -26,7 +27,6 @@ import {
} from 'ontime-utils';
import { is } from '../../../utils/is.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import { getPartialProject } from '../../../models/dataModel.js';
// the methodology of the migrations is to just change the necessary keys to match with v4
@@ -69,12 +69,12 @@ type old_Settings = {
* migrates a settings from v3 to v4
* - update the version number
*/
export function migrateSettings(jsonData: object): Settings | undefined {
export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined {
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
return { version: ONTIME_VERSION, serverPort, editorKey, operatorKey, timeFormat, language };
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
}
}
@@ -394,7 +394,7 @@ export function migrateRundown(
// !==== RUNTIME METADATA ====! //
revision: -1,
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
});
} else if (entry.type === 'block') {
@@ -0,0 +1,35 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { is } from '../../../utils/is.js';
export function shouldMigrateServerPort(jsonData: object): boolean {
return (
is.objectWithKeys(jsonData, ['settings']) &&
is.object(jsonData.settings) &&
is.objectWithKeys(jsonData.settings, ['version', 'serverPort']) &&
typeof jsonData.settings.version === 'string' &&
jsonData.settings.version.split('.')[0] === '4' &&
Number(jsonData.settings.version.split('.')[1]) <= 4
);
}
export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
db: Partial<DatabaseModel>;
serverPort?: number;
} {
const db = structuredClone(jsonData);
const settings = db.settings as Partial<Settings & { serverPort: number }>;
const editorKey = settings?.editorKey;
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
const version = '4.5.0';
db.settings = {
version,
editorKey,
operatorKey,
timeFormat,
language,
app: 'ontime',
} as Settings;
return { db, serverPort: settings?.serverPort };
}
@@ -1,6 +1,7 @@
import {
AutomationSettings,
CustomFields,
DatabaseModel,
EndAction,
OntimeView,
ProjectData,
@@ -14,8 +15,7 @@ import {
ViewSettings,
} from 'ontime-types';
import * as v3 from './db.migration.v3.js';
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
import * as v4 from './db.migration.v4.js';
describe('v3 to v4', () => {
const oldDb = {
@@ -175,8 +175,8 @@ describe('v3 to v4', () => {
};
test('migrate settings', () => {
const expectSettings: Settings = {
version: ONTIME_VERSION,
const expectSettings: Settings & { serverPort: number } = {
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
@@ -493,3 +493,101 @@ describe('v3 to v4', () => {
});
});
});
describe('v4 remove server port', () => {
const demoDb = {
rundowns: {},
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
url: 'www.github.com/cpvalente/ontime',
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
logo: null,
custom: [],
},
settings: {
app: 'ontime',
version: '4.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ff7300',
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#ffa528',
},
customFields: {
song: {
label: 'Song',
type: 'text',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'text',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'clock',
target: 'timer',
search:
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
{
enabled: true,
alias: 'minimal',
target: 'timer',
search:
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
},
],
automation: {
enabledAutomations: false,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
},
};
it('should migrate if server port exists', () => {
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
});
test('should not migrate if the version is newer', () => {
expect(
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
).toBeFalsy();
});
test('should not migrate if there is no server port', () => {
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
});
it('remove server port from project', () => {
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
expect(result).not.toHaveProperty('settings.serverPort');
expect(serverPort).toBe(4001);
expect(result.automation).toMatchObject(demoDb.automation);
expect(result.customFields).toMatchObject(demoDb.customFields);
expect(result.project).toMatchObject(demoDb.project);
expect(result.rundowns).toMatchObject(demoDb.rundowns);
expect(result.settings).toMatchObject({
app: 'ontime',
version: '4.5.0',
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
});
});
@@ -16,6 +16,7 @@ import {
isOntimeMilestone,
OntimeMilestone,
OntimeGroup,
Day,
} from 'ontime-types';
import {
isObjectEmpty,
@@ -281,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
sanitiseCustomFields(customFields, currentEntry);
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
currentEntry.dayOffset = processedData.totalDays;
currentEntry.dayOffset = processedData.totalDays as Day;
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
currentEntry.parent = childOfGroup;
@@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js';
import { password, routerPrefix } from '../../externals.js';
import { hashPassword } from '../../utils/hash.js';
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
const startedAt = new Date();
@@ -37,7 +38,8 @@ export async function getSessionStats(): Promise<SessionStats> {
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = getDataProvider().getSettings();
const { version } = getDataProvider().getSettings();
const { port } = portManager.getPort();
// get nif and inject localhost
const ni = getNetworkInterfaces();
@@ -46,7 +48,7 @@ export async function getInfo(): Promise<GetInfo> {
return {
networkInterfaces: ni,
version,
serverPort,
serverPort: port,
publicDir: publicDir.root,
};
}
@@ -12,7 +12,6 @@ describe('parseSettings()', () => {
expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
return {
version: defaultSettings.version,
serverPort: data.settings.serverPort ?? defaultSettings.serverPort,
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
@@ -1,25 +1,29 @@
import express from 'express';
import { matchedData } from 'express-validator';
import type { Request, Response } from 'express';
import { deepEqual } from 'fast-equals';
import express from "express";
import { matchedData } from "express-validator";
import type { Request, Response } from "express";
import { deepEqual } from "fast-equals";
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types";
import { getErrorMessage, obfuscate } from "ontime-utils";
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { isDocker } from '../../setup/environment.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import {
validateSettings,
validateWelcomeDialog,
validateServerPort,
} from "./settings.validation.js";
import { getDataProvider } from "../../classes/data-provider/DataProvider.js";
import * as appState from "../../services/app-state-service/AppStateService.js";
import { sendRefetch } from "../../adapters/WebsocketAdapter.js";
import { portManager } from "../../classes/port-manager/PortManager.js";
export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => {
router.post("/welcomedialog", validateWelcomeDialog, async (req: Request, res: Response) => {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).json({ show });
});
router.get('/', (_req: Request, res: Response<Settings>) => {
router.get("/", (_req: Request, res: Response<Settings>) => {
const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
res.status(200).json(obfuscatedSettings);
});
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
router.post(
"/",
validateSettings,
async (req: Request, res: Response<Settings | ErrorResponse>) => {
try {
const data = matchedData<Settings>(req);
const settings = getDataProvider().getSettings();
data.version = settings.version;
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
sendRefetch(RefetchKey.Settings);
}
res.status(200).json(data);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
},
);
router.get("/serverport", (_req: Request, res: Response<PortInfo | ErrorResponse>) => {
try {
const data = matchedData<Settings>(req);
const settings = getDataProvider().getSettings();
if (isDocker && settings.serverPort !== data.serverPort) {
res.status(403).json({ message: 'Can`t change port when running inside docker' });
return;
}
data.version = settings.version;
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
sendRefetch(RefetchKey.Settings);
}
res.status(200).json(data);
const { port, pendingRestart } = portManager.getPort();
res.status(200).json({ port, pendingRestart });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
res.status(500).json({ message });
}
});
router.post(
"/serverport",
validateServerPort,
async (req: Request, res: Response<PortInfo | ErrorResponse>) => {
try {
const { serverPort } = matchedData<{ serverPort: number }>(req);
portManager.changePort(serverPort);
const { port, pendingRestart } = portManager.getPort();
res.status(200).json({ port, pendingRestart });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
},
);
@@ -26,7 +26,11 @@ export const validateSettings = [
pinValidator('operatorKey'),
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(),
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
requestValidationFunction,
];
export const validateServerPort = [
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
requestValidationFunction,
];
@@ -43,18 +43,24 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
try {
const alias = req.params.alias;
const currentPresets = getDataProvider().getUrlPresets();
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
if (!existingPreset) {
throw new Error(`Preset with alias ${alias} does not exist.`);
}
const updatedPreset: URLPreset = {
enabled: req.body.enabled,
alias: req.body.alias,
target: req.body.target,
search: req.body.search,
options: req.body.options ?? existingPreset.options,
};
if (alias !== updatedPreset.alias) {
throw new Error('Changing alias is not permitted');
}
const currentPresets = getDataProvider().getUrlPresets();
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
// Update the URL presets in the data provider
+6 -8
View File
@@ -46,7 +46,8 @@ import { oscServer } from './adapters/OscAdapter.js';
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './setup/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getNetworkInterfaces } from './utils/network.js';
import { portManager } from './classes/port-manager/PortManager.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -176,16 +177,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
*/
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
const settings = getDataProvider().getSettings();
const { serverPort: desiredPort } = settings;
expressServer = http.createServer(app);
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
expressServer = http.createServer(app);
const resultPort = await portManager.attachServer(expressServer);
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
socket.init(expressServer, showWelcome, prefix);
/**
@@ -278,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
await portManager.shutdown();
}
expressServer?.close();
@@ -72,14 +72,12 @@ describe('safeMerge', () => {
it('merges the settings key', () => {
const mergedData = safeMerge(baseDb, {
settings: {
serverPort: 3000,
language: 'pt',
version: 'new',
} as Settings,
});
expect(mergedData.settings).toStrictEqual({
version: 'new',
serverPort: 3000,
operatorKey: null,
editorKey: null,
timeFormat: baseDb.settings.timeFormat,
@@ -0,0 +1,140 @@
import { Server } from "http";
import { config } from "../../setup/config.js";
import { envPort, isDocker, isOntimeCloud } from "../../setup/environment.js";
import * as appState from "../../services/app-state-service/AppStateService.js";
import { logger } from "../Logger.js";
import { LogOrigin, MaybeNumber } from "ontime-types";
import { isAddressInfo, isPortInUseError } from "./PortManager.utils.js";
import { shouldCrashDev } from "../../utils/development.js";
class PortManager {
private static port: number;
private static pendingRestart = false;
private static newPort: MaybeNumber = null;
public getPort() {
return {
port: PortManager.port,
pendingRestart: PortManager.pendingRestart,
newPort: PortManager.newPort,
};
}
/**
* marks that a port change is requested and will be applied on next restart
* @throws if trying to change port inside docker
* @param newPort
* @returns {void}
*/
public changePort(newPort: number): void {
if (isDocker) throw new Error("Can not change port when running inside docker");
if (PortManager.port === newPort) return;
PortManager.newPort = newPort;
PortManager.pendingRestart = true;
}
public migratePortFromProjectFile(port: number) {
shouldCrashDev(
PortManager.port !== undefined,
"this function should not be called after `PortManager.port` has been initialized",
);
appState.setServerPort(port);
}
public async shutdown() {
if (PortManager.pendingRestart && PortManager.newPort != null) {
logger.info(
LogOrigin.Server,
`A port change to ${PortManager.newPort} is pending and will take effect on next start`,
);
await appState.setServerPort(PortManager.newPort);
}
}
/**
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to a random port assigned by the OS
* @param {http.Server} server http server object
* @returns {Promise<number>} the resulting port number
* @throws any other server errors will result in a throw
*/
public async attachServer(server: Server): Promise<number> {
if (isOntimeCloud) {
PortManager.port = await this.forceCloudPort(server);
} else {
PortManager.port =
this.parsePort(envPort) || (await appState.getServerPort()) || config.defaultServerPort;
PortManager.port = await this.tryServerPort(server);
}
await appState.setServerPort(PortManager.port);
return PortManager.port;
}
private parsePort(port: string | undefined) {
if (typeof port !== "string") return null;
if (port === "") return null;
const maybePort = Number(port);
if (isNaN(maybePort)) return null;
return maybePort;
}
private async tryServerPort(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", (error) => {
// we should only move ports if we are in a desktop environment
if (isDocker) {
reject(error);
return;
}
if (!isPortInUseError(error)) {
reject(error);
return;
}
// if we get an address in use error, we will try to open the server in an ephemeral port
// port 0 will assign an ephemeral port
server.listen(0, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
logger.error(
LogOrigin.Server,
`Failed to open the desired port: ${PortManager.port} \nMoved to an Ephemeral port: ${address.port}`,
true,
);
resolve(address.port);
});
});
server.listen(PortManager.port, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
resolve(address.port);
});
});
}
private forceCloudPort(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", (error) => {
reject(error);
});
server.listen(config.defaultServerPort, "0.0.0.0", () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error("Unknown port type, unable to proceed"));
return;
}
resolve(address.port);
});
});
}
}
export const portManager = new PortManager();
@@ -0,0 +1,15 @@
import { AddressInfo } from 'net';
/**
* Checks whether a given error is a port in use error
*/
export function isPortInUseError(err: Error): boolean {
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
}
/**
* Guard verifies that the given address is a usable AddressInfo object
*/
export function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
return typeof address === 'object' && address !== null;
}
@@ -0,0 +1,263 @@
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import { Duration, Instant, TimeOfDay } from 'ontime-types';
import { timeNow } from '../../../utils/time.js';
import * as timeCore from '../timeCore.js';
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
// TZ is set to Europe/Copenhagen in vitest.global-setup.ts
// Copenhagen is UTC+1 (CET) in winter and UTC+2 (CEST) in summer
describe('toTimeofDay() converts an instant to local milliseconds since midnight', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('produces the correct local time for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBe(timeNow());
});
it('returns a value in the range [0, dayInMs)', () => {
vi.setSystemTime('2025-06-15T23:59:59.999Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(dayInMs);
});
describe('handles DST transitions in Europe/Copenhagen', () => {
it('produces CET time just before spring forward', () => {
// 2025-03-30 at 02:00 CET clocks jump to 03:00 CEST
// UTC 00:58:18 → Copenhagen CET (UTC+1) → local 01:58:18
vi.setSystemTime('2025-03-30T00:58:18Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('01:58:18');
});
it('produces CEST time just after spring forward', () => {
// UTC 01:00:00 → Copenhagen CEST (UTC+2) → local 03:00:00
// 02:00 local does not exist, clocks skip to 03:00
vi.setSystemTime('2025-03-30T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('03:00:00');
});
it('produces CEST time just before fall back', () => {
// 2025-10-26 at 03:00 CEST clocks fall back to 02:00 CET
// UTC 00:59:59 → Copenhagen CEST (UTC+2) → local 02:59:59
vi.setSystemTime('2025-10-26T00:59:59Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:59:59');
});
it('produces CET time just after fall back', () => {
// UTC 01:00:00 → Copenhagen CET (UTC+1) → local 02:00:00
vi.setSystemTime('2025-10-26T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:00:00');
});
});
});
describe('toInstant() converts a time of day back to an instant anchored to a reference day', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('roundtrips through toTimeofDay for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
describe('roundtrips through DST transitions in Europe/Copenhagen', () => {
it('roundtrips just before spring forward', () => {
vi.setSystemTime('2025-03-30T00:58:18Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after spring forward', () => {
vi.setSystemTime('2025-03-30T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just before fall back', () => {
vi.setSystemTime('2025-10-26T00:59:59Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after fall back', () => {
vi.setSystemTime('2025-10-26T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
});
});
describe('timeSince() returns the duration elapsed since a past point', () => {
it('measures elapsed time between two instants', () => {
const start = 1000 as Instant;
const end = 5000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(4000);
});
it('returns negative when the reference is in the future', () => {
const start = 5000 as Instant;
const end = 1000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(-4000);
});
});
describe('timeUntil() returns the duration until a future point', () => {
it('measures time remaining until a future instant', () => {
const current = 1000 as Instant;
const target = 5000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(4000);
});
it('returns negative when the target is in the past', () => {
const current = 5000 as Instant;
const target = 1000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(-4000);
});
});
describe('addDuration() moves a point in time by a duration', () => {
it('moves an instant forward', () => {
const instant = 1000 as Instant;
const duration = 500 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(1500);
});
it('moves backward with a negative duration', () => {
const instant = 1000 as Instant;
const duration = -300 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(700);
});
it('moves by the sum of multiple durations', () => {
const instant = 1000 as Instant;
const durations = [500, -300, 50] as Duration[];
expect(timeCore.addDuration(instant, durations)).toBe(1250);
});
it('keeps the instant unchanged with an empty duration list', () => {
const instant = 1000 as Instant;
expect(timeCore.addDuration(instant, [])).toBe(1000);
});
});
describe('elapsedTime() calculates duration between two times of day', () => {
it('calculates elapsed time on the same day', () => {
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
});
it('calculates elapsed time when crossing midnight (overnight)', () => {
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
// From 23:50 to 00:21 = 31 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
});
it('returns 0 when start and clock are the same', () => {
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
expect(timeCore.elapsedTime(time, time)).toBe(0);
});
it('calculates correctly for just after midnight', () => {
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
// From 23:59 to 00:01 = 2 minutes
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
});
});
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
it('returns 0 when current epoch equals start epoch', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const epoch = timeCore.now();
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
});
it('returns 0 when less than one day has elapsed', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
});
it('returns 1 when crossing midnight once', () => {
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
it('returns 2 when crossing midnight twice', () => {
vi.setSystemTime('2025-01-15T10:00:00Z');
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
});
it('handles overnight start correctly', () => {
// Copenhagen is UTC+1 in winter
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
const startEpoch = timeCore.now();
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
const currentEpoch = timeCore.now();
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
return Date.now() as Instant;
}
/**
* Converts an instant to milliseconds since midnight in the local timezone
* - Accounts for the system's timezone offset including DST
* - Result is always in the range [0, dayInMs)
*/
export function toTimeOfDay(instant: Instant): TimeOfDay {
const tzOffset = new Date(instant).getTimezoneOffset() * MILLIS_PER_MINUTE;
return ((((instant - tzOffset) % dayInMs) + dayInMs) % dayInMs) as TimeOfDay;
}
/**
* Returns the current time of day in milliseconds since midnight
*/
export function timeOfDayNow(): TimeOfDay {
return toTimeOfDay(now());
}
/**
* Converts a time of day to an instant anchored to the same day as the reference
* - Uses the reference instant to determine which calendar day to anchor to
*/
export function toInstant(clock: TimeOfDay, reference: Instant): Instant {
const referenceClock = toTimeOfDay(reference);
const dayStart = reference - referenceClock;
return (dayStart + clock) as Instant;
}
/**
* Returns the duration elapsed since a past instant
* Result is positive when 'since' is before 'now'
*/
export function timeSince(now: Instant, since: Instant): Duration {
return (now - since) as Duration;
}
/**
* Returns the duration remaining until a future instant
* Result is positive when 'until' is after 'now'
*/
export function timeUntil(now: Instant, until: Instant): Duration {
return (until - now) as Duration;
}
/**
* Moves an instant forward or backward by a duration
* Use a negative duration to move backward
*/
export function addDuration(instant: Instant, duration: Duration | Duration[]): Instant {
const totalDuration = Array.isArray(duration) ? duration.reduce((total, current) => total + current, 0) : duration;
return (instant + totalDuration) as Instant;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
*/
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
return (current < start ? current + dayInMs - start : current - start) as Duration;
}
/**
* Calculates the number of full days elapsed since a start epoch
* Uses the start time-of-day to determine day boundaries
*/
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
const startClock = toTimeOfDay(startEpoch);
const elapsedMs = currentEpoch - startEpoch;
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
}
-1
View File
@@ -25,7 +25,6 @@ const dbModel: DatabaseModel = {
},
settings: {
version: ONTIME_VERSION,
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
+7 -8
View File
@@ -1,4 +1,4 @@
import { DatabaseModel, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { DatabaseModel, Day, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundowns: {
@@ -66,7 +66,7 @@ export const demoDb: DatabaseModel = {
note: 'Music plays, holding slide on screens',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1',
parent: '7eaf99',
@@ -109,7 +109,7 @@ export const demoDb: DatabaseModel = {
note: 'Emma Thompson',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1.1',
parent: '7eaf99',
@@ -141,7 +141,7 @@ export const demoDb: DatabaseModel = {
note: 'Liam Carter, Sophia Patel + PowerPoint',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '1.2',
parent: '7eaf99',
@@ -199,7 +199,7 @@ export const demoDb: DatabaseModel = {
note: 'Buffet in lobby',
colour: '#779BE7',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '2.1',
parent: 'f60403',
@@ -257,7 +257,7 @@ export const demoDb: DatabaseModel = {
note: 'Ethan Brooks + PowerPoint + Video playback',
colour: '#77C785',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '3.1',
parent: '6b0edb',
@@ -289,7 +289,7 @@ export const demoDb: DatabaseModel = {
note: 'Lucas Bennett',
colour: '#FFCC78',
delay: 0,
dayOffset: 0,
dayOffset: 0 as Day,
gap: 0,
cue: '3.2',
parent: '6b0edb',
@@ -336,7 +336,6 @@ export const demoDb: DatabaseModel = {
},
settings: {
version: '-',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -709,6 +709,44 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
expect(state).toStrictEqual(expected);
});
it('should recognise a playing event where its schedule spans over midnight, even with previous events', async () => {
vi.useFakeTimers();
await initRundown(
makeRundown({
order: ['1', '2'],
entries: {
'1': {
...mockEvent,
id: '1',
timeStart: 10 * MILLIS_PER_HOUR,
timeEnd: 11 * MILLIS_PER_HOUR,
duration: 1 * MILLIS_PER_HOUR,
},
'2': {
...mockEvent,
id: '2',
timeStart: 11 * MILLIS_PER_HOUR,
timeEnd: 2 * MILLIS_PER_HOUR,
duration: 14 * MILLIS_PER_HOUR
},
},
}),
{},
);
vi.runAllTimers();
vi.useRealTimers();
const { rundown, metadata } = rundownCache.get();
const now = 1 * MILLIS_PER_HOUR;
const expected = {
event: rundown.entries['2'],
index: 1,
};
const state = loadRoll(rundown, metadata, now);
expect(state).toStrictEqual(expected);
});
it('if the start time is the day after end time, and both are later than now', async () => {
vi.useFakeTimers();
await initRundown(
@@ -1,5 +1,5 @@
import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
findDayOffset,
@@ -7,6 +7,7 @@ import {
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
normaliseEndTime,
skippedOutOfEvent,
} from '../timerUtils.js';
@@ -537,6 +538,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
});
});
describe('hasCrossedMidnight()', () => {
it('returns true when clock wraps from late to early', () => {
const previous = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
const current = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
expect(hasCrossedMidnight(previous, current)).toBe(true);
});
it('returns false when clock moves forward on the same day', () => {
const previous = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
const current = (10 * MILLIS_PER_HOUR + 5 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:05
expect(hasCrossedMidnight(previous, current)).toBe(false);
});
it('returns false when clock value is unchanged', () => {
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
expect(hasCrossedMidnight(time, time)).toBe(false);
});
});
describe('skippedOutOfEvent()', () => {
const testSkipLimit = 32;
it('does not consider an event end as a skip', () => {
@@ -693,6 +713,44 @@ describe('skippedOutOfEvent()', () => {
state.clock -= testSkipLimit + 1;
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
});
it('handles events that cross midnight', () => {
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
const clock = 14 * MILLIS_PER_MINUTE + 47 * MILLIS_PER_SECOND; // 00:14:47
const previousTime = clock - 1021; // ~1 second ago
const state = {
clock,
timer: {
expectedFinish,
startedAt,
},
} as RuntimeState;
// Even though clock < startedAt numerically (00:14 < 23:50),
// we're inside the overnight event, so this should NOT be a skip
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(false);
});
it('correctly detects skip out of an event that crosses midnight', () => {
// Event 23:50-01:50, clock jumps to 02:00 (outside event)
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
const previousTime = 1 * MILLIS_PER_HOUR + 49 * MILLIS_PER_MINUTE; // 01:49
const clock = 2 * MILLIS_PER_HOUR; // 02:00 (outside event)
const state = {
clock,
timer: {
expectedFinish,
startedAt,
},
} as RuntimeState;
// Clock jumped from 01:49 to 02:00 (11 min skip) and is now outside the event
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(true);
});
});
test('normaliseEndTime()', () => {
@@ -10,6 +10,7 @@ interface AppState {
projectName?: string;
rundownId?: string;
showWelcomeDialog?: boolean;
serverPort?: number;
}
const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -61,3 +62,13 @@ export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
await config.write();
return show;
}
export async function getServerPort(): Promise<number | undefined> {
await config.read();
return config.data.serverPort;
}
export async function setServerPort(port: number): Promise<void> {
config.data.serverPort = port;
await config.write();
}
@@ -1,9 +1,9 @@
import { MessageState, runtimeStorePlaceholder } from 'ontime-types';
import { withoutUndefinedValues } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials';
import { throttle } from '../../utils/throttle.js';
import type { StoreGetter, PublishFn } from '../../stores/EventStore.js';
import { withoutUndefinedValues } from '../../../../../packages/utils/src/common/objectUtils.js';
/**
* Create a throttled version of the set function
@@ -183,7 +183,10 @@ export async function initialiseProject(): Promise<string> {
}
try {
const projectName = await loadProjectFile(lastLoaded.projectName, lastLoaded.rundownId);
const projectName = await loadProjectFile(lastLoaded.projectName, {
rundownId: lastLoaded.rundownId,
initialLoad: true,
});
return projectName;
} catch (error) {
// if we are here, most likely the json parsing failed and the file is corrupt
@@ -207,7 +210,10 @@ export async function initialiseProject(): Promise<string> {
* @throws
* @param fileName file name of the project including the extension
*/
export async function loadProjectFile(fileName: string, rundownId?: string): Promise<string> {
export async function loadProjectFile(
fileName: string,
options?: { rundownId?: string; initialLoad?: boolean },
): Promise<string> {
const filePath = doesProjectExist(fileName);
if (filePath === null) {
throw new Error('Project file not found');
@@ -215,7 +221,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
const result = parseDatabaseModel(fileData, options?.initialLoad);
let parsedFileName = fileName;
if (result.migrated) {
@@ -226,7 +232,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
}
const projectName = await loadProject(result.data, parsedFileName, rundownId);
const projectName = await loadProject(result.data, parsedFileName, options?.rundownId);
return projectName;
}
@@ -1,4 +1,4 @@
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
import { Playback, MaybeString, MaybeNumber, Maybe, Instant } from 'ontime-types';
export type RestorePoint = {
playback: Playback;
@@ -7,6 +7,6 @@ export type RestorePoint = {
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
startEpoch: MaybeNumber;
startEpoch: Maybe<Instant>;
currentDay: MaybeNumber;
};
+26 -9
View File
@@ -1,4 +1,4 @@
import { dayInMs } from 'ontime-utils';
import { checkIsNow, dayInMs } from 'ontime-utils';
import { MaybeNumber, PlayableEvent, Rundown } from 'ontime-types';
import { normaliseEndTime } from './timerUtils.js';
@@ -36,11 +36,6 @@ export function loadRoll(
continue;
}
// we check if event crosses midnight
if (event.timeStart > event.timeEnd) {
daySpan++;
}
const correctedDays = dayInMs * daySpan;
const correctedStart = event.timeStart + correctedDays;
const correctedEnd = event.timeEnd + correctedDays;
@@ -49,13 +44,18 @@ export function loadRoll(
* there are 3 possible states for an event
* 1. event is already finished
* 2. event is running
* 3. event is in the future
* 3. event is running (midnight edge case)
* 4. event is in the future
*/
// 1. event is already finished
// when does the event end (handle midnight)
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
if (normalEnd <= timeNow) {
// keep track of day progress
if (event.timeStart > event.timeEnd) {
daySpan++;
}
continue;
}
@@ -66,8 +66,25 @@ export function loadRoll(
return { event, index: getTimedIndexFromPlayableIndex(metadata, i) };
}
// 3. event will run in the future
// we set the isPending flag to indicate that the event is currently playing
// 3. check if an overnight event that comes later is currently playing
for (let j = i + 1; j < metadata.playableEventOrder.length; j++) {
const futureEventId = metadata.playableEventOrder[j];
if (!futureEventId) continue;
const futureEvent = rundown.entries[futureEventId] as PlayableEvent;
if (!futureEvent || futureEvent.duration === 0) continue;
const crossesMidnight = futureEvent.timeStart > futureEvent.timeEnd;
if (!crossesMidnight) continue;
// check if timeNow is inside this overnight event
const isRunning = checkIsNow(futureEvent.timeStart, futureEvent.timeEnd, timeNow);
if (isRunning) {
return { event: futureEvent, index: getTimedIndexFromPlayableIndex(metadata, j) };
}
}
// 4. event will run in the future
return { event, index: getTimedIndexFromPlayableIndex(metadata, i), isPending: true };
}
@@ -77,11 +77,11 @@ class RuntimeService {
if (timerPhaseChanged) {
if (newState.timer.phase === TimerPhase.Warning) {
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onWarning, newState);
triggerAutomations(TimerLifeCycle.onWarning);
});
} else if (newState.timer.phase === TimerPhase.Danger) {
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onDanger, newState);
triggerAutomations(TimerLifeCycle.onDanger);
});
}
}
@@ -96,7 +96,7 @@ class RuntimeService {
} else if (hasTimerFinished) {
// if the timer has finished, we need to load next and keep rolling
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onFinish, newState);
triggerAutomations(TimerLifeCycle.onFinish);
});
this.handleLoadNext();
this.rollLoaded(newState.offset);
@@ -116,7 +116,7 @@ class RuntimeService {
// 3. find if we need to process actions related to the timer finishing
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onFinish, newState);
triggerAutomations(TimerLifeCycle.onFinish);
});
// handle end action if there was a timer playing
@@ -134,7 +134,7 @@ class RuntimeService {
const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current);
if (shouldUpdateTimer) {
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onUpdate, newState);
triggerAutomations(TimerLifeCycle.onUpdate);
});
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
@@ -144,7 +144,7 @@ class RuntimeService {
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
if (shouldUpdateClock) {
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onClock, newState);
triggerAutomations(TimerLifeCycle.onClock);
});
this.lastIntegrationClockUpdate = newState.clock;
@@ -209,10 +209,9 @@ class RuntimeService {
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
const newState = runtimeState.getState();
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onLoad, newState);
triggerAutomations(TimerLifeCycle.onLoad);
});
}
return success;
@@ -447,7 +446,7 @@ class RuntimeService {
if (didStart) {
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart);
});
}
return didStart;
@@ -500,7 +499,7 @@ class RuntimeService {
const newState = runtimeState.getState();
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerAutomations(TimerLifeCycle.onPause, newState);
triggerAutomations(TimerLifeCycle.onPause);
});
}
@@ -520,7 +519,7 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onStop, newState);
triggerAutomations(TimerLifeCycle.onStop);
});
return true;
@@ -548,7 +547,14 @@ class RuntimeService {
const metadata = getRundownMetadata();
try {
runtimeState.roll(rundown, metadata, offset);
const result = runtimeState.roll(rundown, metadata, offset);
if (result.didStart) {
const newState = runtimeState.getState();
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart);
});
}
} catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`);
}
@@ -577,14 +583,14 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStop, previousState);
triggerAutomations(TimerLifeCycle.onLoad, newState);
triggerAutomations(TimerLifeCycle.onLoad);
});
}
if (result.didStart) {
process.nextTick(() => {
triggerReportEntry(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart, newState);
triggerAutomations(TimerLifeCycle.onStart);
});
}
} catch (error) {
+22 -12
View File
@@ -1,5 +1,5 @@
import { MaybeNumber, TimerPhase } from 'ontime-types';
import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { checkIsNow, dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js';
@@ -8,6 +8,15 @@ import type { RuntimeState } from '../stores/runtimeState.js';
*/
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
/**
* Checks whether the local wall clock wrapped into a new day
* This currently uses a simple wrap heuristic and is centralized
* so day-boundary behavior can be evolved in one place later.
*/
export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boolean {
return previous > current;
}
/**
* Calculates expected finish time of a running timer
* @param {RuntimeState} state runtime state
@@ -98,14 +107,15 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
const { startedAt, expectedFinish } = state.timer;
const { clock } = state;
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
const isInsideEvent = checkIsNow(startedAt, expectedFinish, clock);
if (isInsideEvent) return false;
const timeDifference = previousTime - adjustedClock;
const hasSkipped = Math.abs(timeDifference) > skipLimit;
const adjustedExpectedFinish = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
// we are outside the event, but we need to check if we skipped or just finished normally
const timeFromPrevious = Math.abs(previousTime - clock);
// account for midnight when checking skips
const hasSkipped = Math.min(timeFromPrevious, dayInMs - timeFromPrevious) > skipLimit;
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
return hasSkipped;
}
/**
@@ -187,9 +197,9 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
* Finds the day offset relative to an event start
* used byt the runtimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): number {
export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart;
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
if (distance < -12 * MILLIS_PER_HOUR) return 1;
return 0;
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
return 0 as Day;
}
+1
View File
@@ -15,6 +15,7 @@ export const config = {
crash: 'crash logs',
demoProject: 'demo project.json',
newProject: 'new project.json',
defaultServerPort: 4001,
database: {
directory: 'db',
filename: 'db.json',
+3 -1
View File
@@ -5,4 +5,6 @@ export const isTest = Boolean(process.env.IS_TEST);
export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const envPort = process.env.PORT;
@@ -1,9 +1,9 @@
import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
import { TimerPhase, Playback, OffsetMode, type TimeOfDay } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0,
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
@@ -0,0 +1,10 @@
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
const baseStore: RuntimeStore = {
...runtimeStorePlaceholder
};
export function makeRuntimeStoreData(patch?: Partial<RuntimeStore>): RuntimeStore {
return deepmerge(baseStore, patch) as RuntimeStore;
}
@@ -1,4 +1,5 @@
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { Instant, PlayableEvent, Playback, SupportedEntry, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
@@ -256,8 +257,8 @@ describe('mutation on runtimeState', () => {
order: ['event1'],
});
const startEpoch = new Date('2024-01-01T00:00:00Z').getTime();
vi.setSystemTime(new Date('2024-01-03T00:00:00Z'));
const startEpoch = new Date('jan 1 00:00').getTime() as Instant;
vi.setSystemTime('jan 3 23:59:59');
await initRundown(mockRundown, {});
vi.runAllTimers();
@@ -280,7 +281,8 @@ describe('mutation on runtimeState', () => {
expect(newState.rundown.actualStart).toBe(60 * 1000);
expect(newState.rundown.currentDay).toBe(2);
vi.setSystemTime(new Date('2024-01-04T00:00:00Z'));
// crossing midnight increments currentDay
vi.setSystemTime('jan 4 00:00:01');
update();
expect(getState().rundown.currentDay).toBe(3);
});
@@ -297,6 +299,437 @@ describe('roll mode', () => {
vi.useRealTimers();
});
describe('overnight event sets correct currentDay', () => {
test('roll into overnight event after midnight sets currentDay correctly', async () => {
// Event spans 23:30 to 00:30 (overnight)
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
timeEnd: 30 * 60 * 1000, // 00:30
duration: 60 * 60 * 1000, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Clock is at 00:10 (morning part of overnight event)
vi.setSystemTime('jan 1 00:10');
const { rundown, metadata } = rundownCache.get();
const result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(true);
const state = getState();
// currentDay should be 1 because we're in the "next day" part of the overnight event
expect(state.rundown.currentDay).toBe(1);
});
test('roll into overnight event before midnight sets currentDay correctly', async () => {
// Event spans 23:30 to 00:30 (overnight)
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
timeEnd: 30 * 60 * 1000, // 00:30
duration: 60 * 60 * 1000, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Clock is at 23:40 (evening part of overnight event)
vi.setSystemTime('jan 1 23:40');
const { rundown, metadata } = rundownCache.get();
const result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(true);
const state = getState();
// currentDay should be 0 because we're still on the same day as the event start
expect(state.rundown.currentDay).toBe(0);
});
test('currentDay increments when crossing midnight during roll', async () => {
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
timeEnd: 30 * 60 * 1000, // 00:30
duration: 60 * 60 * 1000, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Start at 23:40
vi.setSystemTime('jan 1 23:40');
const { rundown, metadata } = rundownCache.get();
roll(rundown, metadata);
expect(getState().rundown.currentDay).toBe(0);
// Cross midnight
vi.setSystemTime('jan 2 00:10');
update();
expect(getState().rundown.currentDay).toBe(1);
});
test('pending roll crossing midnight keeps currentDay null', async () => {
// Event starts at 01:00 (future event, will be pending)
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
duration: 1 * MILLIS_PER_HOUR, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Clock is at 23:50 (before midnight, event is pending for tomorrow 01:00)
vi.setSystemTime('jan 1 23:50');
const { rundown, metadata } = rundownCache.get();
const result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(false);
const stateBeforeMidnight = getState();
expect(stateBeforeMidnight.timer.secondaryTimer).not.toBeNull();
// currentDay is null when pending (rundown hasn't started)
expect(stateBeforeMidnight.rundown.currentDay).toBeNull();
// Cross midnight while still pending
vi.setSystemTime('jan 2 00:10');
update();
const stateAfterMidnight = getState();
// currentDay stays null while pending (rundown hasn't started yet)
expect(stateAfterMidnight.rundown.currentDay).toBeNull();
// should still be pending (event starts at 01:00)
expect(stateAfterMidnight.timer.secondaryTimer).not.toBeNull();
});
test('waiting between events crossing midnight increments currentDay', async () => {
// Event 1: 23:00-23:30, Event 2: 00:30-01:00 (gap over midnight)
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 23 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 23:30
duration: 30 * MILLIS_PER_MINUTE,
dayOffset: 0,
},
2: {
...mockEvent,
id: '2',
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
duration: 30 * MILLIS_PER_MINUTE,
dayOffset: 0,
},
},
order: ['1', '2'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { rundown, metadata } = rundownCache.get();
// Start event 1 at 23:05
vi.setSystemTime('jan 1 23:05');
const startEpoch = Date.now() as Instant;
let result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(true);
expect(getState().rundown.currentDay).toBe(0);
// Simulate event 1 finishing and loading event 2 (what runtime service does)
// Load event 2 and call roll to put it in pending state
vi.setSystemTime('jan 1 23:35');
load(rundown.entries['2'] as PlayableEvent, rundown, metadata, {
firstStart: getState().rundown.actualStart,
startEpoch,
});
result = roll(rundown, metadata);
expect(result.eventId).toBe('2');
expect(result.didStart).toBe(false); // pending for 00:30
expect(getState().timer.secondaryTimer).not.toBeNull();
expect(getState().rundown.currentDay).toBe(0);
// Cross midnight while waiting for event 2
vi.setSystemTime('jan 2 00:10');
update();
// currentDay should increment even while waiting between events
expect(getState().rundown.currentDay).toBe(1);
expect(getState().timer.secondaryTimer).not.toBeNull(); // still waiting
});
test('pending roll sets currentDay to 0 when event starts (rundown starts fresh)', async () => {
// Event starts at 01:00
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
duration: 1 * MILLIS_PER_HOUR, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Start pending at 23:50
vi.setSystemTime('jan 1 23:50');
const { rundown, metadata } = rundownCache.get();
roll(rundown, metadata);
// Cross midnight
vi.setSystemTime('jan 2 00:10');
update();
// Now the event starts at 01:05 - call roll again to simulate runtime service
vi.setSystemTime('jan 2 01:05');
const result = roll(rundown, metadata);
expect(result.didStart).toBe(true);
const state = getState();
// currentDay is 0 because the rundown just started (this is day 0)
// the pending time before midnight doesn't count as rundown time
expect(state.rundown.currentDay).toBe(0);
});
test('pending roll crossing midnight updates secondaryTimer correctly', async () => {
// Event starts at 01:00
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
duration: 1 * MILLIS_PER_HOUR, // 1 hour
dayOffset: 0,
},
},
order: ['1'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
// Clock is at 23:50
vi.setSystemTime('jan 1 23:50');
const { rundown, metadata } = rundownCache.get();
roll(rundown, metadata);
const stateBeforeMidnight = getState();
// secondaryTimer should be time until 01:00 (1h 10min = 70 min)
expect(stateBeforeMidnight.timer.secondaryTimer).toBe(70 * MILLIS_PER_MINUTE);
// Cross midnight to 00:10
vi.setSystemTime('jan 2 00:10');
update();
const stateAfterMidnight = getState();
// secondaryTimer should now be 50 minutes until 01:00
expect(stateAfterMidnight.timer.secondaryTimer).toBe(50 * MILLIS_PER_MINUTE);
});
test('rolling into overnight event after midnight has correct offset and expected times', async () => {
// Simulates a rundown with:
// - A group starting at 13:00, containing an overnight event
// - Overnight event: 23:50 to 01:50 (2 hours)
// Roll into the event at 00:21 (31 minutes into the event)
const groupId = 'group-1';
const eventId = 'event-1';
const eventStart = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
const eventEnd = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
const eventDuration = 2 * MILLIS_PER_HOUR; // 2 hours
const groupStart = 13 * MILLIS_PER_HOUR; // 13:00
const groupDuration = 12 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 12h 50m (ends at 01:50)
const mockRundown = makeRundown({
entries: {
[groupId]: {
id: groupId,
type: SupportedEntry.Group,
title: 'Test Group',
timeStart: groupStart,
timeEnd: eventEnd,
duration: groupDuration,
entries: [eventId],
colour: '',
note: '',
custom: {},
revision: 0,
isFirstLinked: false,
targetDuration: null,
},
[eventId]: {
...mockEvent,
id: eventId,
timeStart: eventStart,
timeEnd: eventEnd,
duration: eventDuration,
dayOffset: 0,
parent: groupId,
},
},
order: [groupId],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { rundown, metadata } = rundownCache.get();
// Roll into the event AFTER midnight at 00:21 (31 minutes into the 2-hour event)
const rollTime = 21 * MILLIS_PER_MINUTE; // 00:21
vi.setSystemTime('jan 2 00:21');
const result = roll(rundown, metadata);
expect(result.eventId).toBe(eventId);
expect(result.didStart).toBe(true);
// Call update to recalculate all state (like runtime service does)
update();
const state = getState();
// currentDay should be 1 (we're on the next day after midnight)
expect(state.rundown.currentDay).toBe(1);
// offset.absolute should be 0 (event started on time, backdated to planned start)
expect(state.offset.absolute).toBe(0);
// Timer should show correct remaining time
// Event started at 23:50, duration is 2 hours, clock is 00:21
// Time elapsed = 31 minutes, time remaining = 2h - 31m = 1h 29m = 89 minutes
const expectedRemaining = eventDuration - (rollTime + (24 * MILLIS_PER_HOUR - eventStart));
expect(state.timer.current).toBe(expectedRemaining);
// expectedFinish should be 01:50 (event end time)
expect(state.timer.expectedFinish).toBe(eventEnd);
// expectedRundownEnd should be 01:50 (same as event end, only one event)
expect(state.offset.expectedRundownEnd).toBe(eventEnd);
// expectedGroupEnd should be 01:50 (group ends when the event ends)
expect(state.offset.expectedGroupEnd).toBe(eventEnd);
// Verify timer started at correct backdated time
expect(state.timer.startedAt).toBe(eventStart);
// Verify actualStart is backdated to planned start
expect(state.rundown.actualStart).toBe(eventStart);
});
test('rundown loops back after finishing and resets currentDay when first event starts again', async () => {
// Rundown: 09:00-23:00, then overnight event 23:00-01:00
const mockRundown = makeRundown({
entries: {
1: {
...mockEvent,
id: '1',
timeStart: 9 * MILLIS_PER_HOUR, // 09:00
timeEnd: 23 * MILLIS_PER_HOUR, // 23:00
duration: 14 * MILLIS_PER_HOUR, // 14 hours
dayOffset: 0,
},
2: {
...mockEvent,
id: '2',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00 (next day)
duration: 2 * MILLIS_PER_HOUR, // 2 hours
dayOffset: 0,
},
},
order: ['1', '2'],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { rundown, metadata } = rundownCache.get();
// Day 1: Start at 09:05 - first event is playing
vi.setSystemTime('jan 1 09:05');
let result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(true);
expect(getState().rundown.currentDay).toBe(0);
// Day 1: Move to 23:05 - second event (overnight) is playing
vi.setSystemTime('jan 1 23:05');
result = roll(rundown, metadata);
expect(result.eventId).toBe('2');
expect(result.didStart).toBe(true);
// Cross midnight
vi.setSystemTime('jan 2 00:30');
update();
expect(getState().rundown.currentDay).toBe(1);
// Day 2: 01:05 - rundown finishes, loops back to first event (pending for 09:00)
vi.setSystemTime('jan 2 01:05');
result = roll(rundown, metadata);
// First event is pending (09:00 is in the future)
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(false);
// currentDay is null while pending (rundown hasn't "restarted" yet)
expect(getState().rundown.currentDay).toBeNull();
// Day 2: 09:05 - first event starts again (fresh rundown cycle)
vi.setSystemTime('jan 2 09:05');
result = roll(rundown, metadata);
expect(result.eventId).toBe('1');
expect(result.didStart).toBe(true);
// currentDay resets to 0 for the new cycle
expect(getState().rundown.currentDay).toBe(0);
});
});
describe('normal roll', async () => {
beforeEach(async () => {
vi.useFakeTimers();
+85 -34
View File
@@ -1,5 +1,8 @@
import {
Day,
Duration,
isOntimeEvent,
Instant,
MaybeNumber,
MaybeString,
OffsetMode,
@@ -10,9 +13,11 @@ import {
Rundown,
Offset,
runtimeStorePlaceholder,
TimeOfDay,
TimerPhase,
TimerState,
RundownState,
Maybe,
} from 'ontime-types';
import {
calculateDuration,
@@ -23,7 +28,6 @@ import {
isPlaybackActive,
} from 'ontime-utils';
import { getTimeObject, timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/restore-service/restore.type.js';
import {
findDayOffset,
@@ -31,16 +35,22 @@ import {
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
hasCrossedMidnight,
} from '../services/timerUtils.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js';
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
import * as timeCore from '../lib/time-core/timeCore.js';
type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedToLoaded: boolean } | null;
type ExpectedMetadata = {
event: OntimeEvent;
accumulatedGap: number;
isLinkedToLoaded: boolean;
} | null;
export type RuntimeState = {
clock: number; // realtime clock
clock: TimeOfDay;
groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
@@ -50,9 +60,9 @@ export type RuntimeState = {
rundown: RundownState;
// private properties of the timer calculations
_timer: {
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
};
_rundown: {
@@ -61,12 +71,12 @@ export type RuntimeState = {
_group: ExpectedMetadata;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: MaybeNumber;
_startDayOffset: MaybeNumber;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
};
const runtimeState: RuntimeState = {
clock: timeNow(),
clock: timeCore.timeOfDayNow(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -122,7 +132,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow();
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -154,7 +164,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow();
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -354,10 +364,13 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
}
}
@@ -403,7 +416,9 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
@@ -423,7 +438,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.timer.elapsed = 0;
if (state.rundown.actualStart === null) {
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset;
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
@@ -459,7 +474,7 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
state.clock = timeNow();
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
@@ -494,7 +509,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = timeNow();
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
@@ -524,7 +539,8 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
@@ -532,16 +548,16 @@ export function update(): UpdateResult {
return updateIfIdle();
}
// if we are playing and playback changes. we tick the current runtime day
if (runtimeState._startDayOffset !== null && runtimeState._startEpoch) {
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + Math.floor((epoch - runtimeState._startEpoch) / dayInMs);
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const hasCrossedMidnight = previousClock > runtimeState.clock;
return updateIfWaitingToRoll(hasCrossedMidnight);
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
// 3. at this point we know that we are playing an event
@@ -600,11 +616,17 @@ export function update(): UpdateResult {
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return {
hasTimerFinished: false,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
};
}
}
@@ -620,7 +642,8 @@ export function roll(
}
// we will need to do some calculations, update the time first
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
// 2. if there is an event armed, we use it
@@ -668,13 +691,27 @@ export function roll(
runtimeState.rundown.actualGroupStart = plannedStart;
}
/**
* we need to backdate the actual start and start metadata
* to prevent adding unintended offset
*/
if (runtimeState.rundown.actualStart === null) {
runtimeState.rundown.actualStart = plannedStart;
runtimeState._startDayOffset = 0;
runtimeState._startEpoch = epoch;
// use plannedStart (not clock) because actualStart is backdated to plannedStart
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending;
}
@@ -720,12 +757,19 @@ export function roll(
// there is nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = runtimeState.timer.duration;
// reset rundown state - the rundown will start fresh when the event begins
runtimeState.rundown.actualStart = null;
runtimeState.rundown.currentDay = null;
runtimeState._startDayOffset = null;
runtimeState._startEpoch = null;
return { eventId: runtimeState.eventNow.id, didStart: false };
}
@@ -757,8 +801,15 @@ export function roll(
}
// update metadata
runtimeState._startDayOffset = 0;
runtimeState._startEpoch = epoch;
// use plannedStart (not clock) because actualStart is backdated to plannedStart
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
-88
View File
@@ -1,11 +1,4 @@
import { LogOrigin } from 'ontime-types';
import type { Server } from 'http';
import { networkInterfaces } from 'os';
import type { AddressInfo } from 'net';
import { isDocker, isOntimeCloud, isProduction } from '../setup/environment.js';
import { logger } from '../classes/Logger.js';
/**
* @description Gets information on IPV4 non-internal interfaces
@@ -33,84 +26,3 @@ export function getNetworkInterfaces(): { name: string; address: string }[] {
return results;
}
/**
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to an random port assigned by the OS
* @param {http.Server} server http server object
* @param {number} desiredPort the desired port
* @returns {number} the resulting port number
* @throws any other server errors will result in a throw
*/
export function serverTryDesiredPort(server: Server, desiredPort: number): Promise<number> {
if (isOntimeCloud) {
return forceCloudPort(server);
}
return new Promise((resolve, reject) => {
server.once('error', (error) => {
// we should only move ports if we are in a desktop environment
if (isDocker || !isProduction) {
reject(error);
return;
}
if (!isPortInUseError(error)) {
reject(error);
return;
}
// if we get an address in use error, we will try to open the server in an ephemeral port
// port 0 will assign an ephemeral port
server.listen(0, '0.0.0.0', () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error('Unknown port type, unable to proceed'));
return;
}
logger.error(
LogOrigin.Server,
`Failed open the desired port: ${desiredPort} \nMoved to an Ephemeral port: ${address.port}`,
true,
);
resolve(address.port);
});
});
server.listen(desiredPort, '0.0.0.0', () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error('Unknown port type, unable to proceed'));
return;
}
resolve(address.port);
});
});
}
function forceCloudPort(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.listen(4001, '0.0.0.0', () => {
const address = server.address();
if (!isAddressInfo(address)) {
reject(new Error('Unknown port type, unable to proceed'));
return;
}
resolve(address.port);
});
});
}
/**
* Guard verifies that the given address is a usable AddressInfo object
*/
function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
return typeof address === 'object' && address !== null;
}
/**
* Checks whether a given error is a port in use error
*/
function isPortInUseError(err: Error): boolean {
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
}
-15
View File
@@ -74,18 +74,3 @@ export function timeNow() {
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Get current time from system
* @returns [number, number] - [epoch time, milliseconds since midnight]
*/
export function getTimeObject(): [number, number] {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return [now.getTime(), elapsed];
}
-1
View File
@@ -460,7 +460,6 @@
"settings": {
"app": "ontime",
"version": "-",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
+1
View File
@@ -2,6 +2,7 @@
"extends": "../../tsconfig.common.json",
"compilerOptions": {
"target": "esnext",
"composite": true,
"module": "node16",
"moduleResolution": "node16",
"noImplicitReturns": false, //TODO: fix this
+4 -1
View File
@@ -13,6 +13,9 @@
},
"include": [
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.spec.ts"
],
"references": [
{ "path": "./tsconfig.json" }
]
}
+1
View File
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
globalSetup: './vitest.global-setup.ts',
},
});
+3
View File
@@ -0,0 +1,3 @@
export function setup() {
process.env.TZ = 'Europe/Copenhagen';
}
+23 -8
View File
@@ -132,14 +132,16 @@ test.describe('Sharing from cuesheet', () => {
await page.close();
});
test('Sharing a link with readonly permissions', async ({ page }) => {
test('Sharing a link with readonly permissions', async ({ page }, testInfo) => {
const alias = `cuesheet-read-test-${testInfo.retry}-${Date.now()}`;
await page.goto('http://localhost:4001/cuesheet');
await expect(page.getByTestId('cuesheet')).toBeVisible();
await page.getByRole('button', { name: 'Share...' }).click();
// configure share for readonly
await page.getByRole('textbox').fill('cuesheet-read-test');
await page.locator('input[name="alias"]').fill(alias);
await page.getByText('Custom write').click();
await page.getByText('Custom read').click();
await page.getByTestId('lockNav').click();
@@ -151,9 +153,16 @@ test.describe('Sharing from cuesheet', () => {
await page.getByTestId('write-duration').click();
await page.getByTestId('write-note').click();
// disable any custom write permissions if they exist
const customWriteSwitches = page.locator('[data-testid^="write-custom-"]');
const customWriteSwitchCount = await customWriteSwitches.count();
for (let i = 0; i < customWriteSwitchCount; i++) {
await customWriteSwitches.nth(i).click();
}
// create and verify link
await page.getByRole('button', { name: 'Create share link' }).click();
await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-read-test');
await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`);
await expect(page.getByTestId('copy-link')).toContainText('n=1');
// verify the preset
@@ -165,8 +174,9 @@ test.describe('Sharing from cuesheet', () => {
await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
// check that we are locked and cannot edit
await page.getByRole('button', { name: 'Edit' }).click();
// mode toggle should not be rendered for readonly users
await expect(page.getByRole('button', { name: 'Edit' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Run' })).toHaveCount(0);
// Verify that the title is visible but not editable
await expect(page.getByTestId('cuesheet-event').getByText('title 1')).toBeVisible();
@@ -176,14 +186,16 @@ test.describe('Sharing from cuesheet', () => {
await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible();
});
test('Sharing a link with scoped read-write permissions', async ({ page }) => {
test('Sharing a link with scoped read-write permissions', async ({ page }, testInfo) => {
const alias = `cuesheet-scope-test-${testInfo.retry}-${Date.now()}`;
await page.goto('http://localhost:4001/cuesheet');
await expect(page.getByTestId('cuesheet')).toBeVisible();
await page.getByRole('button', { name: 'Share...' }).click();
// configure share for readonly
await page.getByRole('textbox').fill('cuesheet-scope-test');
await page.locator('input[name="alias"]').fill(alias);
await page.getByText('Custom write').click();
await page.getByText('Custom read').click();
await page.getByTestId('lockNav').click();
@@ -202,7 +214,7 @@ test.describe('Sharing from cuesheet', () => {
// create and verify link
await page.getByRole('button', { name: 'Create share link' }).click();
await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-scope-test');
await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`);
await expect(page.getByTestId('copy-link')).toContainText('n=1');
// verify the preset
@@ -219,6 +231,9 @@ test.describe('Sharing from cuesheet', () => {
// Verify that the title is visible and editable
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
await titleEditor.click();
await expect(titleEditor).toBeEditable();
// other elements are not there
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
-1
View File
@@ -477,7 +477,6 @@
"settings": {
"app": "ontime",
"version": "-",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.4.2",
"version": "4.5.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.4.2",
"version": "4.5.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -6,6 +6,11 @@ export type NetworkInterface = {
address: string;
};
export type PortInfo = {
port: number;
pendingRestart: boolean;
};
export interface SessionStats {
startedAt: string;
connectedClients: number;
@@ -4,6 +4,7 @@ import type { TimerType } from '../TimerType.type.js';
import type { TimeStrategy } from '../TimeStrategy.type.js';
import type { Trigger } from './Automation.type.js';
import type { EntryCustomFields } from './CustomFields.type.js';
import type { Day } from './Temporal.js';
export type EntryId = string;
@@ -77,7 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
// !==== RUNTIME METADATA ====! //
revision: number;
delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime
dayOffset: Day; // calculated at runtime
gap: number; // calculated at runtime
};
@@ -2,7 +2,6 @@ import type { TimeFormat } from './TimeFormat.type.js';
export type Settings = {
version: string;
serverPort: number;
editorKey: null | string;
operatorKey: null | string;
timeFormat: TimeFormat;
@@ -0,0 +1,15 @@
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
/** A timestamp in milliseconds from epoch */
export type Instant = Branded<number, 'instant'>;
/** A timestamp of milliseconds since midnight today in the set timezone */
export type TimeOfDay = Branded<number, 'time-of-day'>;
/** A duration of milliseconds */
export type Duration = Branded<number, 'duration'>;
/** A day count integer */
export type Day = Branded<number, 'day'>;
@@ -28,9 +28,9 @@ type CuesheetUrlPreset = {
enabled: boolean;
alias: string;
search: string;
options: {
read: string;
write: string;
options?: {
read?: string;
write?: string;
};
};
+5 -1
View File
@@ -20,6 +20,9 @@ export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/cor
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js';
// ---> Core
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
@@ -76,6 +79,7 @@ export type {
MessageResponse,
SessionStats,
ProjectLogoResponse,
PortInfo,
} from './api/ontime-controller/BackendResponse.type.js';
export type {
EventPostPayload,
@@ -125,7 +129,7 @@ export {
isOntimeAction,
isTimerLifeCycle,
} from './utils/guards.js';
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
export type { Maybe, MaybeNumber, MaybeString } from './utils/utils.type.js';
// Colour
export type { RGBColour } from './definitions/Colour.type.js';
+2
View File
@@ -1,2 +1,4 @@
export type MaybeNumber = number | null;
export type MaybeString = string | null;
export type Maybe<T> = T | null;
@@ -24,6 +24,18 @@ describe('checkIsNow()', () => {
});
test('should return true accounting for events that roll over midnight', () => {
expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true);
const timeStart = 22 * MILLIS_PER_HOUR;
const timeEnd = 8 * MILLIS_PER_HOUR;
const now = 23 * MILLIS_PER_HOUR;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
});
test('should return true accounting for events that roll over midnight (2)', () => {
const timeStart = 22 * MILLIS_PER_HOUR;
const timeEnd = 8 * MILLIS_PER_HOUR;
const now = 1 * MILLIS_PER_HOUR;
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
});
});
+5 -4
View File
@@ -1,9 +1,10 @@
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event should be playing now
*/
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
return timeStart <= clock && clock <= normalisedEnd;
if (timeEnd < timeStart) {
// overnight event: clock is either after start OR before end
return clock >= timeStart || clock <= timeEnd;
}
return timeStart <= clock && clock <= timeEnd;
}
@@ -1,4 +1,4 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import type { Day, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
@@ -24,7 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
parent: null,
revision: 0, // calculated at runtime
delay: 0, // calculated at runtime
dayOffset: 0, // calculated at runtime
dayOffset: 0 as Day, // calculated at runtime
gap: 0, // calculated at runtime
};