mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-23 16:09:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c000118b | |||
| daf2cf67f4 |
@@ -16,7 +16,6 @@ import {
|
|||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isOntimeMilestone,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
@@ -83,7 +82,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
defaultTimerType,
|
defaultTimerType,
|
||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
inheritGroupColour,
|
|
||||||
} = useEditorSettings();
|
} = useEditorSettings();
|
||||||
|
|
||||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
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 {
|
try {
|
||||||
await addEntryMutation([rundownId, newEntry]);
|
await addEntryMutation([rundownId, newEntry]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -264,7 +254,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
defaultTimerType,
|
defaultTimerType,
|
||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
defaultTimeStrategy,
|
defaultTimeStrategy,
|
||||||
inheritGroupColour,
|
|
||||||
addEntryMutation,
|
addEntryMutation,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ type EditorSettingsStore = {
|
|||||||
defaultDangerTime: string;
|
defaultDangerTime: string;
|
||||||
defaultTimerType: TimerType;
|
defaultTimerType: TimerType;
|
||||||
defaultEndAction: EndAction;
|
defaultEndAction: EndAction;
|
||||||
inheritGroupColour: boolean;
|
|
||||||
setDefaultDuration: (defaultDuration: string) => void;
|
setDefaultDuration: (defaultDuration: string) => void;
|
||||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
|
||||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||||
setWarnTime: (warnTime: string) => void;
|
setWarnTime: (warnTime: string) => void;
|
||||||
setDangerTime: (dangerTime: string) => void;
|
setDangerTime: (dangerTime: string) => void;
|
||||||
@@ -31,7 +29,6 @@ export const editorSettingsDefaults = {
|
|||||||
dangerTime: '00:01:00', // 60000 same as backend
|
dangerTime: '00:01:00', // 60000 same as backend
|
||||||
timerType: TimerType.CountDown,
|
timerType: TimerType.CountDown,
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
inheritGroupColour: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
enum EditorSettingsKeys {
|
enum EditorSettingsKeys {
|
||||||
@@ -42,7 +39,6 @@ enum EditorSettingsKeys {
|
|||||||
DefaultDangerTime = 'ontime-default-danger-time',
|
DefaultDangerTime = 'ontime-default-danger-time',
|
||||||
DefaultTimerType = 'ontime-default-timer-type',
|
DefaultTimerType = 'ontime-default-timer-type',
|
||||||
DefaultEndAction = 'ontime-default-end-action',
|
DefaultEndAction = 'ontime-default-end-action',
|
||||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||||
@@ -63,10 +59,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
||||||
editorSettingsDefaults.endAction,
|
editorSettingsDefaults.endAction,
|
||||||
),
|
),
|
||||||
inheritGroupColour: booleanFromLocalStorage(
|
|
||||||
EditorSettingsKeys.InheritGroupColour,
|
|
||||||
editorSettingsDefaults.inheritGroupColour,
|
|
||||||
),
|
|
||||||
|
|
||||||
setDefaultDuration: (defaultDuration) =>
|
setDefaultDuration: (defaultDuration) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
@@ -105,10 +97,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
|||||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
||||||
return { defaultEndAction };
|
return { defaultEndAction };
|
||||||
}),
|
}),
|
||||||
setInheritGroupColour: (inheritGroupColour) =>
|
|
||||||
set(() => {
|
|
||||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
|
||||||
return { inheritGroupColour };
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MILLIS_PER_MINUTE,
|
MILLIS_PER_MINUTE,
|
||||||
MILLIS_PER_SECOND,
|
MILLIS_PER_SECOND,
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
@@ -172,13 +173,15 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
) {
|
) {
|
||||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||||
|
|
||||||
|
const expectedStartState = {
|
||||||
|
totalGap: event.totalGap,
|
||||||
|
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||||
|
...state,
|
||||||
|
};
|
||||||
|
|
||||||
const expectedStart = getExpectedStart(
|
const expectedStart = getExpectedStart(
|
||||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||||
{
|
expectedStartState,
|
||||||
totalGap: event.totalGap,
|
|
||||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
|
||||||
...state,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||||
@@ -186,9 +189,7 @@ export function getExpectedTimesFromExtendedEvent(
|
|||||||
return {
|
return {
|
||||||
expectedStart,
|
expectedStart,
|
||||||
timeToStart: expectedStart - state.clock,
|
timeToStart: expectedStart - state.clock,
|
||||||
expectedEnd: event.countToEnd
|
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
|
||||||
: expectedStart + event.duration,
|
|
||||||
plannedEnd,
|
plannedEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default function RundownDefaultSettings() {
|
|||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
defaultTimerType,
|
defaultTimerType,
|
||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
inheritGroupColour,
|
|
||||||
setDefaultDuration,
|
setDefaultDuration,
|
||||||
setLinkPrevious,
|
setLinkPrevious,
|
||||||
setTimeStrategy,
|
setTimeStrategy,
|
||||||
@@ -24,7 +23,6 @@ export default function RundownDefaultSettings() {
|
|||||||
setDangerTime,
|
setDangerTime,
|
||||||
setDefaultTimerType,
|
setDefaultTimerType,
|
||||||
setDefaultEndAction,
|
setDefaultEndAction,
|
||||||
setInheritGroupColour,
|
|
||||||
} = useEditorSettings((state) => state);
|
} = useEditorSettings((state) => state);
|
||||||
|
|
||||||
const durationInMs = parseUserTime(defaultDuration);
|
const durationInMs = parseUserTime(defaultDuration);
|
||||||
@@ -46,13 +44,6 @@ export default function RundownDefaultSettings() {
|
|||||||
/>
|
/>
|
||||||
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
|
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
|
||||||
</Panel.ListItem>
|
</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.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Timer strategy'
|
title='Timer strategy'
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
|||||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
|
import { demoDb } from '../../../models/demoProject.js';
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
|
duplicateRundown,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
makeDeepClone,
|
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()', () => {
|
describe('makeDeepClone()', () => {
|
||||||
it('deep clones a group along with its nested entries', () => {
|
it('deep clones a group along with its nested entries', () => {
|
||||||
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
|
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
|
||||||
|
|||||||
@@ -15,18 +15,16 @@ import {
|
|||||||
createNewRundown,
|
createNewRundown,
|
||||||
deleteAllEntries,
|
deleteAllEntries,
|
||||||
deleteEntries,
|
deleteEntries,
|
||||||
deleteRundown,
|
|
||||||
duplicateRundown,
|
|
||||||
editEntry,
|
editEntry,
|
||||||
groupEntries,
|
groupEntries,
|
||||||
|
initRundown,
|
||||||
loadRundown,
|
loadRundown,
|
||||||
renameRundown,
|
|
||||||
renumberEntries,
|
renumberEntries,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
} from './rundown.service.js';
|
} from './rundown.service.js';
|
||||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
|
||||||
import {
|
import {
|
||||||
clonePostValidator,
|
clonePostValidator,
|
||||||
entryBatchPutValidator,
|
entryBatchPutValidator,
|
||||||
@@ -36,7 +34,6 @@ import {
|
|||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
rundownPatchValidator,
|
|
||||||
rundownPostValidator,
|
rundownPostValidator,
|
||||||
} from './rundown.validation.js';
|
} from './rundown.validation.js';
|
||||||
|
|
||||||
@@ -47,7 +44,7 @@ export const router: Router = express.Router();
|
|||||||
/**
|
/**
|
||||||
* Returns all rundowns in the project
|
* 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();
|
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||||
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
});
|
});
|
||||||
@@ -55,7 +52,7 @@ router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
|
|||||||
/**
|
/**
|
||||||
* Returns the current rundown
|
* Returns the current rundown
|
||||||
*/
|
*/
|
||||||
router.get('/current', (_req: Request, res: Response<Rundown>) => {
|
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
res.json(rundown);
|
res.json(rundown);
|
||||||
});
|
});
|
||||||
@@ -63,7 +60,7 @@ router.get('/current', (_req: Request, res: Response<Rundown>) => {
|
|||||||
/**
|
/**
|
||||||
* Returns a given rundown in its normalised client shape
|
* 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 {
|
try {
|
||||||
const rundown = getProcessedRundown(req.params.id);
|
const rundown = getProcessedRundown(req.params.id);
|
||||||
res.json(rundown);
|
res.json(rundown);
|
||||||
@@ -107,7 +104,13 @@ router.post(
|
|||||||
paramsWithId,
|
paramsWithId,
|
||||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
try {
|
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) });
|
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@@ -120,26 +123,54 @@ router.post(
|
|||||||
* Patches the data of an existing rundown
|
* Patches the data of an existing rundown
|
||||||
* Currently only the title can be changed
|
* Currently only the title can be changed
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
'/:id',
|
try {
|
||||||
rundownPatchValidator,
|
const dataProvider = getDataProvider();
|
||||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
const rundown = dataProvider.getRundown(req.params.id);
|
||||||
try {
|
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
|
||||||
const projectRundowns = await renameRundown(req.params.id, req.body.title);
|
if (!req.body.title) throw new Error('No title provided');
|
||||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
|
||||||
} catch (error) {
|
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
/**
|
||||||
|
* 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
|
* Deletes a rundown if not loaded
|
||||||
*/
|
*/
|
||||||
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||||
try {
|
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) });
|
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
} from 'ontime-types';
|
} 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 { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.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
|
* @throws if the provided id does not exist
|
||||||
*/
|
*/
|
||||||
export function loadRundown(id: string) {
|
export async function loadRundown(id: string) {
|
||||||
const dataProvider = getDataProvider();
|
const dataProvider = getDataProvider();
|
||||||
if (isCurrentRundown(id)) {
|
if (isCurrentRundown(id)) {
|
||||||
return dataProvider.getProjectRundowns();
|
return dataProvider.getProjectRundowns();
|
||||||
@@ -651,7 +651,7 @@ export function loadRundown(id: string) {
|
|||||||
|
|
||||||
const rundown = dataProvider.getRundown(id);
|
const rundown = dataProvider.getRundown(id);
|
||||||
const customField = dataProvider.getCustomFields();
|
const customField = dataProvider.getCustomFields();
|
||||||
initRundown(rundown, customField);
|
await initRundown(rundown, customField);
|
||||||
return dataProvider.getProjectRundowns();
|
return dataProvider.getProjectRundowns();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +659,11 @@ export function loadRundown(id: string) {
|
|||||||
* Sets a new rundown in the cache
|
* Sets a new rundown in the cache
|
||||||
* and marks it as the currently loaded one
|
* 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();
|
runtimeService.stop();
|
||||||
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||||
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
|
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
|
||||||
@@ -688,73 +692,3 @@ export async function createNewRundown(title: string) {
|
|||||||
|
|
||||||
return projectRundowns;
|
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 = {
|
export type IncrementNumber = {
|
||||||
integer: number;
|
integer: number;
|
||||||
faction: number;
|
faction: number;
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
|
|||||||
// #region operations on project rundowns =========================
|
// #region operations on project rundowns =========================
|
||||||
|
|
||||||
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
|
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 ======================
|
// #endregion operations on project rundowns ======================
|
||||||
// #region operations on rundown entries ==========================
|
// #region operations on rundown entries ==========================
|
||||||
|
|||||||
@@ -101,10 +101,9 @@ function getCustomFields(): Readonly<CustomFields> {
|
|||||||
return db.data.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);
|
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||||
await persist();
|
await persist();
|
||||||
return db.data.rundowns;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSettings(): Readonly<Settings> {
|
function getSettings(): Readonly<Settings> {
|
||||||
|
|||||||
@@ -245,6 +245,52 @@ describe('mutation on runtimeState', () => {
|
|||||||
expect(newState.offset.expectedRundownEnd).toBeNull();
|
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 () => {
|
test('resume restores currentDay from restore point', async () => {
|
||||||
clearState();
|
clearState();
|
||||||
const mockRundown = makeRundown({
|
const mockRundown = makeRundown({
|
||||||
@@ -956,4 +1002,32 @@ describe('loadGroupFlagAndEnd()', () => {
|
|||||||
eventNow: rundown.entries[0],
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
calculateDuration,
|
calculateDuration,
|
||||||
checkIsNow,
|
checkIsNow,
|
||||||
dayInMs,
|
dayInMs,
|
||||||
|
getExpectedEnd,
|
||||||
getExpectedStart,
|
getExpectedStart,
|
||||||
getLastEventNormal,
|
getLastEventNormal,
|
||||||
isPlaybackActive,
|
isPlaybackActive,
|
||||||
@@ -836,8 +837,8 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
const { _group } = state;
|
const { _group } = state;
|
||||||
if (_group !== null) {
|
if (_group !== null) {
|
||||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||||
const lastEventExpectedStart = getExpectedStart(lastEvent, {
|
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, {
|
||||||
currentDay: state.rundown.currentDay!,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
mode: offset.mode,
|
mode: offset.mode,
|
||||||
@@ -845,7 +846,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,8 +868,8 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
|
|
||||||
if (state._end) {
|
if (state._end) {
|
||||||
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
||||||
const expectedStart = getExpectedStart(event, {
|
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
||||||
currentDay: state.rundown.currentDay!,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
totalGap: accumulatedGap,
|
totalGap: accumulatedGap,
|
||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
mode: offset.mode,
|
mode: offset.mode,
|
||||||
@@ -877,7 +877,6 @@ function getExpectedTimes(state = runtimeState) {
|
|||||||
plannedStart,
|
plannedStart,
|
||||||
actualStart,
|
actualStart,
|
||||||
});
|
});
|
||||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,6 +919,8 @@ export function loadGroupFlagAndEnd(
|
|||||||
|
|
||||||
let accumulatedGap = 0;
|
let accumulatedGap = 0;
|
||||||
let isLinkedToLoaded = true;
|
let isLinkedToLoaded = true;
|
||||||
|
// a countToEnd event absorbs overtime and breaks the chain
|
||||||
|
let previousWasCountToEnd = false;
|
||||||
|
|
||||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||||
const entry = entries[playableEventOrder[idx]];
|
const entry = entries[playableEventOrder[idx]];
|
||||||
@@ -928,7 +929,7 @@ export function loadGroupFlagAndEnd(
|
|||||||
if (idx !== currentIndex) {
|
if (idx !== currentIndex) {
|
||||||
// we only accumulate data after the loaded event
|
// we only accumulate data after the loaded event
|
||||||
accumulatedGap += entry.gap;
|
accumulatedGap += entry.gap;
|
||||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
|
||||||
|
|
||||||
// and the loaded event is not allowed to be the next flag
|
// and the loaded event is not allowed to be the next flag
|
||||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||||
@@ -942,6 +943,8 @@ export function loadGroupFlagAndEnd(
|
|||||||
foundGroupEnd = true;
|
foundGroupEnd = true;
|
||||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousWasCountToEnd = entry.countToEnd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
|
||||||
```
|
|
||||||
@@ -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
|
// re-enter edit mode: original value should be unchanged
|
||||||
await durationCell.click();
|
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 expect(durationCell.locator('input')).toHaveValue(originalDuration);
|
||||||
await durationCell.locator('input').press('Escape');
|
await durationCell.locator('input').press('Escape');
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
|||||||
|
|
||||||
// feature business logic
|
// feature business logic
|
||||||
|
|
||||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||||
|
|
||||||
// feature business logic - rundown
|
// feature business logic - rundown
|
||||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||||
|
|||||||
+121
-1
@@ -1,7 +1,7 @@
|
|||||||
import { Day, OffsetMode } from 'ontime-types';
|
import { Day, OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||||
import { getExpectedStart } from './getExpectedStart';
|
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
||||||
|
|
||||||
describe('getExpectedStart()', () => {
|
describe('getExpectedStart()', () => {
|
||||||
describe('Absolute offset mode', () => {
|
describe('Absolute offset mode', () => {
|
||||||
@@ -315,3 +315,123 @@ describe('getExpectedStart()', () => {
|
|||||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
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 drifts when the start is compromised', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the offset pushes the start (160) past the scheduled end (150) so it can no longer
|
||||||
|
// finish on schedule - the end follows the compromised start
|
||||||
|
expect(getExpectedEnd(testEvent, { ...baseState, offset: 60 })).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 1 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the scheduled end must include the day offset (delayedStart + dayInMs + duration),
|
||||||
|
// not collapse to the day-shifted start
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||||
|
const testEvent = {
|
||||||
|
timeStart: 100,
|
||||||
|
duration: 50,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0 as Day,
|
||||||
|
countToEnd: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const relativeState = {
|
||||||
|
...baseState,
|
||||||
|
mode: OffsetMode.Relative,
|
||||||
|
actualStart: 30,
|
||||||
|
plannedStart: 0,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||||
|
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
||||||
|
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||||
|
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
+36
-1
@@ -8,7 +8,6 @@ import { dayInMs } from './conversionUtils.js';
|
|||||||
* @param currentDay the day offset of the currently running event
|
* @param currentDay the day offset of the currently running event
|
||||||
* @param totalGap accumulated gap from the current event
|
* @param totalGap accumulated gap from the current event
|
||||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||||
* @param clock
|
|
||||||
* @param offset
|
* @param offset
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
@@ -60,3 +59,39 @@ export function getExpectedStart(
|
|||||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||||
return offsetStartTimeBufferedByGaps;
|
return offsetStartTimeBufferedByGaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param event the event that we are counting to
|
||||||
|
* @param currentDay the day offset of the currently running event
|
||||||
|
* @param totalGap accumulated gap from the current event
|
||||||
|
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||||
|
* @param offset
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function getExpectedEnd(
|
||||||
|
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
|
||||||
|
state: {
|
||||||
|
currentDay: number; // the current day from the rundown
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedToLoaded: boolean;
|
||||||
|
offset: number;
|
||||||
|
mode: OffsetMode;
|
||||||
|
actualStart: MaybeNumber;
|
||||||
|
plannedStart: MaybeNumber;
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
// expected start encodes the offset from current delays
|
||||||
|
const expectedStart = getExpectedStart(event, state);
|
||||||
|
|
||||||
|
// count to end events should finish on schedule unlesss the start is compromised
|
||||||
|
if (event.countToEnd) {
|
||||||
|
// the scheduled end, normalised to the same day-space as expectedStart
|
||||||
|
// (a raw timeStart + duration would miss the day offset on multi-day rundowns)
|
||||||
|
const delayedStart = Math.max(0, event.timeStart + event.delay);
|
||||||
|
const relativeDayOffset = event.dayOffset - state.currentDay;
|
||||||
|
const scheduledEnd = delayedStart + relativeDayOffset * dayInMs + event.duration;
|
||||||
|
return Math.max(expectedStart, scheduledEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedStart + event.duration;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user