Compare commits

..

2 Commits

Author SHA1 Message Date
Claude c169c419c3 fix(timer): correct count-to-end expected end across modes and days
The first pass pinned the count-to-end end with
`Math.max(expectedStart, plannedEnd)`, but those operands live in
different coordinate spaces: `getExpectedStart` bakes in the day shift,
the relative-start shift and the live runtime offset, while `plannedEnd`
was raw time-of-day. The result was only correct in single-day Absolute
mode and drifted in Relative mode and across day boundaries.

A count-to-end event is anchored to its fixed wall-clock end, so its
expected end is the scheduled end normalised for the day, with no runtime
offset and no relative-start shift (matching getExpectedFinish, which
returns the raw timeEnd). Compute it directly as
`normalisedTimeStart + duration`.

Add relative-mode and multi-day unit tests covering the cases the
previous formula got wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm
2026-06-21 08:07:33 +00:00
Claude 5b30f522aa fix(timer): count-to-end events absorb overtime in expected end times
The server computed rundown/group expected end times as
`expectedStart + duration`, ignoring `countToEnd`. For a count-to-end
last event running in overtime this pushed the projected end far past
(or before) its fixed end time, producing nonsensical values (e.g. a
negative expected rundown end) and leaving group/flag readouts stuck on
"due". It also never broke the link chain after a count-to-end event,
unlike the client.

A count-to-end event ends at its fixed end time regardless of preceding
overtime (matching `getExpectedFinish` for the running timer), so its
expected end should pin to the planned end and absorb the accumulated
offset.

- add shared `getExpectedEnd` helper in ontime-utils that pins
  count-to-end events to their planned end
- use it in `getExpectedTimes` for both rundown and group end
- break the `isLinkedToLoaded` chain after a count-to-end event in
  `loadGroupFlagAndEnd`, mirroring the client metadata logic
- delegate the client's `getExpectedTimesFromExtendedEvent` to the shared
  helper to remove the duplicated formula that caused the drift
- add unit and runtime-state tests covering overtime absorption and the
  chain break

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACGuFWt5aN7Fv3AkYXgxLm
2026-06-20 06:44:06 +00:00
27 changed files with 638 additions and 897 deletions
+15 -15
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.6.0",
"@base-ui/react": "1.3.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -12,27 +12,27 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"@uiw/codemirror-theme-vscode": "^4.25.9",
"autosize": "^6.0.1",
"axios": "^1.18.0",
"axios": "^1.12.2",
"csv-stringify": "^6.6.0",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.7",
"react": "^19.2.3",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
"react-hook-form": "^7.72.0",
"react-icons": "5.5.0",
"react-router": "^7.11.0",
"react-virtuoso": "^4.17.0",
"zustand": "^5.0.9"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -8,12 +8,9 @@
padding-top: 10vh;
}
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1em;
.empty {
width: 100%;
opacity: 0.8;
}
.text {
@@ -1,33 +1,18 @@
import { SupportedEntry } from 'ontime-types';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import EmptyImage from '../../../assets/images/empty.svg?react';
import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps {
handleAddNew?: (type: SupportedEntry) => void;
text: string;
}
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
return (
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</td>
</tr>
</tbody>
@@ -16,7 +16,6 @@ import {
TimeStrategy,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types';
import {
MILLIS_PER_SECOND,
@@ -83,7 +82,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultDangerTime,
defaultTimerType,
defaultEndAction,
inheritGroupColour,
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
@@ -241,14 +239,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
}
}
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
const parentId = resolveInsertParent(rundownData, newEntry);
const maybeParent = parentId ? rundownData.entries[parentId] : null;
if (maybeParent && isOntimeGroup(maybeParent)) {
newEntry.colour = maybeParent.colour;
}
}
try {
await addEntryMutation([rundownId, newEntry]);
} catch (error) {
@@ -264,7 +254,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultTimerType,
defaultEndAction,
defaultTimeStrategy,
inheritGroupColour,
addEntryMutation,
],
);
@@ -12,10 +12,8 @@ type EditorSettingsStore = {
defaultDangerTime: string;
defaultTimerType: TimerType;
defaultEndAction: EndAction;
inheritGroupColour: boolean;
setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void;
@@ -31,7 +29,6 @@ export const editorSettingsDefaults = {
dangerTime: '00:01:00', // 60000 same as backend
timerType: TimerType.CountDown,
endAction: EndAction.None,
inheritGroupColour: false,
};
enum EditorSettingsKeys {
@@ -42,7 +39,6 @@ enum EditorSettingsKeys {
DefaultDangerTime = 'ontime-default-danger-time',
DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -63,10 +59,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction,
),
inheritGroupColour: booleanFromLocalStorage(
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
setDefaultDuration: (defaultDuration) =>
set(() => {
@@ -105,10 +97,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction };
}),
setInheritGroupColour: (inheritGroupColour) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
};
});
@@ -40,15 +40,6 @@ describe('getRouteFromPreset()', () => {
options: {},
},
];
const disabledPresets: URLPreset[] = [
{
enabled: false,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {},
},
];
it('checks if the current location matches an enabled preset', () => {
// we make the current location be the alias
@@ -56,16 +47,6 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('checks if the current location matches an enabled preset target', () => {
const location = resolvePath('/timer');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('does not redirect disabled presets', () => {
const location = resolvePath('/demopage');
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
});
it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull();
@@ -105,11 +86,6 @@ describe('getRouteFromPreset()', () => {
const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
});
describe('cuesheet presets', () => {
+9 -8
View File
@@ -4,6 +4,7 @@ import {
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -172,13 +173,15 @@ export function getExpectedTimesFromExtendedEvent(
) {
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
const expectedStartState = {
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
};
const expectedStart = getExpectedStart(
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
{
totalGap: event.totalGap,
isLinkedToLoaded: event.isLinkedToLoaded,
...state,
},
expectedStartState,
);
const plannedEnd = event.timeStart + event.duration + event.delay;
@@ -186,9 +189,7 @@ export function getExpectedTimesFromExtendedEvent(
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
expectedEnd: getExpectedEnd(event, expectedStartState),
plannedEnd,
};
}
@@ -157,7 +157,7 @@ export default function ManageRundowns() {
</td>
<td>
<DropdownMenu
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
@@ -16,7 +16,6 @@ export default function RundownDefaultSettings() {
defaultDangerTime,
defaultTimerType,
defaultEndAction,
inheritGroupColour,
setDefaultDuration,
setLinkPrevious,
setTimeStrategy,
@@ -24,7 +23,6 @@ export default function RundownDefaultSettings() {
setDangerTime,
setDefaultTimerType,
setDefaultEndAction,
setInheritGroupColour,
} = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration);
@@ -46,13 +44,6 @@ export default function RundownDefaultSettings() {
/>
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Inherit group colour'
description='Whether new events and milestones inherit the colour of their parent group'
/>
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Timer strategy'
+1 -1
View File
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
},
});
return Sentry.withSentryReactRouterV7Routing(Routes);
return Sentry.withSentryReactRouterV6Routing(Routes);
};
@@ -1,6 +1,6 @@
import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
@@ -20,7 +20,6 @@ import { usePersistedRundownOptions } from '../../../features/rundown/rundown.op
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
@@ -63,8 +62,7 @@ export default function CuesheetTable({
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const { updateEntry, updateTimer } = useEntryActionsContext();
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
const optionsStore = useOptions();
@@ -204,9 +202,8 @@ export default function CuesheetTable({
listeners,
rows,
table,
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
}),
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
[columnSizeVars, cursor, listeners, rows, table],
);
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
@@ -276,13 +273,10 @@ interface CuesheetVirtuosoContext {
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
handleAddNew?: (type: SupportedEntry) => void;
}
const EmptyPlaceholder = memo(function EmptyPlaceholder({
context,
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
return <EmptyTableBody text='No data in rundown' />;
});
const CuesheetTableElement = memo(function CuesheetTableElement({
+8 -8
View File
@@ -8,15 +8,15 @@
"@googleapis/sheets": "^5.0.5",
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"cors": "2.8.5",
"dotenv": "^16.0.1",
"express": "5.2.1",
"express-static-gzip": "3.0.1",
"express-validator": "7.3.2",
"fast-equals": "^6.0.0",
"express": "5.1.0",
"express-static-gzip": "3.0.0",
"express-validator": "7.2.1",
"fast-equals": "^5.0.1",
"google-auth-library": "^9.4.2",
"lowdb": "^7.0.1",
"multer": "2.2.0",
"multer": "2.1.0",
"ontime-utils": "workspace:*",
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
@@ -24,9 +24,9 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cookie-parser": "1.4.10",
"@types/cookie-parser": "1.4.9",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/express": "5.0.3",
"@types/multer": "2.1.0",
"@types/node": "catalog:",
"@types/ws": "^8.5.10",
@@ -2,11 +2,13 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { demoDb } from '../../../models/demoProject.js';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import {
calculateDayOffset,
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getIntegerAndFraction,
hasChanges,
makeDeepClone,
@@ -221,6 +223,25 @@ describe('calculateDayOffset()', () => {
});
});
describe('duplicateRundown', () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title);
expect(duplicatedRundown).toMatchObject({
title: title,
entries: expect.any(Object),
order: expect.any(Array),
flatOrder: expect.any(Array),
});
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
});
});
describe('makeDeepClone()', () => {
it('deep clones a group along with its nested entries', () => {
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
@@ -15,18 +15,16 @@ import {
createNewRundown,
deleteAllEntries,
deleteEntries,
deleteRundown,
duplicateRundown,
editEntry,
groupEntries,
initRundown,
loadRundown,
renameRundown,
renumberEntries,
reorderEntry,
swapEvents,
ungroupEntries,
} from './rundown.service.js';
import { normalisedToRundownArray } from './rundown.utils.js';
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
import {
clonePostValidator,
entryBatchPutValidator,
@@ -36,7 +34,6 @@ import {
entryReorderValidator,
entrySwapValidator,
rundownArrayOfIds,
rundownPatchValidator,
rundownPostValidator,
} from './rundown.validation.js';
@@ -47,7 +44,7 @@ export const router: Router = express.Router();
/**
* Returns all rundowns in the project
*/
router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
const projectRundowns = getDataProvider().getProjectRundowns();
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
});
@@ -55,7 +52,7 @@ router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
/**
* Returns the current rundown
*/
router.get('/current', (_req: Request, res: Response<Rundown>) => {
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
const rundown = getCurrentRundown();
res.json(rundown);
});
@@ -63,7 +60,7 @@ router.get('/current', (_req: Request, res: Response<Rundown>) => {
/**
* Returns a given rundown in its normalised client shape
*/
router.get('/:id', paramsWithId, (req: Request, res: Response<Rundown | ErrorResponse>) => {
router.get('/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = getProcessedRundown(req.params.id);
res.json(rundown);
@@ -107,7 +104,13 @@ router.post(
paramsWithId,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const projectRundowns = await duplicateRundown(req.params.id);
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
@@ -120,26 +123,54 @@ router.post(
* Patches the data of an existing rundown
* Currently only the title can be changed
*/
router.patch(
'/:id',
rundownPatchValidator,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const projectRundowns = await renameRundown(req.params.id, req.body.title);
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
if (!req.body.title) throw new Error('No title provided');
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
/**
* If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (req.params.id === getCurrentRundown().id) {
const rundown = dataProvider.getRundown(req.params.id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
}
},
);
const projectRundowns = getDataProvider().getProjectRundowns();
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
/**
* Deletes a rundown if not loaded
*/
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const newProjectRundowns = await deleteRundown(req.params.id);
if (req.params.id === getCurrentRundown().id) {
res.status(400).send({ message: 'Cannot delete loaded rundown' });
return;
}
const dataProvider = getDataProvider();
const projectRundowns = dataProvider.getProjectRundowns();
if (Object.keys(projectRundowns).length <= 1) {
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
res.status(400).send({ message: 'Cannot delete the last rundown' });
return;
}
await dataProvider.deleteRundown(req.params.id);
const newProjectRundowns = getDataProvider().getProjectRundowns();
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
@@ -16,7 +16,7 @@ import {
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -643,7 +643,7 @@ export function isCurrentRundown(id: string) {
/**
* @throws if the provided id does not exist
*/
export function loadRundown(id: string) {
export async function loadRundown(id: string) {
const dataProvider = getDataProvider();
if (isCurrentRundown(id)) {
return dataProvider.getProjectRundowns();
@@ -651,7 +651,7 @@ export function loadRundown(id: string) {
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
initRundown(rundown, customField);
await initRundown(rundown, customField);
return dataProvider.getProjectRundowns();
}
@@ -659,7 +659,11 @@ export function loadRundown(id: string) {
* Sets a new rundown in the cache
* and marks it as the currently loaded one
*/
export function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>, reload: boolean = false) {
export async function initRundown(
rundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>,
reload: boolean = false,
) {
runtimeService.stop();
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
@@ -688,73 +692,3 @@ export async function createNewRundown(title: string) {
return projectRundowns;
}
/**
* duplicate a rundown
* @throws
*/
export async function duplicateRundown(id: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const newRundownId = generateId();
const newRundown: Rundown = structuredClone(rundown);
newRundown.id = newRundownId;
newRundown.title = `Copy of ${rundown.title}`;
newRundown.revision = 0;
const newProjectRundowns = await dataProvider.setRundown(newRundownId, newRundown);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
return newProjectRundowns;
}
/**
* rename a rundown
* @throws
*/
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);
const newProjectRundowns = await dataProvider.setRundown(rundown.id, { ...rundown, title });
/**
* If we are modifying the loaded rundown we re-init it
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
initRundown(rundown, customField);
} else {
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
}
return newProjectRundowns;
}
/**
* delete a rundown
* @throws
*/
export async function deleteRundown(id: string) {
if (isCurrentRundown(id)) throw new Error('Cannot delete loaded rundown');
const dataProvider = getDataProvider();
const projectRundowns = dataProvider.getProjectRundowns();
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
if (Object.keys(projectRundowns).length <= 1) throw new Error('Cannot delete the last rundown');
const newProjectRundowns = await dataProvider.deleteRundown(id);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
return newProjectRundowns;
}
@@ -460,6 +460,20 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund
});
}
/**
* Duplicates an existing rundown ensuring all IDs are unique
*/
export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
const newRundownId = generateId();
const newRundown = structuredClone(rundown);
newRundown.id = newRundownId;
newRundown.title = newTitle;
newRundown.revision = 0;
return newRundown;
}
export type IncrementNumber = {
integer: number;
faction: number;
@@ -5,11 +5,6 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
// #region operations on project rundowns =========================
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
export const rundownPatchValidator = [
param('id').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().withMessage('No title provided'),
requestValidationFunction,
];
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -101,10 +101,9 @@ function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
async function setRundown(rundownKey: string, newData: Rundown): Promise<void> {
db.data.rundowns[rundownKey] = structuredClone(newData);
await persist();
return db.data.rundowns;
}
function getSettings(): Readonly<Settings> {
@@ -245,6 +245,52 @@ describe('mutation on runtimeState', () => {
expect(newState.offset.expectedRundownEnd).toBeNull();
});
test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => {
const tenAM = 10 * MILLIS_PER_HOUR;
const elevenAM = 11 * MILLIS_PER_HOUR;
const noon = 12 * MILLIS_PER_HOUR;
const entries = {
event1: {
...mockEvent,
id: 'event1',
timeStart: tenAM,
timeEnd: elevenAM,
duration: MILLIS_PER_HOUR,
parent: null,
},
event2: {
...mockEvent,
id: 'event2',
timeStart: elevenAM,
timeEnd: noon,
duration: MILLIS_PER_HOUR,
countToEnd: true,
linkStart: true,
parent: null,
},
};
const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] });
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start event1 five minutes behind schedule
vi.setSystemTime('jan 1 10:05');
load(entries.event1, rundown, metadata);
start();
update();
const newState = getState();
expect(newState.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
// without countToEnd the rundown would end at noon + 5min, but the countToEnd
// event absorbs the overtime so the rundown is still expected to end at noon
expect(newState.offset.expectedRundownEnd).toBe(noon);
});
test('resume restores currentDay from restore point', async () => {
clearState();
const mockRundown = makeRundown({
@@ -956,4 +1002,32 @@ describe('loadGroupFlagAndEnd()', () => {
eventNow: rundown.entries[0],
});
});
test('a countToEnd event breaks the link chain for the events that follow it', () => {
// chain: A (loaded) -> B (countToEnd, flagged) -> C (linked, last event)
// the chain stays intact up to and including B, but breaks for C since it follows a countToEnd event
const rundown = makeRundown({
entries: {
A: makeOntimeEvent({ id: 'A', parent: null, linkStart: false, countToEnd: false, gap: 0 }),
B: makeOntimeEvent({ id: 'B', parent: null, linkStart: true, countToEnd: true, gap: 0, flag: true }),
C: makeOntimeEvent({ id: 'C', parent: null, linkStart: true, countToEnd: false, gap: 0 }),
},
order: ['A', 'B', 'C'],
});
const state = {
groupNow: null,
eventNow: rundown.entries.A,
rundown: { actualGroupStart: null },
} as RuntimeState;
const metadata = { playableEventOrder: ['A', 'B', 'C'], flags: ['B'] } as RundownMetadata;
loadGroupFlagAndEnd(rundown, metadata, 0, state);
// the flag (B) is still part of the chain
expect(state._flag).toMatchObject({ event: rundown.entries.B, isLinkedToLoaded: true });
// the rundown end (C) follows the countToEnd event, so the chain is broken
expect(state._end).toMatchObject({ event: rundown.entries.C, isLinkedToLoaded: false });
});
});
+10 -5
View File
@@ -23,6 +23,7 @@ import {
calculateDuration,
checkIsNow,
dayInMs,
getExpectedEnd,
getExpectedStart,
getLastEventNormal,
isPlaybackActive,
@@ -836,7 +837,7 @@ function getExpectedTimes(state = runtimeState) {
const { _group } = state;
if (_group !== null) {
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
const lastEventExpectedStart = getExpectedStart(lastEvent, {
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, {
currentDay: state.rundown.currentDay!,
totalGap: accumulatedGap,
isLinkedToLoaded,
@@ -845,7 +846,6 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
}
}
@@ -868,7 +868,7 @@ function getExpectedTimes(state = runtimeState) {
if (state._end) {
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
const expectedStart = getExpectedStart(event, {
state.offset.expectedRundownEnd = getExpectedEnd(event, {
currentDay: state.rundown.currentDay!,
totalGap: accumulatedGap,
isLinkedToLoaded,
@@ -877,7 +877,6 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = expectedStart + event.duration;
}
}
@@ -920,6 +919,9 @@ export function loadGroupFlagAndEnd(
let accumulatedGap = 0;
let isLinkedToLoaded = true;
// a countToEnd event absorbs overtime, so the chain breaks on the event that follows it
// mirrors the client logic in common/utils/rundownMetadata.ts
let previousWasCountToEnd = false;
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
const entry = entries[playableEventOrder[idx]];
@@ -928,7 +930,7 @@ export function loadGroupFlagAndEnd(
if (idx !== currentIndex) {
// we only accumulate data after the loaded event
accumulatedGap += entry.gap;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
// and the loaded event is not allowed to be the next flag
if (!foundFlag && metadata.flags.includes(entry.id)) {
@@ -942,6 +944,9 @@ export function loadGroupFlagAndEnd(
foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
}
// carry the countToEnd status forward so the next event can break the chain
previousWasCountToEnd = entry.countToEnd;
}
}
-260
View File
@@ -1,260 +0,0 @@
# Ontime Sync Engine — Backend Plan
> Status: **draft / requirements**. Scope: backend only. The client/UI integration is
> deliberately out of scope and will be specified separately.
## 1. Goal
Run **two independent Ontime backends** (typically one **local** and one in the **cloud**) and
keep them in sync:
- The user, from the client of *one* backend, initiates a sync between the two.
- After the initial sync, both backends hold the **same project data**.
- Ongoing **data changes** (rundown edits, custom fields, settings, …) propagate to both.
- Ongoing **playback actions** (start / stop / pause / roll / load / add-time, messages,
aux timers) propagate to both, so both report the same runtime state.
The brief suggests leaning on a CRDT library (Automerge / `automerge-repo`) to do the heavy
lifting. This document validates that idea against the codebase and proposes a concrete
architecture.
## 2. What the code actually looks like (validated)
There are **three distinct kinds of state** in the server, and they have very different sync
requirements. This distinction drives the whole design.
### 2.1 Persistent project data — *the document*
`DatabaseModel` (`packages/types/src/definitions/DataModel.type.ts`):
```ts
type DatabaseModel = {
rundowns: ProjectRundowns; // Record<RundownId, Rundown>
project: ProjectData;
settings: Settings;
viewSettings: ViewSettings;
urlPresets: URLPreset[];
customFields: CustomFields;
automation: AutomationSettings;
};
```
- Persisted by `DataProvider` (`apps/server/src/classes/data-provider/DataProvider.ts`) via
**lowdb** to a JSON file, with a **3 s trailing-edge debounced write** (`persist()`).
- A `Rundown` carries `{ id, title, order, flatOrder, entries, revision }`. `entries` is a
`Record<EntryId, OntimeEntry>` — i.e. a map keyed by stable id. `order`/`flatOrder` are
arrays of ids.
- This is **collaborative-document-shaped data**. It is the natural fit for a CRDT.
### 2.2 Rundown cache + transaction layer
`apps/server/src/api-data/rundown/rundown.dao.ts`:
- The **currently loaded** rundown lives in an in-memory `cachedRundown` plus derived
`rundownMetadata` (computed schedule: gaps, delays, group times, ordered lists).
- All edits go through `createTransaction({ rundownId, mutableRundown })`
`rundownMutation.*` (add/edit/remove/reorder/applyDelay/swap/clone/group/ungroup/renumber)
`commit()`. `commit()`:
1. bumps `cachedRundown.revision`,
2. re-processes derived metadata (`processRundown`),
3. persists through `DataProvider.setRundown`.
- **Non-loaded ("background") rundowns** bypass the cache: read from disk, mutate, persist.
- `rundown.service.ts` wraps every mutation and, in a `setImmediate`, fires **side effects**:
- `updateRuntimeOnChange()` → pushes derived counts into runtime state,
- `notifyChanges()``runtimeService.notifyOfChangedEvents()` (timer) and
`sendRefetch(RefetchKey.Rundown, revision, rundownId)` (tells clients to re-pull).
> Key takeaway: **mutations are funnelled through a single, well-defined chokepoint** with a
> post-commit side-effect hook. That hook is exactly where remote (synced) changes must also
> be injected, so that a change arriving from the peer triggers the same cache rebuild +
> client refetch as a local edit.
The other persistent slices (`project`, `settings`, `viewSettings`, `urlPresets`,
`customFields`, `automation`) are written **directly** through `DataProvider` setters and do
**not** go through the transaction/side-effect layer — they emit their own `Refetch` from
their routers. Sync must cover these too.
### 2.3 Runtime / playback state — *not document data*
`apps/server/src/stores/runtimeState.ts` + `EventTimer` + `runtime.service.ts`:
- Live timer state is **derived every tick from the local wall clock** (`timeCore.now()` =
`Date.now()`), recomputed at 30 fps and broadcast to clients at ~1 fps via `eventStore`
over the websocket (`MessageTag.RuntimeData`).
- The **entire playback state collapses to a tiny serialisable record** — the existing
`RestorePoint` (`services/restore-service/restore.type.ts`):
```ts
type RestorePoint = {
playback: Playback;
selectedEventId: MaybeString;
startedAt: MaybeNumber; // TimeOfDay (ms since local midnight)
addedTime: number;
pausedAt: MaybeNumber; // TimeOfDay
firstStart: MaybeNumber; // TimeOfDay
startEpoch: Maybe<Instant>; // absolute epoch ms ← timezone independent
currentDay: MaybeNumber;
};
```
- `runtimeState.resume(restorePoint, event, rundown, metadata)` already **reconstructs a live
playing timer from this record** — this is the mechanism a follower backend will reuse to
adopt remote playback state.
- **Commands** (`start/startById/stop/pause/roll/load*/addTime/setOffsetMode`) all live on the
`runtimeService` singleton, decorated with `@broadcastResult`. External callers reach them
through `dispatchFromAdapter()` (`integration.controller.ts`) from WS/OSC/HTTP.
- **Messages** (`message.service.ts`) and **aux timers** (`AuxTimerService`) are additional
ephemeral runtime state held in `eventStore`, not in the DB.
> Critical timing observation: `timeCore.toTimeOfDay()` uses the **machine's local timezone
> offset** (`getTimezoneOffset`). `startEpoch` is absolute and TZ-independent, but `startedAt`,
> `pausedAt`, `firstStart`, `clock` are all *TimeOfDay in the originating machine's TZ*. A
> local box and a cloud box in different timezones will **not** interpret a replicated
> TimeOfDay the same way. Playback sync must therefore anchor on **absolute epoch + the
> project's configured timezone**, and the follower must **recompute** TimeOfDay-derived
> fields locally rather than copying them verbatim.
## 3. Does a CRDT fit? — Verdict
**Yes, but only for §2.1 (the project document).** Automerge is a strong fit there:
- Edits are keyed by stable ids (`entries[id]`, `customFields[key]`, rundowns by id), so
concurrent edits to *different* entries merge cleanly (Automerge maps merge per-key).
- It removes the need to hand-roll conflict resolution, op ordering, and incremental
catch-up after disconnection.
**No for §2.3 (playback/runtime).** A CRDT is the wrong tool for real-time control:
- Timer ticks must **not** be streamed over the network — each backend already derives them
locally from the wall clock. We only need to replicate **intent transitions**.
- Playback is a control-plane concern with a "last command wins" nature, not a mergeable
document.
So the recommendation is a **hybrid**:
| Domain | Mechanism | Library |
| --- | --- | --- |
| Project document (`DatabaseModel`) | CRDT document, incremental sync | `@automerge/automerge-repo` + WS network adapter |
| Playback / messages / aux timers | Replicated **intent** (LWW register w/ logical clock), recomputed locally | small in-house module over the same socket |
| Live timer ticks | **Not synced** — derived locally on each node | existing `EventTimer` |
## 4. Topology & connection model
- **Initiation is directional, ongoing sync is bidirectional.** "Sync now" from backend A
must choose a **baseline owner** (whose project seeds the shared document). Merging two
*unrelated* projects with a CRDT yields a union of both rundowns — almost never what the
user wants. So:
1. On "sync", A and B establish a connection.
2. The chosen baseline (say A) exports its current project as the shared Automerge document;
B **adopts** it (forks from A's document so they share lineage/history).
3. From then on, both edit the *same* document and `automerge-repo` reconciles incrementally
and bidirectionally.
- **Who dials whom:** the cloud instance (`IS_CLOUD`) has a reachable public endpoint; the
local instance is usually behind NAT. The **local node dials out to the cloud node**, and
the cloud node acts as the `automerge-repo` sync server / relay. This is exactly the
`automerge-repo` WebSocket server/client split.
- **Sync targets the currently loaded project only** (one Automerge `DocumentId` ↔ one Ontime
project). Switching projects detaches/attaches the sync session.
- **Auth:** the sync socket must authenticate. Reuse the existing auth (`makeAuthenticateMiddleware`
/ login flow + shared token). The "sync" action carries the peer URL + credentials.
## 5. Integration points (where code hooks in)
1. **DataProvider becomes CRDT-backed (the document).**
- The shared Automerge doc holds the persistent `DatabaseModel`.
- `DataProvider` read paths return the doc's current value; write paths (`setRundown`,
`setCustomFields`, `setSettings`, …) are re-expressed as Automerge `change()` calls.
- Granularity: the existing mutations already operate at **per-entry / per-key** level
(`rundown.entries[id] = …`, `order.splice(...)`, `customFields[key] = …`). Re-expressing
them as Automerge changes at that same granularity gives good merge behaviour without a
full rewrite of the mutation algorithms. **Avoid replacing whole `entries`/`order`
objects wholesale** — that defeats per-key merge. The current `commit()` reassigns
`cachedRundown.entries = entries`; the CRDT adapter needs to apply the *delta* instead.
- lowdb persistence stays as a **local durability layer** (or is replaced by Automerge's
own storage adapter). Either way the 3 s debounce semantics should be preserved.
2. **Remote-change observer → existing side-effect path.**
- Subscribe to Automerge doc changes. When a change arrives **from the peer** (not from a
local mutation), run the same post-commit side effects that a local edit would:
- rebuild the loaded-rundown cache (`rundownCache.init` / `runtimeState.updateAll`),
- `runtimeService.notifyOfChangedEvents(metadata)`,
- `sendRefetch(RefetchKey.Rundown | …, revision, rundownId)` to local clients.
- This is the single most important hook: it makes remote edits indistinguishable from
local edits to everything downstream (clients, timer, integrations).
3. **`revision` semantics.** Today `revision` is a per-rundown monotonic counter used only to
tell clients "you're stale, refetch". With two writers it can collide. Options: derive the
client-facing revision from the Automerge document heads/hash, or keep the counter as
advisory and rely on the refetch always pulling current truth. Recommend deriving a stable
version token from Automerge heads.
4. **Playback intent channel.**
- Define a replicated `PlaybackIntent` ≈ `RestorePoint` + `offsetMode`, plus `messages` and
`auxTimers[1..3]` intent (`{playback, startedAtEpoch, duration, direction}`).
- Model as a **LWW register stamped with a logical (Lamport) clock + originating peer id**.
Every `runtimeService` command updates the local intent and publishes it; the peer
applies it if its stamp is newer.
- The follower applies intent via a **resume-style path** (`runtimeState.resume`-like) that
**recomputes TimeOfDay fields from `startEpoch` + clock offset + project timezone** — it
does *not* copy `startedAt`/`pausedAt` verbatim (see §2.3 timing note).
- Live ticks remain local; both nodes converge because they share intent + a common clock.
5. **Clock synchronisation.**
- Both nodes must agree on epoch time within tolerance (target sub-100 ms for broadcast use).
- Recommend an **application-level offset estimate** over the sync socket (periodic
timestamped ping ⇒ Cristian's algorithm / NTP-lite), applied by the follower when
interpreting `startEpoch`. Do not assume both machines are NTP-disciplined, but benefit
from it when they are.
## 6. Conflict & authority model
- **Document edits:** resolved by Automerge (per-key map merge, RGA for arrays). Define a
policy for the rare same-key concurrent edit (Automerge picks a deterministic winner; we
may surface a "changed remotely" hint to the editor). Concurrent reorders of the same list
are the main thing to test (array CRDT semantics).
- **Playback:** a human operator drives it; genuinely simultaneous conflicting commands are
rare. LWW on the intent register (logical clock + peer id tiebreak) is sufficient for a
2-node system and far simpler than a leader-election protocol. Revisit if N>2 is ever needed.
## 7. Phased delivery
1. **Phase 0 — Spec & spike.** Lock requirements (this doc). Spike `automerge-repo` WS
client/server between two local server instances; prove a doc round-trips.
2. **Phase 1 — Document sync (data only).** CRDT-back the `DatabaseModel`; remote-change
observer wired into the existing refetch/cache side-effect path. Directional initial seed.
No playback sync yet. Deliverable: edits on either node appear on both.
3. **Phase 2 — Clock sync + playback intent.** Offset estimation; replicate `PlaybackIntent`;
follower derives ticks locally. Deliverable: start/stop/pause/roll/load/add-time mirror.
4. **Phase 3 — Messages & aux timers.** Extend intent channel.
5. **Phase 4 — Resilience.** Reconnection/catch-up, project-switch handling, auth hardening,
conflict UX hints, observability (drift metrics, sync status).
## 8. Open questions / decisions needed
1. **Library:** confirm `@automerge/automerge-repo` (WASM core) vs alternatives (Yjs). Automerge
matches the keyed-map data model and brittle-free merges; Yjs is leaner/faster but more
text-CRDT oriented. *Recommendation: Automerge.*
2. **Baseline-owner UX:** when the two projects differ at initiation, is it always
"push mine / overwrite theirs", or do we offer "pull theirs"? (Merging unrelated projects is
explicitly discouraged.)
3. **Persistence:** keep lowdb as the local store and treat Automerge as the in-memory
sync truth, or move durability to an Automerge storage adapter? Affects crash recovery and
the existing `flushPendingWrites`/restore flow.
4. **Timezone authority:** anchor playback on the **project's configured timezone** (not each
machine's local TZ). Confirm where that timezone lives / whether it must be added.
5. **`report` data** (run history): sync as part of the document, or keep per-instance?
6. **Scope of N:** is 2 nodes the hard ceiling, or should the intent/authority model leave room
for more peers?
7. **Multiple loaded rundowns / background rundowns:** confirm the whole project document syncs
(all rundowns), while only the *loaded* one drives the runtime on each node.
## 9. Risks
- **TimeOfDay vs absolute epoch** across timezones (the single biggest playback-sync trap; §2.3).
- **Array/order merge** semantics for concurrent reorders — needs explicit test coverage.
- **`structuredClone`-and-replace** mutation style must be converted to deltas or it will
clobber concurrent edits and negate the CRDT.
- **Document growth / compaction** — Automerge history grows; plan periodic compaction/snapshots.
- **Bandwidth on the local↔cloud link** — fine for doc deltas + intent; would be a problem if
timer ticks were ever streamed (they must not be).
```
-26
View File
@@ -30,8 +30,6 @@ test('cuesheet datagrid does not submit timer cells on tab-out or escape', async
// re-enter edit mode: original value should be unchanged
await durationCell.click();
// tabbing selects the next input field so we have to click twice to first leave input field and then select
await durationCell.click();
await expect(durationCell.locator('input')).toHaveValue(originalDuration);
await durationCell.locator('input').press('Escape');
@@ -102,27 +100,3 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
await expect(cueEditor).not.toBeFocused();
await expect(cueEditor).toHaveValue(cueBeforeCancel);
});
test('cuesheet background edit from empty state', async ({ page }) => {
// create an empty rundown
await page.goto('/editor');
await page.getByRole('button', { name: 'Toggle settings' }).click();
await page.getByRole('button', { name: 'Manage rundowns' }).click();
await page.getByRole('button', { name: 'New' }).nth(1).click();
const emptyName = `empty-${Date.now()}`;
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
await page.getByRole('button', { name: 'Create rundown' }).click();
// edit it in the cuesheet
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
await page.getByText('Edit in cuesheet').click();
// expect to see and empty screen
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
// create 1 event
await page.getByRole('button', { name: 'Create Event' }).click();
// and expect to find it
await expect(page.getByTestId('cuesheet-event')).toBeVisible();
});
+1 -1
View File
@@ -55,7 +55,7 @@
"devEngines": {
"runtime": {
"name": "node",
"version": "22.22.3"
"version": "22.22.2"
}
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
// feature business logic
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
export { getExpectedEnd, getExpectedStart } from './src/date-utils/getExpectedStart.js';
// feature business logic - rundown
export { checkIsNow } from './src/date-utils/checkIsNow.js';
@@ -1,7 +1,7 @@
import { Day, OffsetMode } from 'ontime-types';
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
import { getExpectedStart } from './getExpectedStart';
import { getExpectedEnd, getExpectedStart } from './getExpectedStart';
describe('getExpectedStart()', () => {
describe('Absolute offset mode', () => {
@@ -315,3 +315,110 @@ describe('getExpectedStart()', () => {
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
});
});
describe('getExpectedEnd()', () => {
const baseState = {
currentDay: 0,
totalGap: 0,
mode: OffsetMode.Absolute,
actualStart: null,
plannedStart: null,
isLinkedToLoaded: true,
};
test('a regular event ends at its expected start plus duration', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: false,
};
// on schedule
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150);
// running 20 behind pushes the end out
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170);
});
test('a countToEnd event pins to the planned end while in overtime', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150);
});
test('a countToEnd event pins to the planned end while ahead of schedule', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// ahead of schedule the start moves earlier (90) but the end stays pinned to 150
expect(getExpectedEnd(testEvent, { ...baseState, offset: -10 })).toBe(150);
});
test('an overnight countToEnd event returns a normalised end', () => {
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
const timeStart = 23 * MILLIS_PER_HOUR;
const duration = 2 * MILLIS_PER_HOUR;
const testEvent = {
timeStart,
duration,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
});
test('a countToEnd event is NOT shifted by the relative-start offset', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 0 as Day,
countToEnd: true,
};
// in relative mode a regular event would be shifted by actualStart - plannedStart (+30),
// but a countToEnd event is anchored to its wall-clock end and stays at 150
const relativeState = {
...baseState,
mode: OffsetMode.Relative,
actualStart: 30,
plannedStart: 0,
offset: 0,
};
// sanity: a regular event in the same state is shifted to 180
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
// the countToEnd event is not shifted
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
});
test('a countToEnd event on a later day adds the day offset', () => {
const testEvent = {
timeStart: 100,
duration: 50,
delay: 0,
dayOffset: 1 as Day,
countToEnd: true,
};
// dayOffset 1 with currentDay 0 -> end normalised one day forward
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
// when the running event is already on the same day, no extra day is added
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
});
});
@@ -60,3 +60,24 @@ export function getExpectedStart(
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
return offsetStartTimeBufferedByGaps;
}
/**
* Computes the normalised expected end of an event.
* A countToEnd event is anchored to its fixed wall-clock end: it absorbs the accumulated
* runtime offset and is not shifted by the relative-start offset, so its expected end is the
* scheduled end normalised for the day (mirrors getExpectedFinish in the running timer, which
* returns the raw timeEnd). A regular event's end moves with the runtime offset.
* The result lives in the same day-normalised space as getExpectedStart (it may exceed dayInMs).
*/
export function getExpectedEnd(
event: Pick<OntimeEvent, 'timeStart' | 'duration' | 'delay' | 'dayOffset' | 'countToEnd'>,
state: Parameters<typeof getExpectedStart>[1],
): number {
if (!event.countToEnd) {
return getExpectedStart(event, state) + event.duration;
}
const delayedStart = Math.max(0, event.timeStart + event.delay);
const relativeDayOffset = event.dayOffset - state.currentDay;
return delayedStart + relativeDayOffset * dayInMs + event.duration;
}
+279 -373
View File
File diff suppressed because it is too large Load Diff