mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8885d0b7 | |||
| dd8cfef4de | |||
| bdd4f75c4a | |||
| 3a740ec713 | |||
| dcdfd9ca85 | |||
| d17a7e9321 | |||
| c960a983a0 | |||
| 7498abb326 | |||
| a0d72d1776 | |||
| eab63e838d |
+15
-15
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
};
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TextLikeInput from './TextLikeInput';
|
||||
|
||||
interface EditableCellProps {
|
||||
initialValue: string;
|
||||
multiline?: boolean;
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
interface FocusableEditor {
|
||||
focus: () => void;
|
||||
select?: () => void;
|
||||
}
|
||||
|
||||
interface FocusableDisplay {
|
||||
focusParentElement: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mounts the text editor for a cell.
|
||||
*
|
||||
* Mounting an `<input>`/`<textarea>` editor (with its reactive-input hooks and autosize) for every
|
||||
* cell is expensive when many rows mount at once during virtualised scroll. While the cell is not
|
||||
* being edited we render a lightweight, focusable display element and only mount the real editor
|
||||
* when the user clicks/focuses the cell — mirroring how the time/duration cells already behave.
|
||||
*
|
||||
* On exit we return focus to the parent cell (through the display element, in a layout effect once
|
||||
* it is back in the DOM) so the table keyboard navigation keeps working — the editor is unmounted
|
||||
* by then, so we cannot rely on its own ref.
|
||||
*/
|
||||
function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpdate }: EditableCellProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const wasEditing = useRef(false);
|
||||
const editorRef = useRef<FocusableEditor | null>(null);
|
||||
const displayRef = useRef<FocusableDisplay | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isEditing) {
|
||||
// focus the editor once it mounts on entering edit mode
|
||||
editorRef.current?.focus();
|
||||
editorRef.current?.select?.();
|
||||
} else if (wasEditing.current) {
|
||||
// returning from edit: hand focus back to the cell so table keyboard navigation continues
|
||||
displayRef.current?.focusParentElement();
|
||||
}
|
||||
wasEditing.current = isEditing;
|
||||
}, [isEditing]);
|
||||
|
||||
const enterEdit = useCallback(() => setIsEditing(true), []);
|
||||
const exitEdit = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(newValue: string) => {
|
||||
setIsEditing(false);
|
||||
handleUpdate(newValue);
|
||||
},
|
||||
[handleUpdate],
|
||||
);
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<TextLikeInput
|
||||
ref={displayRef}
|
||||
onClick={enterEdit}
|
||||
onFocus={enterEdit}
|
||||
multiline={multiline}
|
||||
topAligned
|
||||
aria-label={fieldLabel ? `${fieldLabel} cell` : undefined}
|
||||
>
|
||||
{initialValue}
|
||||
</TextLikeInput>
|
||||
);
|
||||
}
|
||||
|
||||
return multiline ? (
|
||||
<MultiLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={exitEdit}
|
||||
/>
|
||||
) : (
|
||||
<SingleLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={exitEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EditableCell);
|
||||
+24
-5
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -8,19 +8,36 @@ interface MultiLineCellProps {
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
handleCancelUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
|
||||
const MultiLineCell = forwardRef(function MultiLineCell(
|
||||
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
|
||||
inputRef,
|
||||
) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
allowKeyboardNavigation: true,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
});
|
||||
|
||||
// expose focus to the parent so the editor can be focused when mounted on demand
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
() => ({
|
||||
focus() {
|
||||
ref.current?.focus();
|
||||
},
|
||||
select() {
|
||||
ref.current?.select();
|
||||
},
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoTextarea
|
||||
inputref={ref}
|
||||
@@ -36,4 +53,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
|
||||
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
+20
-1
@@ -1,6 +1,8 @@
|
||||
/* element matching input styles */
|
||||
.textInput {
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 2rem;
|
||||
background-color: transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
text-wrap: nowrap;
|
||||
@@ -32,4 +34,21 @@
|
||||
background-color: $gray-1100;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// single-line placeholder: keep the glyph in a 2rem band at the top of the cell so it lines up
|
||||
// with the editor (which mounts top-aligned) and does not jump when the cell is taller than 2rem
|
||||
&.topAligned:not(.multiline) {
|
||||
align-items: flex-start;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
height: auto;
|
||||
min-height: 100%; // fill the cell so the whole area is clickable (grows with content)
|
||||
text-wrap: wrap;
|
||||
white-space: break-spaces;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -8,11 +8,23 @@ interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
offset?: 'over' | 'under' | 'muted' | null;
|
||||
muted?: boolean;
|
||||
disabled?: boolean;
|
||||
multiline?: boolean;
|
||||
/** keep the content at the top of the cell (matches an editor that mounts top-aligned) */
|
||||
topAligned?: boolean;
|
||||
}
|
||||
|
||||
const TextLikeInput = forwardRef(
|
||||
(
|
||||
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
||||
{
|
||||
offset,
|
||||
muted,
|
||||
disabled,
|
||||
multiline,
|
||||
topAligned,
|
||||
children,
|
||||
className,
|
||||
...elementProps
|
||||
}: PropsWithChildren<TextLikeInputProps>,
|
||||
textRef,
|
||||
) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@@ -21,6 +33,8 @@ const TextLikeInput = forwardRef(
|
||||
offset && style[offset],
|
||||
muted && style.muted,
|
||||
disabled && style.disabled,
|
||||
multiline && style.multiline,
|
||||
topAligned && style.topAligned,
|
||||
className,
|
||||
]);
|
||||
|
||||
|
||||
+6
-5
@@ -9,12 +9,11 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableCell from './EditableCell';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
import GhostedText from './GhostedText';
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
@@ -151,7 +150,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleLineCell
|
||||
<EditableCell
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -238,7 +238,8 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
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');
|
||||
|
||||
@@ -59,48 +57,56 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||
await expect(firstEvent).toBeVisible();
|
||||
|
||||
const cueCell = firstEvent.getByTestId('cuesheet-cell-cue');
|
||||
const titleCell = firstEvent.getByTestId('cuesheet-cell-title');
|
||||
const noteCell = firstEvent.getByTestId('cuesheet-cell-note');
|
||||
const cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
|
||||
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
||||
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
||||
|
||||
/**
|
||||
* 1. focus a cell in the datagrid single line text
|
||||
* submitting the data returns the focus to the parent
|
||||
* 1. clicking a single line text cell opens the editor (mounted on demand)
|
||||
* submitting with Enter closes the editor and returns focus to the parent cell
|
||||
*/
|
||||
await titleEditor.click();
|
||||
await titleCell.click();
|
||||
await expect(titleEditor).toBeFocused();
|
||||
const updatedTitle = `focus-title-${Date.now()}`;
|
||||
await titleEditor.fill(updatedTitle);
|
||||
await titleEditor.press('Enter');
|
||||
await expect(titleEditor).not.toBeFocused();
|
||||
await expect(titleEditor).toHaveValue(updatedTitle);
|
||||
await expect(titleEditor).toHaveCount(0);
|
||||
await expect(titleCell).toContainText(updatedTitle);
|
||||
await expect(titleCell).toBeFocused();
|
||||
|
||||
/**
|
||||
* 2. navigate and modify multiline text cell
|
||||
* 2. navigate to the multiline text cell with the keyboard and open it with Enter
|
||||
* submitting works with ctrl/cmd + enter and the focus returns to the parent
|
||||
*/
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(noteCell).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(noteEditor).toBeFocused();
|
||||
const updatedNote = `focus-note-${Date.now()}`;
|
||||
await noteEditor.fill(updatedNote);
|
||||
await noteEditor.press('ControlOrMeta+Enter');
|
||||
await expect(noteEditor).not.toBeFocused();
|
||||
await expect(noteEditor).toHaveValue(updatedNote);
|
||||
await expect(noteEditor).toHaveCount(0);
|
||||
await expect(noteCell).toContainText(updatedNote);
|
||||
await expect(noteCell).toBeFocused();
|
||||
|
||||
/**
|
||||
* 2. navigate and modify single line text cell again
|
||||
* pressing escape cancels the edit and the focus returns to the parent
|
||||
* 3. navigating back returns focus to the title cell
|
||||
* opening the cue cell and pressing escape cancels the edit and reverts the value
|
||||
*/
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(titleEditor).toBeFocused();
|
||||
await expect(titleCell).toBeFocused();
|
||||
|
||||
await cueCell.click();
|
||||
await expect(cueEditor).toBeFocused();
|
||||
const cueBeforeCancel = await cueEditor.inputValue();
|
||||
await cueEditor.click();
|
||||
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
||||
await cueEditor.press('Escape');
|
||||
await expect(cueEditor).not.toBeFocused();
|
||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||
await expect(cueEditor).toHaveCount(0);
|
||||
await expect(cueCell).toContainText(cueBeforeCancel);
|
||||
await expect(cueCell).toBeFocused();
|
||||
});
|
||||
|
||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
@@ -113,8 +119,8 @@ test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
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();
|
||||
// edit it in the cuesheet (scope to the rundown we just created so retries stay isolated)
|
||||
await page.getByRole('row', { name: emptyName }).getByTestId('rundown_menu').click();
|
||||
await page.getByText('Edit in cuesheet').click();
|
||||
|
||||
// expect to see and empty screen
|
||||
|
||||
@@ -231,9 +231,10 @@ test.describe('Sharing from cuesheet', () => {
|
||||
|
||||
// Verify that the title is visible and editable
|
||||
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
||||
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
|
||||
await titleEditor.click();
|
||||
await expect(titleEditor).toBeEditable();
|
||||
// the editor mounts on demand: clicking the cell opens it
|
||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||
await firstEvent.getByTestId('cuesheet-cell-title').click();
|
||||
await expect(firstEvent.getByTestId('cuesheet-editor-title')).toBeEditable();
|
||||
|
||||
// other elements are not there
|
||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "22.22.3"
|
||||
"version": "22.22.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+279
-373
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user