mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c000118b | |||
| daf2cf67f4 | |||
| 6633cfc869 | |||
| d127367df9 | |||
| b21ec41ffd | |||
| 66292bc0cf |
+15
-15
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.3.0",
|
"@base-ui/react": "1.6.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"@codemirror/commands": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -12,27 +12,27 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.6",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@mantine/hooks": "^8.3.7",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.2.0",
|
"@sentry/react": "^10.59.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.85.9",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.18.0",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.7",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.7.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.7",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.72.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.6.0",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.17.0",
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ describe('getRouteFromPreset()', () => {
|
|||||||
options: {},
|
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', () => {
|
it('checks if the current location matches an enabled preset', () => {
|
||||||
// we make the current location be the alias
|
// we make the current location be the alias
|
||||||
@@ -47,6 +56,16 @@ describe('getRouteFromPreset()', () => {
|
|||||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
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', () => {
|
it('returns null when already on a preset path', () => {
|
||||||
const location = resolvePath('/preset/demopage');
|
const location = resolvePath('/preset/demopage');
|
||||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||||
@@ -86,6 +105,11 @@ describe('getRouteFromPreset()', () => {
|
|||||||
const location = resolvePath('/demopage?n=1&token=123');
|
const location = resolvePath('/demopage?n=1&token=123');
|
||||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=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', () => {
|
describe('cuesheet presets', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||||
};
|
};
|
||||||
|
|||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
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);
|
|
||||||
+5
-24
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
||||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
@@ -8,36 +8,19 @@ interface MultiLineCellProps {
|
|||||||
fieldId?: string;
|
fieldId?: string;
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
handleUpdate: (newValue: string) => void;
|
handleUpdate: (newValue: string) => void;
|
||||||
handleCancelUpdate?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultiLineCell = forwardRef(function MultiLineCell(
|
export default memo(MultiLineCell);
|
||||||
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
|
|
||||||
inputRef,
|
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
|
||||||
) {
|
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
submitOnCtrlEnter: true,
|
submitOnCtrlEnter: true,
|
||||||
allowKeyboardNavigation: 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 (
|
return (
|
||||||
<AutoTextarea
|
<AutoTextarea
|
||||||
inputref={ref}
|
inputref={ref}
|
||||||
@@ -53,6 +36,4 @@ const MultiLineCell = forwardRef(function MultiLineCell(
|
|||||||
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export default memo(MultiLineCell);
|
|
||||||
|
|||||||
+1
-20
@@ -1,8 +1,6 @@
|
|||||||
/* element matching input styles */
|
/* element matching input styles */
|
||||||
.textInput {
|
.textInput {
|
||||||
width: 100%;
|
height: 2rem;
|
||||||
height: 100%;
|
|
||||||
min-height: 2rem;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
@@ -34,21 +32,4 @@
|
|||||||
background-color: $gray-1100;
|
background-color: $gray-1100;
|
||||||
cursor: text;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-15
@@ -8,23 +8,11 @@ interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
|||||||
offset?: 'over' | 'under' | 'muted' | null;
|
offset?: 'over' | 'under' | 'muted' | null;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
disabled?: 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(
|
const TextLikeInput = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
||||||
offset,
|
|
||||||
muted,
|
|
||||||
disabled,
|
|
||||||
multiline,
|
|
||||||
topAligned,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...elementProps
|
|
||||||
}: PropsWithChildren<TextLikeInputProps>,
|
|
||||||
textRef,
|
textRef,
|
||||||
) => {
|
) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -33,8 +21,6 @@ const TextLikeInput = forwardRef(
|
|||||||
offset && style[offset],
|
offset && style[offset],
|
||||||
muted && style.muted,
|
muted && style.muted,
|
||||||
disabled && style.disabled,
|
disabled && style.disabled,
|
||||||
multiline && style.multiline,
|
|
||||||
topAligned && style.topAligned,
|
|
||||||
className,
|
className,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -9,11 +9,12 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
|||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||||
import DurationInput from './DurationInput';
|
import DurationInput from './DurationInput';
|
||||||
import EditableCell from './EditableCell';
|
|
||||||
import EditableImage from './EditableImage';
|
import EditableImage from './EditableImage';
|
||||||
import FlagCell from './FlagCell';
|
import FlagCell from './FlagCell';
|
||||||
import GhostedText from './GhostedText';
|
import GhostedText from './GhostedText';
|
||||||
|
import MultiLineCell from './MultiLineCell';
|
||||||
import MutedText from './MutedText';
|
import MutedText from './MutedText';
|
||||||
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||||
@@ -150,8 +151,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableCell
|
<MultiLineCell
|
||||||
multiline
|
|
||||||
initialValue={initialValue as string}
|
initialValue={initialValue as string}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableCell
|
<SingleLineCell
|
||||||
initialValue={initialValue as string}
|
initialValue={initialValue as string}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
@@ -238,8 +238,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableCell
|
<MultiLineCell
|
||||||
multiline
|
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.6",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "5.1.0",
|
"express": "5.2.1",
|
||||||
"express-static-gzip": "3.0.0",
|
"express-static-gzip": "3.0.1",
|
||||||
"express-validator": "7.2.1",
|
"express-validator": "7.3.2",
|
||||||
"fast-equals": "^5.0.1",
|
"fast-equals": "^6.0.0",
|
||||||
"google-auth-library": "^9.4.2",
|
"google-auth-library": "^9.4.2",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"multer": "2.1.0",
|
"multer": "2.2.0",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"osc-min": "2.1.2",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "1.4.9",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.6",
|
||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,56 +57,48 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
|||||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||||
await expect(firstEvent).toBeVisible();
|
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 cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
|
||||||
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
||||||
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. clicking a single line text cell opens the editor (mounted on demand)
|
* 1. focus a cell in the datagrid single line text
|
||||||
* submitting with Enter closes the editor and returns focus to the parent cell
|
* submitting the data returns the focus to the parent
|
||||||
*/
|
*/
|
||||||
await titleCell.click();
|
await titleEditor.click();
|
||||||
await expect(titleEditor).toBeFocused();
|
await expect(titleEditor).toBeFocused();
|
||||||
const updatedTitle = `focus-title-${Date.now()}`;
|
const updatedTitle = `focus-title-${Date.now()}`;
|
||||||
await titleEditor.fill(updatedTitle);
|
await titleEditor.fill(updatedTitle);
|
||||||
await titleEditor.press('Enter');
|
await titleEditor.press('Enter');
|
||||||
await expect(titleEditor).toHaveCount(0);
|
await expect(titleEditor).not.toBeFocused();
|
||||||
await expect(titleCell).toContainText(updatedTitle);
|
await expect(titleEditor).toHaveValue(updatedTitle);
|
||||||
await expect(titleCell).toBeFocused();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. navigate to the multiline text cell with the keyboard and open it with Enter
|
* 2. navigate and modify multiline text cell
|
||||||
* submitting works with ctrl/cmd + enter and the focus returns to the parent
|
* submitting works with ctrl/cmd + enter and the focus returns to the parent
|
||||||
*/
|
*/
|
||||||
await page.keyboard.press('ArrowRight');
|
await page.keyboard.press('ArrowRight');
|
||||||
await expect(noteCell).toBeFocused();
|
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await expect(noteEditor).toBeFocused();
|
await expect(noteEditor).toBeFocused();
|
||||||
const updatedNote = `focus-note-${Date.now()}`;
|
const updatedNote = `focus-note-${Date.now()}`;
|
||||||
await noteEditor.fill(updatedNote);
|
await noteEditor.fill(updatedNote);
|
||||||
await noteEditor.press('ControlOrMeta+Enter');
|
await noteEditor.press('ControlOrMeta+Enter');
|
||||||
await expect(noteEditor).toHaveCount(0);
|
await expect(noteEditor).not.toBeFocused();
|
||||||
await expect(noteCell).toContainText(updatedNote);
|
await expect(noteEditor).toHaveValue(updatedNote);
|
||||||
await expect(noteCell).toBeFocused();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3. navigating back returns focus to the title cell
|
* 2. navigate and modify single line text cell again
|
||||||
* opening the cue cell and pressing escape cancels the edit and reverts the value
|
* pressing escape cancels the edit and the focus returns to the parent
|
||||||
*/
|
*/
|
||||||
await page.keyboard.press('ArrowLeft');
|
await page.keyboard.press('ArrowLeft');
|
||||||
await expect(titleCell).toBeFocused();
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(titleEditor).toBeFocused();
|
||||||
await cueCell.click();
|
|
||||||
await expect(cueEditor).toBeFocused();
|
|
||||||
const cueBeforeCancel = await cueEditor.inputValue();
|
const cueBeforeCancel = await cueEditor.inputValue();
|
||||||
|
await cueEditor.click();
|
||||||
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
||||||
await cueEditor.press('Escape');
|
await cueEditor.press('Escape');
|
||||||
await expect(cueEditor).toHaveCount(0);
|
await expect(cueEditor).not.toBeFocused();
|
||||||
await expect(cueCell).toContainText(cueBeforeCancel);
|
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||||
await expect(cueCell).toBeFocused();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
test('cuesheet background edit from empty state', async ({ page }) => {
|
||||||
@@ -119,8 +111,8 @@ test('cuesheet background edit from empty state', async ({ page }) => {
|
|||||||
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
||||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
await page.getByRole('button', { name: 'Create rundown' }).click();
|
||||||
|
|
||||||
// edit it in the cuesheet (scope to the rundown we just created so retries stay isolated)
|
// edit it in the cuesheet
|
||||||
await page.getByRole('row', { name: emptyName }).getByTestId('rundown_menu').click();
|
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
||||||
await page.getByText('Edit in cuesheet').click();
|
await page.getByText('Edit in cuesheet').click();
|
||||||
|
|
||||||
// expect to see and empty screen
|
// expect to see and empty screen
|
||||||
|
|||||||
@@ -231,10 +231,9 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
|
|
||||||
// Verify that the title is visible and editable
|
// Verify that the title is visible and editable
|
||||||
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
||||||
// the editor mounts on demand: clicking the cell opens it
|
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
|
||||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
await titleEditor.click();
|
||||||
await firstEvent.getByTestId('cuesheet-cell-title').click();
|
await expect(titleEditor).toBeEditable();
|
||||||
await expect(firstEvent.getByTestId('cuesheet-editor-title')).toBeEditable();
|
|
||||||
|
|
||||||
// other elements are not there
|
// other elements are not there
|
||||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@
|
|||||||
"devEngines": {
|
"devEngines": {
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"name": "node",
|
"name": "node",
|
||||||
"version": "22.22.2"
|
"version": "22.22.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Generated
+373
-279
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user