refactor: UI for linking events (#763)

This commit is contained in:
Carlos Valente
2024-02-11 15:18:04 +01:00
committed by GitHub
parent 12f81c63ce
commit 5355e45b80
49 changed files with 1353 additions and 596 deletions
+5 -5
View File
@@ -60,7 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.10.2",
"@sentry/vite-plugin": "^2.14.0",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
@@ -86,10 +86,10 @@
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^5.2.2",
"vite": "^5.0.10",
"vite-plugin-compression2": "^0.11.0",
"vite": "^5.1.0",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^4.2.2",
"vitest": "^1.0.4"
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.2.2"
}
}
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
@@ -1,8 +1,4 @@
$input-font-size: 15px;
$input-delayed-border-color: #E69056;
.timeInput {
font-size: $input-font-size;
letter-spacing: 1px;
width: 6.5em;
}
}
@@ -4,6 +4,7 @@ import { millisToString } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
@@ -11,11 +12,12 @@ interface TimeInputProps<T extends string> {
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder: string;
disabled?: boolean;
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -118,14 +120,16 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClass = className ? className : style.timeInput;
const timeInputClasses = cx([style.timeInput, className]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClass}
className={timeInputClasses}
fontSize='1rem'
type='text'
placeholder={placeholder}
variant='ontime-filled'
@@ -1,24 +1,12 @@
$input-font-size: 1rem;
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
.inputLeft,
.inputButton {
aspect-ratio: 1;
}
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
width: 7.5em;
padding: 0 0 0 2.6em;
}
.inputButton {
border-radius: 2px 0 0 2px;
}
}
.inputField {
max-width: 7.75em;
}
@@ -1,4 +1,5 @@
import { Button, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
import { PropsWithChildren } from 'react';
import { InputGroup, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import { cx } from '../../../utils/styleUtils';
@@ -12,30 +13,28 @@ interface TimeInputWithButtonProps<T extends string> {
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: TimeInputWithButtonProps<T>) {
const { name, submitHandler, time, hasDelay, placeholder } = props;
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<InputLeftElement className={style.inputLeft}>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<Button size='sm' variant='ontime-subtle-white' className={style.inputButton} tabIndex={-1}>
{placeholder.charAt(0)}
</Button>
</Tooltip>
</InputLeftElement>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
/>
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
disabled={disabled}
/>
</Tooltip>
{children}
</InputGroup>
);
}
+42 -2
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { reorderArray, swapEventData } from 'ontime-utils';
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
@@ -186,6 +186,7 @@ export const useEventAction = () => {
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
} else {
@@ -205,6 +206,44 @@ export const useEventAction = () => {
[_updateEventMutation, queryClient],
);
/**
* Toggles link of an event to the previous
*/
const linkTimer = useCallback(
async (eventId: string, linkStart: MaybeString) => {
let newEvent: Partial<OntimeEvent> = { id: eventId };
if (!linkStart) {
newEvent.linkStart = null;
} else {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown) {
return;
}
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
if (!isOntimeEvent(currentEvent)) {
return;
}
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
if (!previousEvent) {
newEvent.linkStart = null;
} else {
newEvent.linkStart = previousEvent.id;
const timePatch = getLinkedTimes(currentEvent, previousEvent);
newEvent = { ...newEvent, ...timePatch };
}
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
@@ -509,6 +548,7 @@ export const useEventAction = () => {
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
linkTimer,
reorderEvent,
swapEvents,
updateEvent,
@@ -1,7 +1,4 @@
import {
forgivingStringToMillis,
millisToDelayString,
} from '../dateConfig';
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
@@ -270,10 +267,10 @@ describe('millisToDelayString()', () => {
});
describe('converts values in seconds', () => {
it('shows a simple string with value in seconds', () => {
expect(millisToDelayString(10000)).toBe('+10 sec');
expect(millisToDelayString(10000, true)).toBe('+10 sec');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-10000)).toBe('-10 sec');
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
});
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
@@ -287,32 +284,32 @@ describe('millisToDelayString()', () => {
describe('converts values in minutes', () => {
it('shows a simple string with value in minutes', () => {
expect(millisToDelayString(720000)).toBe('+12 min');
expect(millisToDelayString(720000, true)).toBe('+12 min');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-720000)).toBe('-12 min');
expect(millisToDelayString(-720000, true)).toBe('-12 min');
});
it('shows a simple string with value in minutes and seconds', () => {
expect(millisToDelayString(630000)).toBe('+00:10:30');
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
});
it('... and its negative counterpart', () => {
expect(millisToDelayString(-630000)).toBe('-00:10:30');
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
});
const underAnHour = [60000, 360000, 720000];
underAnHour.forEach((value) => {
it(`handles ${value}`, () => {
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
});
});
});
describe('converts values with full time string', () => {
it('positive added time', () => {
expect(millisToDelayString(45015000)).toBe('+12:30:15');
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
});
it('negative added time', () => {
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
});
});
});
+6 -4
View File
@@ -155,19 +155,21 @@ export const forgivingStringToMillis = (value: string): number => {
return millis;
};
export function millisToDelayString(millis: number | null): undefined | string | null {
export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
if (millis == null || millis === 0) {
return null;
}
const isNegative = millis < 0;
const absMillis = Math.abs(millis);
const delayed = small ? '+' : 'delayed by ';
const ahead = small ? '-' : 'ahead by ';
if (absMillis < MILLIS_PER_MINUTE) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
} else {
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
}
}
@@ -21,6 +21,8 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
linkStart: event.linkStart,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
@@ -25,7 +25,8 @@ $table-header-font-size: calc(1rem - 3px);
display: flex;
}
th, td {
th,
td {
margin: 1px;
font-weight: inherit;
font-size: inherit;
@@ -102,6 +103,9 @@ $table-header-font-size: calc(1rem - 3px);
position: sticky;
left: 47.5%; // center of the screen, ish
padding: 0.5rem 0;
&:first-letter {
text-transform: uppercase;
}
}
}
@@ -126,6 +126,8 @@ export default function RundownEntry(props: RundownEntryProps) {
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
timeStrategy={data.timeStrategy}
linkStart={data.linkStart}
eventId={data.id}
isPublic={data.isPublic}
endAction={data.endAction}
@@ -125,12 +125,6 @@ $skip-opacity: 0.1;
display: flex;
gap: $block-clearance;
height: 100%;
.timerNote {
color: $blue-500;
margin-right: $block-clearance;
font-size: 1.5em;
}
}
.eventTitle {
@@ -143,6 +137,7 @@ $skip-opacity: 0.1;
.eventActions {
grid-area: actions;
height: 100%;
text-align: end;
}
.progressBg {
@@ -7,7 +7,7 @@ import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import copyToClipboard from '../../../common/utils/copyToClipboard';
@@ -26,6 +26,8 @@ interface EventBlockProps {
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
eventId: string;
eventIndex: number;
isPublic: boolean;
@@ -61,6 +63,8 @@ export default function EventBlock(props: EventBlockProps) {
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
isPublic = true,
eventIndex,
endAction,
@@ -251,6 +255,8 @@ export default function EventBlock(props: EventBlockProps) {
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
linkStart={linkStart}
timeStrategy={timeStrategy}
eventId={eventId}
eventIndex={eventIndex}
isPublic={isPublic}
@@ -9,16 +9,16 @@ import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar';
import EventBlockTimers from './composite/EventBlockTimers';
import style from './EventBlock.module.scss';
@@ -30,6 +30,8 @@ interface EventBlockInnerProps {
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
eventId: string;
eventIndex: number;
isPublic: boolean;
@@ -51,6 +53,8 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
eventId,
isPublic = true,
endAction,
@@ -84,7 +88,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
return !renderInner ? null : (
<>
<EventBlockTimers eventId={eventId} timeStart={timeStart} timeEnd={timeEnd} duration={duration} delay={delay} />
<div className={style.eventTimers}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
/>
</div>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{next && (
<Tooltip label='Next event' {...tooltipProps}>
@@ -99,7 +113,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
selected={selected}
disablePlayback={skip || isRolling}
/>
<div className={style.statusElements}>
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
<span className={style.eventNote}>{note}</span>
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{selected && <EventBlockProgressBar playback={playback} />}
@@ -117,10 +131,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span>
<IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-ispublic={isPublic}
/>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span>
</Tooltip>
</div>
@@ -13,7 +13,7 @@ function formatDelay(timeStart: number, delay: number): string | undefined {
const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start: ${timeTag}`;
return `New start ${timeTag}`;
}
function formatOverlap(previousEnd: number | null, timeStart: number): string | undefined {
@@ -1,83 +0,0 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { IoAlertCircleOutline } from '@react-icons/all-files/io5/IoAlertCircleOutline';
import { OntimeEvent } from 'ontime-types';
import TimeInputWithButton from '../../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from '../EventBlock.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride'; // we call it durationOverride to stop from passing as a duration value
const EventBlockTimers = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, delay } = props;
const { updateEvent, updateTimer } = useEventAction();
// In sync with EventEditorTimes
const handleSubmit = (field: TimeActions, value: string) => {
if (field === 'timeStart' || field === 'timeEnd') {
updateTimer(eventId, field, value);
return;
}
if (field === 'durationOverride') {
const timeInMillis = forgivingStringToMillis(value);
const newEventData: Partial<OntimeEvent> = { id: eventId, timeEnd: timeStart + timeInMillis };
updateEvent(newEventData);
return;
}
};
const overMidnight = timeStart > timeEnd;
const hasDelay = delay !== 0;
return (
<div className={style.eventTimers}>
<TimeInputWithButton<TimeActions>
name='timeStart'
submitHandler={handleSubmit}
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
/>
<TimeInputWithButton<TimeActions>
name='timeEnd'
submitHandler={handleSubmit}
time={timeEnd}
hasDelay={hasDelay}
placeholder='End'
/>
<TimeInputWithButton<TimeActions>
name='durationOverride'
submitHandler={handleSubmit}
time={duration}
placeholder='Duration'
/>
{overMidnight && (
<div className={style.timerNote}>
<Tooltip
label='End timer before start'
openDelay={tooltipDelayFast}
variant='ontime-ondark'
shouldWrapChildren
>
<IoAlertCircleOutline />
</Tooltip>
</div>
)}
</div>
);
};
export default memo(EventBlockTimers);
@@ -17,7 +17,6 @@
flex-direction: column;
gap: 2rem;
overflow-y: auto;
}
.footer {
@@ -45,9 +44,15 @@
font-size: calc(1rem - 3px);
color: $label-gray;
margin-bottom: 0.25rem;
}
&.delayLabel {
color: $ontime-delay-text;
.delayLabel {
font-size: calc(1rem - 3px);
color: $ontime-delay-text;
margin-top: 0.25rem;
&::after {
content: '\200b';
}
}
@@ -55,8 +60,7 @@
font-size: calc(1rem - 3px);
color: $label-gray;
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.5rem;
max-width: max-content;
cursor: pointer;
}
@@ -100,6 +100,8 @@ export default function EventEditor() {
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
@@ -1,13 +1,12 @@
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import TimeInputWithButton from '../../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { forgivingStringToMillis, millisToDelayString } from '../../../../common/utils/dateConfig';
import { cx } from '../../../../common/utils/styleUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
@@ -16,6 +15,8 @@ interface EventEditorTimesProps {
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
delay: number;
isPublic: boolean;
endAction: EndAction;
@@ -25,27 +26,23 @@ interface EventEditorTimesProps {
}
type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride'; // we call it durationOverride to stop from passing as a duration value
const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType, timeWarning, timeDanger } =
props;
const { updateEvent, updateTimer } = useEventAction();
// In sync with EventBlockTimers
const handleTimeSubmit = (field: TimeActions, value: string) => {
if (field === 'timeStart' || field === 'timeEnd') {
updateTimer(eventId, field, value);
return;
}
if (field === 'durationOverride') {
const timeInMillis = forgivingStringToMillis(value);
const newEventData: Partial<OntimeEvent> = { id: eventId, timeEnd: timeStart + timeInMillis };
updateEvent(newEventData);
return;
}
};
const {
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
delay,
isPublic,
endAction,
timerType,
timeWarning,
timeDanger,
} = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') {
@@ -66,49 +63,28 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
};
const hasDelay = delay !== 0;
const delayTime = hasDelay ? millisToDelayString(delay) : null;
const startLabel = delayTime ? `New Start ${millisToString(timeStart + delay)}` : 'Start time';
const endLabel = delayTime ? `New End ${millisToString(timeEnd + delay)}` : 'End time';
const inputTimeLabels = cx([style.inputLabel, hasDelay ? style.delayLabel : null]);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay)}. New schedule ${millisToString(timeStart + delay)}${millisToString(
timeEnd + delay,
)}`
: '';
return (
<div className={style.column}>
<div className={style.inline}>
<div>
<label className={inputTimeLabels} htmlFor='timeStart'>
{startLabel}
</label>
<TimeInputWithButton
name='timeStart'
submitHandler={handleTimeSubmit}
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
/>
</div>
<div>
<label className={inputTimeLabels} htmlFor='timeEnd'>
{endLabel}
</label>
<TimeInputWithButton
name='timeEnd'
submitHandler={handleTimeSubmit}
time={timeEnd}
hasDelay={hasDelay}
placeholder='End'
/>
</div>
<div>
<label className={style.inputLabel} htmlFor='durationOverride'>
Duration
</label>
<TimeInputWithButton
name='durationOverride'
submitHandler={handleTimeSubmit}
time={duration}
placeholder='Duration'
<>
<div>
<div className={style.inputLabel}>Event schedule</div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
<div className={style.splitTwo}>
@@ -159,11 +135,11 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
<div>
<span className={style.inputLabel}>Event Visibility</span>
<label className={style.switchLabel}>
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
<Switch size='sm' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
{isPublic ? 'Public' : 'Private'}
</label>
</div>
</div>
</>
);
};
@@ -0,0 +1,29 @@
.timeLabel {
position: absolute;
z-index: 100;
font-size: 0.75rem;
font-weight: 600;
top: 2px;
right: 4px;
color: inherit;
}
.timeAction {
opacity: 0.4;
cursor: pointer;
padding-right: 0.5em;
&.active {
opacity: 1;
color: var(--status-color-active-override, $active-indicator);
}
.fourtyfive {
transform: rotate(-45deg);
}
}
.timerNote {
color: $blue-500;
margin-right: 0.5rem;
font-size: 1.5em;
}
@@ -0,0 +1,126 @@
import { memo } from 'react';
import { InputRightElement, Tooltip } from '@chakra-ui/react';
import { IoAlertCircleOutline } from '@react-icons/all-files/io5/IoAlertCircleOutline';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import style from './TimeInputFlow.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
delay: number;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
const TimeInputFlow = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
const { updateEvent, updateTimer, linkTimer } = useEventAction();
// In sync with EventEditorTimes
const handleSubmit = (field: TimeActions, value: string) => {
updateTimer(eventId, field, value);
};
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
const newEvent: Partial<OntimeEvent> = { id: eventId, timeStrategy };
updateEvent(newEvent);
};
const handleLink = (doLink: boolean) => {
// the string doesnt mean much for now, not more than an intent to link
// we imagine that we can leverage this to create offsets p+10
linkTimer(eventId, doLink ? 'p' : null);
};
const overMidnight = timeStart > timeEnd;
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
return (
<>
<TimeInputWithButton<TimeActions>
name='timeStart'
submitHandler={handleSubmit}
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
disabled={Boolean(linkStart)}
>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
<span className={style.timeLabel}>S</span>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</InputRightElement>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
name='timeEnd'
submitHandler={handleSubmit}
time={timeEnd}
hasDelay={hasDelay}
disabled={isLockedDuration}
placeholder='End'
>
<InputRightElement
className={activeEnd}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
<span className={style.timeLabel}>E</span>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
name='duration'
submitHandler={handleSubmit}
time={duration}
disabled={isLockedEnd}
placeholder='Duration'
>
<InputRightElement
className={activeDuration}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
<span className={style.timeLabel}>D</span>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</TimeInputWithButton>
{overMidnight && (
<div className={style.timerNote}>
<Tooltip
label='Over midnight: end time is before start'
openDelay={tooltipDelayFast}
variant='ontime-ondark'
shouldWrapChildren
>
<IoAlertCircleOutline />
</Tooltip>
</div>
)}
</>
);
};
export default memo(TimeInputFlow);
+2 -2
View File
@@ -7,7 +7,7 @@ $thumb-color-hover: $gray-900;
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-color: $track-color $thumb-color;
scrollbar-color: $thumb-color $track-color;
scrollbar-width: thin;
}
@@ -48,7 +48,7 @@ option {
/* Track */
::-webkit-scrollbar-track {
background: $track-color;
background: $white-1;
border-radius: 2px;
}
+1 -1
View File
@@ -45,7 +45,7 @@
"shx": "^0.3.4",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"vitest": "^1.0.4"
"vitest": "^1.2.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+12 -6
View File
@@ -12,6 +12,7 @@ import {
currentDirectory,
environment,
isProduction,
resolveDbPath,
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
@@ -42,13 +43,14 @@ import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './modules/loadDemo.js';
import { getState, updateNumEvents } from './stores/runtimeState.js';
import { getNumEvents } from './services/rundown-service/RundownService.js';
import { getNumEvents, setRundown } from './services/rundown-service/RundownService.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
if (!isProduction) {
console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${currentDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`);
}
// Create express APP
@@ -177,16 +179,20 @@ export const startServer = async () => {
},
});
// initialise rundown service
const persistedRundown = DataProvider.getRundown();
setRundown(persistedRundown);
// TODO: do this on the init of the runtime service
const numEvents = getNumEvents();
updateNumEvents(numEvents);
// load restore point if it exists
const maybeRestorePoint = await restoreService.load();
// TODO: pass event store to rundownservice
runtimeService.init(maybeRestorePoint);
// TODO: do this on the init of the runtime service
const numEvents = getNumEvents();
updateNumEvents(numEvents);
// eventStore set is a dependency of the services that publish to it
messageService.init(eventStore.set.bind(eventStore));
@@ -276,7 +282,7 @@ export const shutdown = async (exitCode = 0) => {
process.exit(exitCode);
};
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', async (error) => {
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
@@ -10,23 +10,23 @@ import {
deleteAllEvents,
deleteEvent,
editEvent,
getRundown,
reorderEvent,
swapEvents,
} from '../services/rundown-service/RundownService.js';
import { get } from '../services/rundown-service/rundownCache.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { get as getCachedRundown } from '../services/rundown-service/rundownCache.js';
// Create controller for GET request to '/events'
// Returns -
export const rundownGetAll: RequestHandler = async (_req, res) => {
const rundown = DataProvider.getRundown();
const rundown = getRundown();
res.json(rundown);
};
// Create controller for GET request to '/events/cached'
// Returns -
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<RundownCached>) => {
const cachedRundown = get();
const cachedRundown = getCachedRundown();
res.json(cachedRundown);
};
@@ -1,5 +0,0 @@
export const alias = {
enabled: false,
alias: '',
pathAndParams: '',
};
+11 -1
View File
@@ -1,4 +1,12 @@
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import {
EndAction,
OntimeBlock,
OntimeDelay,
OntimeEvent,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
title: '',
@@ -7,6 +15,8 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
timeStart: 0,
timeEnd: 0,
duration: 0,
@@ -11,7 +11,6 @@ import {
} from 'ontime-types';
import { getCueCandidate } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { logger } from '../../classes/Logger.js';
@@ -26,7 +25,7 @@ function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> |
const id = cache.getUniqueId();
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent;
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as OntimeEvent;
}
if (isOntimeDelay(eventData)) {
@@ -188,7 +187,7 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
* @return {array}
*/
export function getRundown(): OntimeRundown {
return DataProvider.getRundown();
return cache.getPersistedRundown();
}
/**
@@ -196,7 +195,7 @@ export function getRundown(): OntimeRundown {
* @return {array}
*/
export function getTimedEvents(): OntimeEvent[] {
return DataProvider.getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
return getRundown().filter((event) => isOntimeEvent(event)) as OntimeEvent[];
}
/**
@@ -204,7 +203,7 @@ export function getTimedEvents(): OntimeEvent[] {
* @return {array}
*/
export function getPlayableEvents(): OntimeEvent[] {
return DataProvider.getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
return getRundown().filter((event) => isOntimeEvent(event) && !event.skip) as OntimeEvent[];
}
/**
@@ -288,7 +287,6 @@ export function findNext(currentEventId?: string): OntimeEvent | null {
}
export async function setRundown(rundown: OntimeRundown) {
await DataProvider.setRundown(rundown);
cache.init(rundown);
notifyChanges({ timer: true });
}
@@ -1,7 +1,174 @@
import { EndAction, OntimeEvent, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import {
EndAction,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
import { add, batchEdit, edit, remove, reorder, swap } from '../rundownCache.js';
import { add, batchEdit, edit, generate, remove, reorder, swap } from '../rundownCache.js';
describe('init() function', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(3);
expect(initResult.order).toStrictEqual(['1', '2', '3']);
expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event);
expect(initResult.rundown['2'].type).toBe(SupportedEvent.Block);
expect(initResult.rundown['3'].type).toBe(SupportedEvent.Delay);
});
it('calculates delays versions of a given rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay,
{ type: SupportedEvent.Event, id: '2', timeStart: 1 } as OntimeEvent,
{ type: SupportedEvent.Block, id: '3' } as OntimeBlock,
{ type: SupportedEvent.Event, id: '4', timeStart: 2 } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(4);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
});
it('links times across events', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
timeStart: 1,
duration: 1,
timeEnd: 2,
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
timeStart: 11,
duration: 1,
timeEnd: 12,
linkStart: '1',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
{ type: SupportedEvent.Block, id: 'block' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay,
{
type: SupportedEvent.Event,
id: '3',
timeStart: 21,
duration: 1,
timeEnd: 22,
linkStart: '2',
timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(5);
expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12);
expect((initResult.rundown['2'] as OntimeEvent).duration).toBe(10);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(12);
expect((initResult.rundown['3'] as OntimeEvent).timeEnd).toBe(22);
expect((initResult.rundown['3'] as OntimeEvent).duration).toBe(10);
expect(initResult.links['1']).toBe('2');
expect(initResult.links['2']).toBe('3');
});
it('links times across events, reordered', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(3);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2);
expect(initResult.links['1']).toBe('3');
expect(initResult.links['3']).toBe('2');
});
it('handles updating event sequence', () => {
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '97cc3e',
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: 'e01948',
timeStart: 600000,
timeEnd: 601000,
duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd,
linkStart: '97cc3e',
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '25c1af',
timeStart: 100, // <------------- value out of sync
timeEnd: 602000,
duration: 0,
timeStrategy: TimeStrategy.LockEnd,
linkStart: 'e01948',
} as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.rundown).toMatchObject({
'97cc3e': {
timeStart: 0,
timeEnd: 600000,
duration: 600000,
timeStrategy: 'lock-duration',
linkStart: null,
},
e01948: {
timeStart: 600000,
timeEnd: 601000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: '97cc3e',
},
'25c1af': {
timeStart: 601000,
timeEnd: 602000,
duration: 1000,
timeStrategy: 'lock-end',
linkStart: 'e01948',
},
});
});
it('deletes links if invalid', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent,
];
const initResult = generate(testRundown);
expect(initResult.order.length).toBe(1);
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
expect(Object.keys(initResult.links).length).toBe(0);
});
});
describe('add() mutation', () => {
test('adds an event to the rundown', () => {
@@ -137,6 +304,8 @@ describe('calculateRuntimeDelays', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -172,6 +341,8 @@ describe('calculateRuntimeDelays', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
@@ -207,6 +378,8 @@ describe('calculateRuntimeDelays', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -242,6 +415,8 @@ describe('calculateRuntimeDelays', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
@@ -286,6 +461,8 @@ describe('getDelayAt()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -322,6 +499,8 @@ describe('getDelayAt()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
@@ -358,6 +537,8 @@ describe('getDelayAt()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -394,6 +575,8 @@ describe('getDelayAt()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
@@ -456,6 +639,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -492,6 +677,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1200000,
duration: 0,
@@ -528,6 +715,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 600000,
timeEnd: 1200000,
duration: 600000,
@@ -564,6 +753,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
note: '',
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
timeStart: 1200000,
timeEnd: 1800000,
duration: 600000,
@@ -6,80 +6,123 @@ import {
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData } from 'ontime-utils';
import {
generateId,
deleteAtIndex,
insertAtIndex,
reorderArray,
swapEventData,
getLinkedTimes,
formatFromMillis,
} from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js';
type NormalisedRundown = Record<string, OntimeRundownEntry>;
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
/** Utility function gets rundown from DataProvider */
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
let rundown: NormalisedRundown = {};
let order: string[] = [];
let order: EventID[] = [];
let revision = 0;
let isStale = true;
/**
* Utility initialises cache
* @param persistedRundown
*/
export function init(persistedRundown: Readonly<OntimeRundown>) {
// we decided to try and re-write this dataset for every change
// instead of maintaining logic to update it
rundown = {};
order = [];
let links: Record<EventID, EventID> = {};
let accumulatedDelay = 0;
for (let i = 0; i < persistedRundown.length; i++) {
const event = persistedRundown[i];
// calculate delays
if (isOntimeDelay(event)) {
accumulatedDelay += event.duration;
} else if (isOntimeBlock(event)) {
accumulatedDelay = 0;
} else if (isOntimeEvent(event)) {
event.delay = accumulatedDelay;
}
order.push(event.id);
rundown[event.id] = { ...event };
}
isStale = false;
export async function init(initialRundown: OntimeRundown) {
persistedRundown = structuredClone(initialRundown);
generate();
await DataProvider.setRundown(persistedRundown);
}
/**
* Returns an ID guaranteed to be unique
* @returns
* Utility initialises cache
* @param rundown
*/
export function getUniqueId(persistedRundown: Readonly<OntimeRundown> = getPersistedRundown()): string {
export function generate(initialRundown: OntimeRundown = persistedRundown) {
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
function getLink(currentIndex: number): OntimeEvent | null {
// currently the link is the previous event
for (let i = currentIndex - 1; i >= 0; i--) {
const event = initialRundown[i];
if (isOntimeEvent(event)) {
return event;
}
}
return null;
}
rundown = {};
order = [];
links = {};
let accumulatedDelay = 0;
for (let i = 0; i < initialRundown.length; i++) {
const currentEvent = initialRundown[i];
let updatedEvent = { ...currentEvent };
// handle links
if (isOntimeEvent(updatedEvent)) {
if (updatedEvent.linkStart) {
const linkedEvent = getLink(i);
// link is always the previous event for now
if (linkedEvent) {
links[linkedEvent.id] = currentEvent.id;
const timePatch = getLinkedTimes(updatedEvent, linkedEvent);
updatedEvent = { ...updatedEvent, ...timePatch };
} else {
updatedEvent.linkStart = null;
}
// update the persisted event
initialRundown[i] = updatedEvent;
}
}
// calculate delays
if (isOntimeDelay(updatedEvent)) {
accumulatedDelay += updatedEvent.duration;
} else if (isOntimeBlock(updatedEvent)) {
accumulatedDelay = 0;
} else if (isOntimeEvent(updatedEvent)) {
updatedEvent.delay = accumulatedDelay;
}
order.push(updatedEvent.id);
rundown[updatedEvent.id] = { ...updatedEvent };
}
isStale = false;
return { rundown, order, links };
}
/** Returns an ID guaranteed to be unique */
export function getUniqueId(): string {
if (isStale) {
generate();
}
let id = '';
do {
id = generateId();
} while (!isIdUnique(persistedRundown, id));
} while (Object.hasOwn(rundown, id));
return id;
}
export function isIdUnique(persistedRundown: Readonly<OntimeRundown>, eventId: string) {
if (isStale) {
init(persistedRundown);
}
return !Object.hasOwn(rundown, eventId);
}
/** Returns index of an event with a given id */
export function getIndexOf(eventId: string) {
if (isStale) {
init(getPersistedRundown());
generate();
}
return order.indexOf(eventId);
}
/**
* Utility function gets rundown from DataProvider
* @returns {OntimeRundown}
*/
export const getPersistedRundown = (): OntimeRundown => DataProvider.getRundown();
type RundownCache = {
rundown: NormalisedRundown;
order: string[];
@@ -93,7 +136,7 @@ type RundownCache = {
export function get(): Readonly<RundownCache> {
if (isStale) {
console.time('rundownCache__init');
init(getPersistedRundown());
generate();
console.timeEnd('rundownCache__init');
}
return {
@@ -117,21 +160,25 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
*/
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
async function scopedMutation(params: T) {
const persistedRundown = getPersistedRundown();
const { newEvent, newRundown } = mutation({ ...params, persistedRundown });
revision = revision + 1;
isStale = true;
persistedRundown = newRundown;
DataProvider.setRundown(newRundown);
// schedule the update to the next tick
process.nextTick(() => {
// schedule a non priority cache update
setImmediate(() => {
console.time('rundownCache__init');
init(newRundown);
generate();
console.timeEnd('rundownCache__init');
});
// TODO: should we trottle this?
// defer writing to the database
setImmediate(() => {
DataProvider.setRundown(persistedRundown);
});
// TODO: could we return a patch object?
return { newEvent };
}
@@ -186,8 +233,12 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
throw new Error('Invalid event type');
}
// @ts-expect-error -- testing
console.log('patch', formatFromMillis(patch?.timeStart ?? 0, 'HH:mm:ss'));
const eventInMemory = persistedRundown[indexAt];
const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent;
+1
View File
@@ -10,6 +10,7 @@ import { ensureDirectory } from './utils/fileManagement.js';
/**
* @description Returns public path depending on OS
* This is the correct path for the app running in production mode
*/
export function getAppDataPath(): string {
// handle docker
+15 -2
View File
@@ -8,6 +8,7 @@ import {
ProjectData,
Settings,
SupportedEvent,
TimeStrategy,
TimerType,
ViewSettings,
} from 'ontime-types';
@@ -33,6 +34,8 @@ describe('test json parser with valid def', () => {
timeStart: 31500000,
timeEnd: 32400000,
duration: 32400000 - 31500000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
@@ -63,6 +66,8 @@ describe('test json parser with valid def', () => {
timeStart: 32400000,
timeEnd: 36000000,
duration: 36000000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: true,
colour: 'red',
@@ -93,6 +98,8 @@ describe('test json parser with valid def', () => {
timeStart: 32400000,
timeEnd: 37200000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
@@ -144,6 +151,8 @@ describe('test json parser with valid def', () => {
timeStart: 39600000,
timeEnd: 45000000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: '',
@@ -174,6 +183,8 @@ describe('test json parser with valid def', () => {
timeStart: 46800000,
timeEnd: 50400000,
duration: 37200000 - 32400000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: true,
colour: '',
@@ -566,10 +577,12 @@ describe('test event validator', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
expect(typeof validated.timeStart).toEqual('number');
assertType<number>(validated.timeStart);
assertType<number>(validated.timeEnd);
assertType<number>(validated.duration);
expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(2);
expect(validated.duration).toEqual(2);
});
it('handles bad objects', () => {
@@ -1,4 +1,4 @@
import { EndAction, OntimeRundownEntry, SupportedEvent, TimerType } from 'ontime-types';
import { EndAction, OntimeRundownEntry, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
@@ -29,6 +29,8 @@ describe('cellRequestFromEvent()', () => {
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
@@ -97,6 +99,8 @@ describe('cellRequestFromEvent()', () => {
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: 'red',
@@ -163,6 +167,8 @@ describe('cellRequestFromEvent()', () => {
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
@@ -228,6 +234,8 @@ describe('cellRequestFromEvent()', () => {
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
duration: 10800000,
isPublic: true,
skip: false,
@@ -272,6 +280,8 @@ describe('cellRequestFromEvent()', () => {
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
@@ -315,6 +325,8 @@ describe('cellRequestFromEvent()', () => {
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: true,
skip: false,
colour: 'red',
+28 -4
View File
@@ -7,6 +7,8 @@ import {
validateTimerType,
type ExcelImportOptions,
validateTimes,
isKnownTimerType,
validateLinkStart,
} from 'ontime-utils';
import {
DatabaseModel,
@@ -16,6 +18,7 @@ import {
UserFields,
EndAction,
TimerType,
TimeStrategy,
} from 'ontime-types';
import fs from 'fs';
@@ -38,7 +41,6 @@ import {
import { parseExcelDate } from './time.js';
import { configService } from '../services/ConfigService.js';
import { coerceBoolean } from './coerceType.js';
import { isKnownTimerType } from '../../../../packages/utils/src/validate-events/validateEvent.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
@@ -335,16 +337,36 @@ export const parseJson = async (jsonData: Partial<DatabaseModel>): Promise<Datab
return returnData;
};
/**
* Function infers strategy for a patch with only partial timer data
* @param end
* @param duration
* @param fallback
* @returns
*/
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
if (end && !duration) {
return TimeStrategy.LockEnd;
}
if (!end && duration) {
return TimeStrategy.LockDuration;
}
return fallback;
}
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
const { timeStart, timeEnd, duration } = validateTimes(
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
patchEvent?.timeStart ?? originalEvent.timeStart,
patchEvent?.timeEnd ?? originalEvent.timeEnd,
patchEvent?.duration ?? originalEvent.duration,
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
);
const maybeLinkStart = patchEvent.linkStart !== undefined ? patchEvent.linkStart : originalEvent.linkStart;
return {
id: originalEvent.id,
@@ -355,6 +377,8 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart: validateLinkStart(maybeLinkStart),
endAction: validateEndAction(patchEvent.endAction, EndAction.None),
timerType: validateTimerType(patchEvent.timerType, TimerType.CountDown),
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
@@ -374,8 +398,8 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning,
timeDanger: patchEvent.timeDanger,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
};
}
+2 -2
View File
@@ -10,12 +10,12 @@ import { join } from 'path';
import { URL } from 'url';
import { logger } from '../classes/Logger.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { getAppDataPath } from '../setup.js';
import { ensureDirectory } from './fileManagement.js';
import { cellRequestFromEvent, getA1Notation } from './sheetUtils.js';
import { parseExcel } from './parser.js';
import { parseRundown, parseUserFields } from './parserFunctions.js';
import { getRundown } from '../services/rundown-service/RundownService.js';
type ResponseOK = {
data: Partial<DatabaseModel>;
@@ -281,7 +281,7 @@ class Sheet {
});
if (readResponse.status === 200) {
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
const rundown = DataProvider.getRundown();
const rundown = getRundown();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();