mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 261e71e8c3 | |||
| 9b464ff2db | |||
| 544f01630b | |||
| 022778f892 | |||
| c9545d06e2 | |||
| c9013fc8cf | |||
| 4b49d13366 |
@@ -36,7 +36,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac --env-mode=loose
|
||||
run: pnpm dist-mac
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
|
||||
@@ -28,7 +28,6 @@ test-results
|
||||
playwright-report
|
||||
/playwright/.cache/
|
||||
automated-screenshots
|
||||
e2e/tests/fixtures/tmp/*
|
||||
|
||||
# production
|
||||
build/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.10.3",
|
||||
"version": "3.9.5",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.10.3",
|
||||
"version": "3.9.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -92,4 +92,4 @@
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#2B5ABC",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download`, { filename });
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,23 +9,32 @@ import style from './DelayIndicator.module.scss';
|
||||
|
||||
interface DelayIndicatorProps {
|
||||
delayValue?: number;
|
||||
tooltipPrefix?: string;
|
||||
}
|
||||
|
||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
const { delayValue, tooltipPrefix } = props;
|
||||
const { delayValue } = props;
|
||||
|
||||
if (typeof delayValue !== 'number' || delayValue === 0) {
|
||||
return null;
|
||||
if (typeof delayValue === 'number') {
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const delayString = tooltipPrefix
|
||||
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
|
||||
: millisToDelayString(delayValue);
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
|
||||
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
|
||||
const handleClick = () => {
|
||||
onClick?.(color);
|
||||
};
|
||||
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={classes} onClick={handleClick}>
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import { debounce } from '../../../../common/utils/debounce';
|
||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface SwatchPickerProps {
|
||||
color: string;
|
||||
isSelected?: boolean;
|
||||
onChange: (name: string) => void;
|
||||
alwaysDisplayColor?: boolean;
|
||||
}
|
||||
|
||||
export default function SwatchPicker(props: SwatchPickerProps) {
|
||||
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
||||
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((newValue: string) => {
|
||||
onChange(newValue);
|
||||
}, 500),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
|
||||
<div
|
||||
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
|
||||
style={{ backgroundColor: displayColor }}
|
||||
>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
const displayColor = typeof value === 'string' ? value : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={value as string} onChange={onChange}>
|
||||
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 0.5rem
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid $blue-500;
|
||||
@@ -20,9 +17,11 @@
|
||||
|
||||
&.selectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
import SwatchPicker from './SwatchPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
outline: none;
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import style from './PopoverPicker.module.scss';
|
||||
|
||||
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
return <PopoverPicker color={value as string} onChange={onChange} />;
|
||||
}
|
||||
|
||||
interface PopoverPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||
const { color, onChange, children } = props;
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -15,8 +15,6 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -49,9 +47,7 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
if (valueToSubmit !== initialText) {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
@@ -60,7 +56,7 @@ export default function useReactiveTextInput(
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
},
|
||||
[initialText, options, ref, submitCallback],
|
||||
[initialText, ref, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -70,9 +66,8 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, options, ref]);
|
||||
}, [initialText, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
|
||||
@@ -55,7 +55,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
submitHandler(name, newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
|
||||
@@ -2,8 +2,6 @@ $input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
color: $label-gray;
|
||||
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
@@ -46,4 +42,4 @@
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
@@ -20,10 +20,13 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
const debouncedChange = (value: string) => {
|
||||
setColour(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
<span>{colour}</span>
|
||||
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
RundownCached,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||
@@ -216,75 +214,13 @@ export const useEventAction = () => {
|
||||
[updateEvent],
|
||||
);
|
||||
|
||||
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
|
||||
/**
|
||||
* Updates time of existing event
|
||||
* @param eventId {string} - id of the event
|
||||
* @param field {TimeField} - field to update
|
||||
* @param value {string} - new value string to be parsed
|
||||
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
|
||||
*/
|
||||
const updateTimer = useCallback(
|
||||
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
|
||||
// an empty value with no lock has no domain validity
|
||||
if (!lockOnUpdate && value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEvent: Partial<OntimeEvent> = {
|
||||
id: eventId,
|
||||
};
|
||||
|
||||
// check if we should lock the field
|
||||
if (lockOnUpdate) {
|
||||
if (field === 'timeEnd') {
|
||||
// an empty value indicates that we should unlock the field
|
||||
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
|
||||
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
|
||||
} else if (field === 'duration') {
|
||||
// an empty value indicates that we should unlock the field
|
||||
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
|
||||
newEvent.duration = value === '' ? undefined : calculateNewValue();
|
||||
} else if (field === 'timeStart') {
|
||||
// an empty values means we should link to the previous
|
||||
newEvent.linkStart = value === '' ? 'true' : null;
|
||||
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
|
||||
}
|
||||
} else {
|
||||
newEvent[field] = calculateNewValue();
|
||||
}
|
||||
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to calculate the new time value
|
||||
*/
|
||||
function calculateNewValue(): number {
|
||||
let newValMillis = 0;
|
||||
|
||||
// check for previous keyword
|
||||
if (value === 'p' || value === 'prev' || value === 'previous') {
|
||||
newValMillis = getPreviousEnd();
|
||||
|
||||
// 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() + parseUserTime(remainingString);
|
||||
} else {
|
||||
newValMillis = parseUserTime(value);
|
||||
}
|
||||
// dont allow timer values over 23:59:59
|
||||
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get the previous event end time
|
||||
*/
|
||||
function getPreviousEnd(): number {
|
||||
async (eventId: string, field: TimeField, value: string) => {
|
||||
const getPreviousEnd = (): number => {
|
||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
|
||||
if (!cachedRundown?.order || !cachedRundown?.rundown) {
|
||||
@@ -304,6 +240,34 @@ export const useEventAction = () => {
|
||||
}
|
||||
}
|
||||
return previousEnd;
|
||||
};
|
||||
|
||||
let newValMillis = 0;
|
||||
|
||||
// check for previous keyword
|
||||
if (value === 'p' || value === 'prev' || value === 'previous') {
|
||||
newValMillis = getPreviousEnd();
|
||||
|
||||
// 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() + parseUserTime(remainingString);
|
||||
} else {
|
||||
newValMillis = parseUserTime(value);
|
||||
}
|
||||
|
||||
// dont allow timer values over 23:59:59
|
||||
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||
|
||||
const newEvent = {
|
||||
id: eventId,
|
||||
[field]: cappedMillis,
|
||||
};
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, queryClient],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { debounce } from '../../common/utils/debounce';
|
||||
|
||||
export const useFadeOutOnInactivity = () => {
|
||||
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
||||
@@ -16,11 +16,11 @@ export const useFadeOutOnInactivity = () => {
|
||||
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
|
||||
};
|
||||
|
||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', throttledShowMenu);
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', throttledShowMenu);
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const useMessagePreview = () => {
|
||||
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: state.eventNow?.timerType ?? null,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
isTimeToEnd: state.eventNow?.isTimeToEnd ?? false,
|
||||
});
|
||||
|
||||
return useRuntimeStore(featureSelector);
|
||||
|
||||
@@ -15,5 +15,5 @@ export type ViewExtendedTimer = {
|
||||
|
||||
clock: number;
|
||||
timerType: TimerType;
|
||||
countToEnd: boolean;
|
||||
isTimeToEnd: boolean;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-uti
|
||||
|
||||
/**
|
||||
* Parses a value in millis to a string which encodes a delay
|
||||
* @param millis
|
||||
* @param format
|
||||
* @param millis
|
||||
* @param format
|
||||
*/
|
||||
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
|
||||
if (millis == null || millis === 0) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) {
|
||||
export function debounce(callback: () => void, wait: number) {
|
||||
let timeout: NodeJS.Timeout | null;
|
||||
return (...args: T) => {
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
callback(...args);
|
||||
callback();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,16 +17,13 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
||||
timeEnd: event.timeEnd,
|
||||
timerType: event.timerType,
|
||||
timeStrategy: event.timeStrategy,
|
||||
countToEnd: event.countToEnd,
|
||||
isTimeToEnd: event.isTimeToEnd,
|
||||
linkStart: event.linkStart,
|
||||
endAction: event.endAction,
|
||||
isPublic: event.isPublic,
|
||||
skip: event.skip,
|
||||
colour: event.colour,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: event.timeWarning,
|
||||
timeDanger: event.timeDanger,
|
||||
custom: {},
|
||||
|
||||
@@ -7,11 +7,3 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export function throttle<T extends any[]>(callback: (...args: T) => void, wait: number) {
|
||||
let timeout: NodeJS.Timeout | null;
|
||||
return (...args: T) => {
|
||||
if (timeout) {
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
callback(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
-21
@@ -6,9 +6,6 @@ declare module '*.scss' {
|
||||
type ListenerType = (event: 'string', args: unknown[]) => void;
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Register the electron properties
|
||||
*/
|
||||
interface Window {
|
||||
ipcRenderer: {
|
||||
send: (channel: string, args?: string | object) => void;
|
||||
@@ -20,24 +17,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We pass a custom property to the table meta to allow field update
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow passing CSS Properties
|
||||
*/
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
|
||||
@@ -70,7 +70,7 @@ function PanelListItem(props: PanelListItemProps) {
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li
|
||||
key={secondary.id}
|
||||
|
||||
@@ -26,24 +26,15 @@ $inner-padding: 1rem;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.section, .indent {
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
padding: 1rem;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
@@ -93,6 +84,7 @@ $inner-padding: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 0 1px $white-10;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
@@ -171,40 +163,6 @@ $inner-padding: 1rem;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: $black-10;
|
||||
text-align: center;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inlineSiblings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: $black-10;
|
||||
text-align: center;
|
||||
color: $muted-gray;
|
||||
|
||||
td {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
@@ -24,19 +22,10 @@ type SectionProps<C extends AllowedTags> = {
|
||||
children: ReactNode;
|
||||
} & JSX.IntrinsicElements[C];
|
||||
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
|
||||
const Element = as ?? 'div';
|
||||
return (
|
||||
<Element className={cx([style.section, className])} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
export function Indent<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
|
||||
const Element = as ?? 'div';
|
||||
return (
|
||||
<Element className={cx([style.indent, className])} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
@@ -63,21 +52,8 @@ export function Table({ className, children }: { className?: string; children: R
|
||||
);
|
||||
}
|
||||
|
||||
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
|
||||
return (
|
||||
<tr className={style.empty}>
|
||||
<td colSpan={99}>
|
||||
<div>No data yet</div>
|
||||
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
|
||||
New
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListGroup({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
|
||||
export function ListGroup({ children }: { children: ReactNode }) {
|
||||
return <ul className={style.listGroup}>{children}</ul>;
|
||||
}
|
||||
|
||||
export function ListItem({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ClientList() {
|
||||
)}
|
||||
{name}
|
||||
</td>
|
||||
<td className={style.pathList}>{path}</td>
|
||||
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
||||
<td className={style.actionButtons}>
|
||||
<Button
|
||||
size='xs'
|
||||
|
||||
@@ -149,14 +149,9 @@ export default function UrlPresetsForm() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
// only saved and enabled URLs can be tested
|
||||
const canTest =
|
||||
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
|
||||
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
@@ -195,7 +190,6 @@ export default function UrlPresetsForm() {
|
||||
<td className={style.flex}>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
isDisabled={!canTest}
|
||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||
tooltip='Test preset'
|
||||
aria-label='Test preset'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ViewSettings } from 'ontime-types';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
||||
import { PopoverPickerRHF } from '../../../../common/components/input/popover-picker/PopoverPicker';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -111,15 +111,15 @@ export default function ViewSettingsForm() {
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer colour' description='Default colour of a running timer' />
|
||||
<SwatchPickerRHF name='normalColor' control={control} />
|
||||
<PopoverPickerRHF name='normalColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Warning colour' description='Colour of a running timer in warning mode' />
|
||||
<SwatchPickerRHF name='warningColor' control={control} />
|
||||
<PopoverPickerRHF name='warningColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Danger colour' description='Colour of a running timer in danger mode' />
|
||||
<SwatchPickerRHF name='dangerColor' control={control} />
|
||||
<PopoverPickerRHF name='dangerColor' control={control} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { generateId } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -63,6 +63,13 @@ export default function OscIntegrations() {
|
||||
}
|
||||
};
|
||||
|
||||
const preventEscape = (event: React.KeyboardEvent) => {
|
||||
if (isKeyEscape(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewSubscription = () => {
|
||||
prepend({
|
||||
id: generateId(),
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ export const namedImportMap = {
|
||||
Duration: 'duration',
|
||||
Cue: 'cue',
|
||||
Title: 'title',
|
||||
'Count to end': 'count to end',
|
||||
'Time to end': 'time to end',
|
||||
'Is Public': 'public',
|
||||
Skip: 'skip',
|
||||
Note: 'notes',
|
||||
@@ -48,7 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
duration: namedImportMap.Duration,
|
||||
cue: namedImportMap.Cue,
|
||||
title: namedImportMap.Title,
|
||||
countToEnd: namedImportMap['Count to end'],
|
||||
isTimeToEnd: namedImportMap['Time to end'],
|
||||
isPublic: namedImportMap['Is Public'],
|
||||
skip: namedImportMap.Skip,
|
||||
note: namedImportMap.Note,
|
||||
|
||||
+3
-3
@@ -40,7 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<th>Duration</th>
|
||||
<th>Warning Time</th>
|
||||
<th>Danger Time</th>
|
||||
<th>Count to end</th>
|
||||
<th>Is Time to End</th>
|
||||
<th>Is Public</th>
|
||||
<th>Skip</th>
|
||||
<th>Colour</th>
|
||||
@@ -72,7 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
}
|
||||
eventIndex += 1;
|
||||
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||
const countToEnd = booleanToText(event.countToEnd);
|
||||
const isTimeToEnd = booleanToText(event.isTimeToEnd);
|
||||
const isPublic = booleanToText(event.isPublic);
|
||||
const skip = booleanToText(event.skip);
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<td>{millisToString(event.duration)}</td>
|
||||
<td>{millisToString(event.timeWarning)}</td>
|
||||
<td>{millisToString(event.timeDanger)}</td>
|
||||
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
|
||||
<td className={style.center}>{isTimeToEnd && <Tag>{isTimeToEnd}</Tag>}</td>
|
||||
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||
<td style={{ ...colour }}>{event.colour}</td>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils';
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||
const { blink, blackout, isTimeToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } =
|
||||
useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function TimerPreview() {
|
||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||
if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message';
|
||||
if (timerType === TimerType.Clock) return 'Clock';
|
||||
if (countToEnd) return 'Count to End';
|
||||
if (isTimeToEnd) return 'Target event scheduled end';
|
||||
return 'Timer';
|
||||
})();
|
||||
|
||||
@@ -77,8 +77,12 @@ export default function TimerPreview() {
|
||||
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
|
||||
</Tooltip>
|
||||
<Tooltip label={countToEnd ? 'Count to end' : 'Count duration'} openDelay={tooltipDelayMid} shouldWrapChildren>
|
||||
<IoFlag className={style.statusIcon} data-active={countToEnd} />
|
||||
<Tooltip
|
||||
label={isTimeToEnd ? 'Count to schedule' : 'Count from start'}
|
||||
openDelay={tooltipDelayMid}
|
||||
shouldWrapChildren
|
||||
>
|
||||
<IoFlag className={style.statusIcon} data-active={isTimeToEnd} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
color: $ui-white;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
border-radius: $component-border-radius-full;
|
||||
border-radius: 99px;
|
||||
|
||||
// similar styles to ontime-button-subtle
|
||||
background-color: $gray-1050;
|
||||
@@ -27,16 +27,13 @@
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
color: $title-gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: $aux-text-size;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
margin-bottom: $element-inner-spacing;
|
||||
margin-bottom: 0.25rem;
|
||||
max-width: max-content; // prevent label from taking entire row
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes, LabelHTMLAttributes } from 'react';
|
||||
import type { LabelHTMLAttributes, ReactNode } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { type IconBaseProps } from '@react-icons/all-files/lib';
|
||||
|
||||
@@ -10,13 +10,8 @@ export function Corner({ className, ...elementProps }: IconBaseProps) {
|
||||
return <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
||||
}
|
||||
|
||||
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const classes = cx([style.title, className]);
|
||||
return (
|
||||
<h3 className={classes} {...elementProps}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
export function Title({ children }: { children: ReactNode }) {
|
||||
return <h3 className={style.title}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function Label({ children, className, ...elementProps }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useSettings from '../../common/hooks-query/useSettings';
|
||||
import { throttle } from '../../common/utils/throttle';
|
||||
import { debounce } from '../../common/utils/debounce';
|
||||
import { getDefaultFormat } from '../../common/utils/time';
|
||||
import { getPropertyValue, isStringBoolean } from '../viewers/common/viewUtils';
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function Operator() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
||||
const debouncedHandleScroll = debounce(handleUserScroll, 1000);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (timeoutId.current) {
|
||||
@@ -97,7 +97,7 @@ export default function Operator() {
|
||||
|
||||
setShowEditPrompt(true);
|
||||
|
||||
throttledHandleScroll();
|
||||
debouncedHandleScroll();
|
||||
};
|
||||
|
||||
const handleEdit = useCallback((event: EditEvent) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
checkIsNextDay,
|
||||
getFirstNormal,
|
||||
getLastNormal,
|
||||
getNextBlockNormal,
|
||||
@@ -268,7 +267,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
let eventIndex = 0;
|
||||
// all events before the current selected are in the past
|
||||
let isPast = Boolean(featureData?.selectedEventId);
|
||||
let isNextDay = false;
|
||||
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
|
||||
return (
|
||||
@@ -287,7 +286,6 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (index === 0) {
|
||||
eventIndex = 0;
|
||||
}
|
||||
isNextDay = false;
|
||||
previousEntryId = thisId;
|
||||
thisId = entryId;
|
||||
if (isOntimeEvent(entry)) {
|
||||
@@ -296,9 +294,8 @@ export default function Rundown({ data }: RundownProps) {
|
||||
lastEvent = thisEvent;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
// populate previous entry
|
||||
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
|
||||
thisEvent = entry;
|
||||
}
|
||||
}
|
||||
@@ -326,11 +323,12 @@ export default function Rundown({ data }: RundownProps) {
|
||||
loaded={isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousStart={lastEvent?.timeStart}
|
||||
previousEnd={lastEvent?.timeEnd}
|
||||
previousEntryId={previousEntryId}
|
||||
previousEventId={lastEvent?.id}
|
||||
playback={isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={isNextDay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,8 @@ interface RundownEntryProps {
|
||||
eventIndex: number;
|
||||
hasCursor: boolean;
|
||||
isNext: boolean;
|
||||
isNextDay: boolean;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
previousEntryId?: string;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
@@ -46,12 +47,13 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
loaded,
|
||||
hasCursor,
|
||||
isNext,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
previousEntryId,
|
||||
previousEventId,
|
||||
playback,
|
||||
isRolling,
|
||||
eventIndex,
|
||||
isNextDay,
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
@@ -156,13 +158,15 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
duration={data.duration}
|
||||
timeStrategy={data.timeStrategy}
|
||||
linkStart={data.linkStart}
|
||||
countToEnd={data.countToEnd}
|
||||
isTimeToEnd={data.isTimeToEnd}
|
||||
isPublic={data.isPublic}
|
||||
endAction={data.endAction}
|
||||
timerType={data.timerType}
|
||||
title={data.title}
|
||||
note={data.note}
|
||||
delay={data.delay ?? 0}
|
||||
previousStart={previousStart}
|
||||
previousEnd={previousEnd}
|
||||
colour={data.colour}
|
||||
isPast={isPast}
|
||||
isNext={isNext}
|
||||
@@ -171,8 +175,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
hasCursor={hasCursor}
|
||||
playback={playback}
|
||||
isRolling={isRolling}
|
||||
gap={data.gap}
|
||||
isNextDay={isNextDay}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
.side {
|
||||
max-height: 100%;
|
||||
max-width: 45rem;
|
||||
margin: 0.5rem 0;
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
padding-right: 0;
|
||||
background-color: $gray-1325;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface EventBlockProps {
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
countToEnd: boolean;
|
||||
isTimeToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
@@ -39,6 +39,8 @@ interface EventBlockProps {
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
colour: string;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
@@ -47,8 +49,6 @@ interface EventBlockProps {
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
gap: number;
|
||||
isNextDay: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
@@ -69,7 +69,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
isTimeToEnd,
|
||||
isPublic = true,
|
||||
eventIndex,
|
||||
endAction,
|
||||
@@ -77,6 +77,8 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
colour,
|
||||
isPast,
|
||||
isNext,
|
||||
@@ -85,8 +87,6 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
hasCursor,
|
||||
playback,
|
||||
isRolling,
|
||||
gap,
|
||||
isNextDay,
|
||||
actionHandler,
|
||||
} = props;
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
@@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
onContextMenu={onContextMenu}
|
||||
id='event-block'
|
||||
>
|
||||
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
|
||||
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
|
||||
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
@@ -288,7 +288,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
isTimeToEnd={isTimeToEnd}
|
||||
timeStrategy={timeStrategy}
|
||||
eventId={eventId}
|
||||
eventIndex={eventIndex}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
import {
|
||||
calculateDuration,
|
||||
checkIsNextDay,
|
||||
dayInMs,
|
||||
getTimeFromPrevious,
|
||||
millisToString,
|
||||
removeTrailingZero,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
@@ -10,15 +17,25 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
|
||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||
return `New start ${timeTag}`;
|
||||
}
|
||||
export function formatGap(gap: number, isNextDay: boolean) {
|
||||
if (gap === 0) {
|
||||
if (isNextDay) {
|
||||
// We show a next day warning even if there is no gap
|
||||
return '(next day)';
|
||||
}
|
||||
return;
|
||||
|
||||
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
|
||||
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
|
||||
if (noPreviousElement) return;
|
||||
|
||||
const normalisedDuration = calculateDuration(previousStart, previousEnd);
|
||||
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
|
||||
if (timeFromPrevious === 0) return;
|
||||
|
||||
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
|
||||
const previousCrossMidnight = previousStart > previousEnd;
|
||||
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
|
||||
|
||||
const gap = dayInMs - normalisedPreviousEnd + timeStart;
|
||||
if (gap === 0) return;
|
||||
const gapString = formatDuration(Math.abs(gap), false);
|
||||
return `Gap ${gapString} (next day)`;
|
||||
}
|
||||
|
||||
const gapString = formatDuration(Math.abs(gap), false);
|
||||
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
|
||||
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
|
||||
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ interface EventBlockInnerProps {
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
countToEnd: boolean;
|
||||
isTimeToEnd: boolean;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
@@ -52,7 +52,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
isTimeToEnd,
|
||||
isPublic = true,
|
||||
endAction,
|
||||
timerType,
|
||||
@@ -93,7 +93,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
isTimeToEnd={isTimeToEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
@@ -124,9 +124,9 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`${countToEnd ? 'Count to End' : 'Count duration'}`} openDelay={tooltipDelayMid}>
|
||||
<Tooltip label={`${isTimeToEnd ? 'Time to End' : 'Count from start'}`} openDelay={tooltipDelayMid}>
|
||||
<span>
|
||||
<IoFlag className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
|
||||
<IoFlag className={`${style.statusIcon} ${isTimeToEnd ? style.active : style.disabled}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { formatDelay, formatGap } from './EventBlock.utils';
|
||||
import { formatDelay, formatOverlap } from './EventBlock.utils';
|
||||
|
||||
import style from './RundownIndicators.module.scss';
|
||||
|
||||
interface RundownIndicatorProps {
|
||||
timeStart: number;
|
||||
isNextDay: boolean;
|
||||
previousStart?: number;
|
||||
previousEnd?: number;
|
||||
delay: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export default function RundownIndicators(props: RundownIndicatorProps) {
|
||||
const { timeStart, delay, gap, isNextDay } = props;
|
||||
const { timeStart, previousStart, previousEnd, delay } = props;
|
||||
|
||||
const hasGap = formatGap(gap, isNextDay);
|
||||
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
const hasDelay = formatDelay(timeStart, delay);
|
||||
|
||||
return (
|
||||
<div className={style.indicators}>
|
||||
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
||||
{hasGap && <div className={style.gap}>{hasGap}</div>}
|
||||
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatDelay } from '../EventBlock.utils';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { formatDelay, formatOverlap } from '../EventBlock.utils';
|
||||
|
||||
describe('formatDelay()', () => {
|
||||
it('adds a given delay to the start time', () => {
|
||||
@@ -8,3 +10,69 @@ describe('formatDelay()', () => {
|
||||
expect(result).toEqual('New start 00:02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOverlap()', () => {
|
||||
it('recognises an overlap between two times', () => {
|
||||
const previousStart = 0;
|
||||
const previousEnd = 60000; // 1 min
|
||||
const timeStart = 30000; // 30 sec
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toEqual('Overlap 30s');
|
||||
});
|
||||
|
||||
it('bug #949 recognises an overlap between two times', () => {
|
||||
const previousStart = 46800000; // 13:00:00
|
||||
const previousEnd = 48600000; // 13:30:00
|
||||
const timeStart = 48300000; // 13:25:00
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toEqual('Overlap 5m');
|
||||
});
|
||||
|
||||
it('handles events the day after, without overlap', () => {
|
||||
const previousStart = 11 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 12 * MILLIS_PER_HOUR;
|
||||
const timeStart = 6 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 18h (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with gap', () => {
|
||||
const previousStart = 17 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 23 * MILLIS_PER_HOUR;
|
||||
const timeStart = 9 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 10h (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending at midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
|
||||
const previousEnd = 0; // 00:00:00
|
||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 1h (next day)');
|
||||
});
|
||||
|
||||
it('handles sequential events the day after, with previous ending over midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 1 * MILLIS_PER_HOUR;
|
||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending over midnight with overlap', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 2 * MILLIS_PER_HOUR;
|
||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Overlap 1h');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending over midnight with gap', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 1 * MILLIS_PER_HOUR;
|
||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(timeStart, previousStart, previousEnd);
|
||||
expect(result).toBe('Gap 1h');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.eventEditor {
|
||||
color: $label-gray;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-inline: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -15,7 +16,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -29,22 +30,19 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
h3 {
|
||||
margin-bottom: -0.5rem; // bring the title closer to the section elements
|
||||
}
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.decorated {
|
||||
color: var(--decorator-color, $ui-white);
|
||||
background-color: var(--decorator-bg, $gray-1100);
|
||||
width: fit-content;
|
||||
padding-inline: 0.5rem;
|
||||
border-radius: $component-border-radius-sm;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.delayLabel {
|
||||
font-size: $aux-text-size;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $ontime-delay-text;
|
||||
|
||||
&::after {
|
||||
@@ -58,8 +56,7 @@
|
||||
gap: 0.5rem;
|
||||
max-width: max-content;
|
||||
cursor: pointer;
|
||||
height: 2rem; // manually match the height of a text input
|
||||
margin-bottom: 0; // reset margin from label component
|
||||
height: 30px; // manually match the height of a text input
|
||||
}
|
||||
|
||||
.inline {
|
||||
@@ -71,13 +68,6 @@
|
||||
.splitTwo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 1rem;
|
||||
column-gap: 1.5rem;
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.tooltipIcon {
|
||||
color: $blue-500;
|
||||
display: inline-block;
|
||||
font-size: 1.25em;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
duration={event.duration}
|
||||
timeStrategy={event.timeStrategy}
|
||||
linkStart={event.linkStart}
|
||||
countToEnd={event.countToEnd}
|
||||
isTimeToEnd={event.isTimeToEnd}
|
||||
delay={event.delay ?? 0}
|
||||
isPublic={event.isPublic}
|
||||
endAction={event.endAction}
|
||||
@@ -79,14 +79,14 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Editor.Title>Custom Fields</Editor.Title>
|
||||
{isEditor && (
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</Editor.Title>
|
||||
</div>
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
const key = `${event.id}-${fieldKey}`;
|
||||
const fieldName = `custom-${fieldKey}`;
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Kbd } from '@chakra-ui/react';
|
||||
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import style from './EventEditorEmpty.module.scss';
|
||||
|
||||
@@ -12,7 +11,7 @@ function EventEditorEmpty() {
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
<div className={style.shortcutSection}>
|
||||
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
||||
<div className={style.prompt}>Rundown shortcuts:</div>
|
||||
<table className={style.shortcuts}>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -169,6 +168,6 @@ function EventEditorEmpty() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuxKey({ children }: PropsWithChildren) {
|
||||
function AuxKey({ children }: { children: React.ReactNode }) {
|
||||
return <span className={style.divider}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Select, Switch, Tooltip } from '@chakra-ui/react';
|
||||
import { IoInformationCircle } from '@react-icons/all-files/io5/IoInformationCircle';
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
@@ -19,7 +18,7 @@ interface EventEditorTimesProps {
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
countToEnd: boolean;
|
||||
isTimeToEnd: boolean;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
@@ -28,7 +27,7 @@ interface EventEditorTimesProps {
|
||||
timeDanger: number;
|
||||
}
|
||||
|
||||
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
|
||||
type HandledActions = 'isTimeToEnd' | 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
|
||||
|
||||
function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
const {
|
||||
@@ -38,7 +37,7 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
countToEnd,
|
||||
isTimeToEnd,
|
||||
delay,
|
||||
isPublic,
|
||||
endAction,
|
||||
@@ -54,8 +53,8 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'countToEnd') {
|
||||
updateEvent({ id: eventId, countToEnd: !(value as boolean) });
|
||||
if (field === 'isTimeToEnd') {
|
||||
updateEvent({ id: eventId, isTimeToEnd: !(value as boolean) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,8 +80,8 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event schedule</Editor.Title>
|
||||
<div>
|
||||
<Editor.Label>Event schedule</Editor.Label>
|
||||
<div className={style.inline}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
@@ -92,16 +91,13 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
countToEnd={countToEnd}
|
||||
showLabels
|
||||
isTimeToEnd={isTimeToEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.delayLabel}>{delayLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event Behaviour</Editor.Title>
|
||||
<Editor.Title>Event behaviour</Editor.Title>
|
||||
<div className={style.splitTwo}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
|
||||
@@ -120,30 +116,22 @@ function EventEditorTimes(props: EventEditorTimesProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label>
|
||||
<Editor.Label htmlFor='timeToEnd'>Target Event Scheduled End</Editor.Label>
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch
|
||||
id='countToEnd'
|
||||
id='timeToEnd'
|
||||
size='md'
|
||||
isChecked={countToEnd}
|
||||
onChange={() => handleSubmit('countToEnd', countToEnd)}
|
||||
isChecked={isTimeToEnd}
|
||||
onChange={() => handleSubmit('isTimeToEnd', isTimeToEnd)}
|
||||
variant='ontime'
|
||||
/>
|
||||
{countToEnd ? 'On' : 'Off'}
|
||||
{isTimeToEnd ? 'On' : 'Off'}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
<Tooltip label='Changes how the timer is displayed in different views. It is not reflected in the rundown'>
|
||||
<span>
|
||||
Display Options
|
||||
<IoInformationCircle className={style.tooltipIcon} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Editor.Title>
|
||||
<Editor.Title>Display options</Editor.Title>
|
||||
<div className={style.splitTwo}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
||||
|
||||
@@ -29,7 +29,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
|
||||
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Event Data</Editor.Title>
|
||||
<Editor.Title>Event data</Editor.Title>
|
||||
<div className={style.splitTwo}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
|
||||
|
||||
@@ -5,35 +5,34 @@ 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, TimeField, TimeStrategy } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { MaybeString, 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, tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||
|
||||
import style from './TimeInputFlow.module.scss';
|
||||
|
||||
interface EventBlockTimerProps {
|
||||
eventId: string;
|
||||
countToEnd: boolean;
|
||||
isTimeToEnd: boolean;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
delay: number;
|
||||
showLabels?: boolean;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
|
||||
|
||||
function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
|
||||
const { eventId, isTimeToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
|
||||
// In sync with EventEditorTimes
|
||||
const handleSubmit = (field: TimeField, value: string) => {
|
||||
const handleSubmit = (field: TimeActions, value: string) => {
|
||||
updateTimer(eventId, field, value);
|
||||
};
|
||||
|
||||
@@ -46,12 +45,12 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
};
|
||||
|
||||
const warnings = [];
|
||||
if (timeStart + duration > dayInMs) {
|
||||
if (timeStart > timeEnd) {
|
||||
warnings.push('Over midnight');
|
||||
}
|
||||
|
||||
if (countToEnd) {
|
||||
warnings.push('Count to End');
|
||||
if (isTimeToEnd) {
|
||||
warnings.push('Target event scheduled end');
|
||||
}
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
@@ -65,72 +64,63 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{showLabels && <Editor.Label className={style.sectionTitle}>Start time</Editor.Label>}
|
||||
<TimeInputWithButton<TimeField>
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
</div>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<div>
|
||||
{showLabels && <Editor.Label>End time</Editor.Label>}
|
||||
<TimeInputWithButton<TimeField>
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeEnd}
|
||||
hasDelay={hasDelay}
|
||||
disabled={isLockedDuration}
|
||||
placeholder='End'
|
||||
>
|
||||
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
</div>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeEnd}
|
||||
hasDelay={hasDelay}
|
||||
disabled={isLockedDuration}
|
||||
placeholder='End'
|
||||
>
|
||||
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<div>
|
||||
{showLabels && <Editor.Label>Duration</Editor.Label>}
|
||||
<TimeInputWithButton<TimeField>
|
||||
name='duration'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
disabled={isLockedEnd}
|
||||
placeholder='Duration'
|
||||
>
|
||||
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
</div>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='duration'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
disabled={isLockedEnd}
|
||||
placeholder='Duration'
|
||||
>
|
||||
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className={style.timerNote} data-testid='event-warning'>
|
||||
<div className={style.timerNote}>
|
||||
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
|
||||
@@ -82,7 +82,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
...timer,
|
||||
clock,
|
||||
timerType: eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEnd: eventNow?.countToEnd ?? false,
|
||||
isTimeToEnd: eventNow?.isTimeToEnd ?? false,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,14 +5,14 @@ import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'
|
||||
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'isTimeToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
|
||||
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
||||
if (!timerObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (timerObject.countToEnd) {
|
||||
if (timerObject.isTimeToEnd) {
|
||||
if (timerObject.current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.countToEnd;
|
||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage;
|
||||
const showFinished =
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function Timer(props: TimerProps) {
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
|
||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.countToEnd;
|
||||
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd;
|
||||
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
|
||||
const showProgress =
|
||||
eventNow !== null &&
|
||||
|
||||
@@ -5,7 +5,6 @@ $transition-time-feedback: 0.3s;
|
||||
|
||||
$component-border-radius-md: 3px;
|
||||
$component-border-radius-sm: 2px;
|
||||
$component-border-radius-full: 99px;
|
||||
|
||||
// semantic colours
|
||||
$action-blue: #3182ce;
|
||||
@@ -51,14 +50,12 @@ $main-spacing: 2rem;
|
||||
|
||||
// interface text
|
||||
$ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
||||
$title-gray: $gray-200;
|
||||
$label-gray: $gray-400;
|
||||
$secondary-text-gray: $gray-400;
|
||||
$muted-gray: $gray-600;
|
||||
$section-white: $ui-white;
|
||||
$inner-section-text-size: calc(1rem - 2px);
|
||||
$text-body-size: calc(1rem - 1px);
|
||||
$aux-text-size: calc(1rem - 3px);
|
||||
|
||||
// media queries
|
||||
$min-tablet: 500px;
|
||||
|
||||
@@ -43,7 +43,7 @@ export const ontimeInputTransparent = {
|
||||
...commonStyles,
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -56,6 +56,6 @@ export const ontimeTextAreaTransparent = {
|
||||
...commonStyles,
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
@@ -23,13 +25,14 @@ import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
// TODO: can we use the normalised rundown for the table?
|
||||
const { data: flatRundown } = useFlatRundown();
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
|
||||
const [eventId, setEventId] = useState<string | null>(null);
|
||||
|
||||
const { updateCustomField, updateEvent } = useEventAction();
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
@@ -40,6 +43,65 @@ export default function CuesheetPage() {
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
/**
|
||||
* Handles updating a custom field
|
||||
*/
|
||||
const handleUpdateCustom = useCallback(
|
||||
async (rowIndex: number, accessor: CustomFieldLabel, payload: string) => {
|
||||
if (!flatRundown || rundownStatus !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowIndex == null || accessor == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = flatRundown[rowIndex];
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if there is no value change
|
||||
const previousValue = event.custom[accessor];
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
updateCustomField(event.id, accessor, payload);
|
||||
},
|
||||
[flatRundown, rundownStatus, updateCustomField],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles updating all other string fields
|
||||
*/
|
||||
const handleUpdate = useCallback(
|
||||
async (rowIndex: number, accessor: keyof OntimeEvent, payload: string) => {
|
||||
if (!flatRundown || rundownStatus !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowIndex == null || accessor == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = flatRundown[rowIndex];
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if there is no value change
|
||||
const previousValue = event[accessor];
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateEvent({ id: event.id, [accessor]: payload });
|
||||
},
|
||||
[flatRundown, rundownStatus, updateEvent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles setting the edit modal target and visibility
|
||||
*/
|
||||
@@ -56,7 +118,7 @@ export default function CuesheetPage() {
|
||||
[onEventEditorClose, onEventEditorOpen],
|
||||
);
|
||||
|
||||
if (!customFields || !flatRundown) {
|
||||
if (!customFields || !flatRundown || rundownStatus !== 'success') {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
@@ -89,7 +151,13 @@ export default function CuesheetPage() {
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetDnd columns={columns}>
|
||||
<CuesheetTable data={flatRundown} columns={columns} showModal={setShowModal} />
|
||||
<CuesheetTable
|
||||
data={flatRundown}
|
||||
columns={columns}
|
||||
handleUpdate={handleUpdate}
|
||||
handleUpdateCustom={handleUpdateCustom}
|
||||
showModal={setShowModal}
|
||||
/>
|
||||
</CuesheetDnd>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -32,20 +32,24 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
|
||||
.tableHeader,
|
||||
.eventRow {
|
||||
.actionColumn {
|
||||
width: calc(2rem + 0.5rem); // sm button size (--chakra-sizes-8) + 2 * padding
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
|
||||
min-width: 3em; // allow for 3-digit numbers
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
font-size: $table-header-font-size;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: $gray-1300; // will be overridden inline
|
||||
}
|
||||
|
||||
.actionColumn {
|
||||
width: calc(1.5rem + 0.5rem); // sm button size (--chakra-sizes-6) + 2 * padding
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
@@ -121,6 +125,16 @@ th {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
.delayedTime {
|
||||
color: $ontime-delay-text;
|
||||
font-size: calc(1rem - 2px);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useRef } from 'react';
|
||||
import { Menu } from '@chakra-ui/react';
|
||||
import { IconButton, Menu, MenuButton } from '@chakra-ui/react';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import {
|
||||
CustomFieldLabel,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
TimeField,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -32,15 +31,16 @@ import style from './CuesheetTable.module.scss';
|
||||
interface CuesheetTableProps {
|
||||
data: OntimeRundown;
|
||||
columns: ColumnDef<OntimeRundownEntry>[];
|
||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: string) => void;
|
||||
handleUpdateCustom: (rowIndex: number, accessor: CustomFieldLabel, payload: string) => void;
|
||||
showModal: (eventId: MaybeString) => void;
|
||||
}
|
||||
|
||||
export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
const { data, columns, showModal } = props;
|
||||
const { data, columns, handleUpdate, handleUpdateCustom, showModal } = props;
|
||||
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { followSelected, hideDelays, hidePast, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||
const { followSelected, hideDelays, hidePast, hideIndexColumn } = useCuesheetOptions();
|
||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||
useColumnManager(columns);
|
||||
|
||||
@@ -57,40 +57,13 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
},
|
||||
meta: {
|
||||
handleUpdate,
|
||||
handleUpdateCustom,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta: {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// skip if there is no value change
|
||||
const key = accessor as keyof OntimeEvent;
|
||||
const previousValue = event[key];
|
||||
if (previousValue === payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
updateEvent({ id: event.id, custom: { [accessor]: payload } });
|
||||
return;
|
||||
}
|
||||
|
||||
updateEvent({ id: event.id, [accessor]: payload });
|
||||
},
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
|
||||
// the timer element already contains logic to avoid submitting a unchanged value
|
||||
updateTimer(eventId, field, payload, true);
|
||||
},
|
||||
options: {
|
||||
showDelayedTimes,
|
||||
hideTableSeconds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setAllVisible = () => {
|
||||
@@ -119,7 +92,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
/>
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet} id='cuesheet'>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<CuesheetHeader headerGroups={headerGroups} showIndexColumn={!hideIndexColumn} />
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
const key = row.original.id;
|
||||
@@ -172,7 +145,17 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
skip={entry.skip}
|
||||
colour={entry.colour}
|
||||
showIndexColumn={!hideIndexColumn}
|
||||
>
|
||||
<td>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
size='xs'
|
||||
aria-label='Options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
variant='ontime-ghosted'
|
||||
/>
|
||||
</td>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||
|
||||
+4
-5
@@ -3,7 +3,6 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import { SortableCell } from './SortableCell';
|
||||
|
||||
@@ -11,11 +10,11 @@ import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||
showIndexColumn: boolean;
|
||||
}
|
||||
|
||||
export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
const { headerGroups } = props;
|
||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||
const { headerGroups, showIndexColumn } = props;
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
@@ -24,8 +23,8 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
|
||||
return (
|
||||
<tr key={headerGroup.id}>
|
||||
{showActionMenu && <th className={style.actionColumn} />}
|
||||
{!hideIndexColumn && <th className={style.indexColumn}>#</th>}
|
||||
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
|
||||
<th className={style.actionColumn} />
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const width = header.getSize();
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { IconButton, MenuButton } from '@chakra-ui/react';
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
import Color from 'color';
|
||||
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface EventRowProps {
|
||||
eventIndex: number;
|
||||
showIndexColumn: boolean;
|
||||
isPast?: boolean;
|
||||
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
||||
skip?: boolean;
|
||||
@@ -17,8 +15,7 @@ interface EventRowProps {
|
||||
}
|
||||
|
||||
function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
|
||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||
const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -58,22 +55,9 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
|
||||
ref={selectedRef ?? ownRef}
|
||||
>
|
||||
{showActionMenu && (
|
||||
<td className={style.actionColumn}>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
size='sm'
|
||||
aria-label='Options'
|
||||
icon={<IoEllipsisHorizontal />}
|
||||
variant='ontime-subtle'
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{!hideIndexColumn && (
|
||||
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}>
|
||||
{eventIndex}
|
||||
</td>
|
||||
)}
|
||||
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}>
|
||||
{showIndexColumn && eventIndex}
|
||||
</td>
|
||||
{isVisible ? children : null}
|
||||
</tr>
|
||||
);
|
||||
|
||||
+6
-10
@@ -8,9 +8,7 @@ interface MultiLineCellProps {
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
function MultiLineCell(props: MultiLineCellProps) {
|
||||
const MultiLineCell = (props: MultiLineCellProps) => {
|
||||
const { initialValue, handleUpdate } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||
@@ -24,12 +22,8 @@ function MultiLineCell(props: MultiLineCellProps) {
|
||||
inputref={ref}
|
||||
rows={1}
|
||||
size='sm'
|
||||
style={{
|
||||
minHeight: '2rem',
|
||||
padding: '0',
|
||||
paddingTop: '0.25rem',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
padding={0}
|
||||
fontSize='1rem'
|
||||
transition='none'
|
||||
variant='ontime-transparent'
|
||||
value={value}
|
||||
@@ -39,4 +33,6 @@ function MultiLineCell(props: MultiLineCellProps) {
|
||||
spellCheck={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
+5
-26
@@ -1,46 +1,27 @@
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
|
||||
interface SingleLineCellProps {
|
||||
initialValue: string;
|
||||
allowSubmitSameValue?: boolean;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
handleCancelUpdate?: () => void;
|
||||
}
|
||||
|
||||
const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
|
||||
const { initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate } = props;
|
||||
const SingleLineCell = (props: SingleLineCellProps) => {
|
||||
const { initialValue, handleUpdate } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
allowSubmitSameValue,
|
||||
submitOnEnter: true, // single line should submit on enter
|
||||
submitOnCtrlEnter: true,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
});
|
||||
|
||||
// expose a subset of the methods to the parent
|
||||
useImperativeHandle(inputRef, () => {
|
||||
return {
|
||||
focus() {
|
||||
ref.current?.focus();
|
||||
},
|
||||
select() {
|
||||
ref.current?.select();
|
||||
},
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
size='sm'
|
||||
size='sx'
|
||||
variant='ontime-transparent'
|
||||
padding={0}
|
||||
fontSize='md'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
@@ -49,8 +30,6 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
|
||||
autoComplete='off'
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SingleLineCell.displayName = 'SingleLineCell';
|
||||
};
|
||||
|
||||
export default memo(SingleLineCell);
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/* element attempts matching input styles */
|
||||
.textInput {
|
||||
height: 2rem;
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
&.delayed {
|
||||
color: $ontime-delay-text;
|
||||
}
|
||||
|
||||
&.muted {
|
||||
// we use opacity instead of colour to handle delayed values
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { HTMLAttributes, memo, PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TextLikeInput.module.scss';
|
||||
|
||||
export default memo(TextLikeInput);
|
||||
|
||||
interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
delayed?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
function TextLikeInput(props: PropsWithChildren<TextLikeInputProps>) {
|
||||
const { delayed, muted, children, className, ...elementProps } = props;
|
||||
const classes = cx([style.textInput, delayed && style.delayed, muted && style.muted, className]);
|
||||
return (
|
||||
<div className={classes} {...elementProps} tabIndex={0}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { memo, PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TextLikeInput from './TextLikeInput';
|
||||
|
||||
interface TimeInputDurationProps {
|
||||
initialValue: number;
|
||||
lockedValue: boolean;
|
||||
delayed?: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
}
|
||||
|
||||
export default memo(TimeInputDuration);
|
||||
|
||||
function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
const { initialValue, lockedValue, delayed, onSubmit, children } = props;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// when we go into edit mode, set focus to the input
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// reset value when initialValue changes, avoiding interrupting the user if we are in edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [initialValue, isEditing]);
|
||||
|
||||
const handleFakeFocus = () => setIsEditing(true);
|
||||
const handleFakeBlur = () => setIsEditing(false);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(newValue: string) => {
|
||||
setIsEditing(false);
|
||||
|
||||
// if the user sends an empty string, we want to clear the value
|
||||
if (newValue === '') {
|
||||
onSubmit(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
onSubmit(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
if (valueInMillis < 0 || isNaN(valueInMillis)) {
|
||||
setValue(initialValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the value is the same, we may still want to push the lock change
|
||||
if (valueInMillis === initialValue && lockedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(newValue);
|
||||
setValue(Number(newValue));
|
||||
},
|
||||
[initialValue, lockedValue, onSubmit],
|
||||
);
|
||||
|
||||
const timeString = millisToString(value);
|
||||
|
||||
return isEditing ? (
|
||||
<SingleLineCell
|
||||
ref={inputRef}
|
||||
initialValue={timeString}
|
||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancelUpdate={handleFakeBlur}
|
||||
/>
|
||||
) : (
|
||||
<TextLikeInput onClick={handleFakeFocus} onFocus={handleFakeFocus} muted={!lockedValue} delayed={delayed}>
|
||||
{children}
|
||||
</TextLikeInput>
|
||||
);
|
||||
}
|
||||
+27
-83
@@ -1,95 +1,44 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString, removeSeconds } from 'ontime-utils';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import RunningTime from '../../../../features/viewers/common/running-time/RunningTime';
|
||||
import { useCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
|
||||
|
||||
const startTime = getValue() as number;
|
||||
const isStartLocked = (row.original as OntimeEvent).linkStart === null;
|
||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
|
||||
let formattedTime = millisToString(displayTime);
|
||||
if (hideTableSeconds) {
|
||||
formattedTime = removeSeconds(formattedTime);
|
||||
}
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
|
||||
</TimeInput>
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
<RunningTime value={cellValue} hideSeconds={hideTableSeconds} />
|
||||
{delayValue !== 0 && showDelayedTimes && (
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideTableSeconds} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const { hideTableSeconds } = useCuesheetOptions();
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
|
||||
|
||||
const endTime = getValue() as number;
|
||||
const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd;
|
||||
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
const displayTime = showDelayedTimes ? endTime + delayValue : endTime;
|
||||
let formattedTime = millisToString(displayTime);
|
||||
if (hideTableSeconds) {
|
||||
formattedTime = removeSeconds(formattedTime);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
|
||||
</TimeInput>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { handleUpdateTimer } = table.options.meta;
|
||||
|
||||
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
|
||||
|
||||
const duration = getValue() as number;
|
||||
const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration;
|
||||
const formattedDuration = formatDuration(duration, false);
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
|
||||
{formattedDuration}
|
||||
</TimeInput>
|
||||
);
|
||||
return <RunningTime value={cellValue} hideSeconds={hideTableSeconds} />;
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
@@ -108,7 +57,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
@@ -127,7 +77,8 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEn
|
||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdateCustom(row.index, column.id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[column.id, row.index],
|
||||
@@ -151,7 +102,6 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
meta: { colour: customFields[key].colour },
|
||||
cell: MakeCustomField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
}));
|
||||
|
||||
return [
|
||||
@@ -161,23 +111,20 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
header: 'Cue',
|
||||
cell: MakeSingleLineField,
|
||||
size: 75,
|
||||
minSize: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeStart',
|
||||
id: 'timeStart',
|
||||
header: 'Start',
|
||||
cell: MakeStart,
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeEnd',
|
||||
id: 'timeEnd',
|
||||
header: 'End',
|
||||
cell: MakeEnd,
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'duration',
|
||||
@@ -185,7 +132,6 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
header: 'Duration',
|
||||
cell: MakeDuration,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
@@ -193,7 +139,6 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
header: 'Title',
|
||||
cell: MakeSingleLineField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
@@ -201,7 +146,6 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
header: 'Note',
|
||||
cell: MakeMultiLineField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
},
|
||||
...dynamicCustomFields,
|
||||
];
|
||||
|
||||
@@ -10,13 +10,6 @@ import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
*/
|
||||
export const cuesheetOptions: ViewOption[] = [
|
||||
{ section: 'Table options' },
|
||||
{
|
||||
id: 'showActionMenu',
|
||||
title: 'Show action menu',
|
||||
description: 'Whether to show the action menu for every row in the table',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hideTableSeconds',
|
||||
title: 'Hide seconds in table',
|
||||
@@ -63,7 +56,6 @@ export const cuesheetOptions: ViewOption[] = [
|
||||
];
|
||||
|
||||
type CuesheetOptions = {
|
||||
showActionMenu: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
followSelected: boolean;
|
||||
hidePast: boolean;
|
||||
@@ -79,7 +71,6 @@ type CuesheetOptions = {
|
||||
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
|
||||
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
|
||||
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
|
||||
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||
@@ -30,8 +30,10 @@ function Timeline(props: TimelineProps) {
|
||||
const startHour = getStartHour(firstStart);
|
||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||
|
||||
let previousEventStartTime: MaybeNumber = null;
|
||||
// we use selectedEventId as a signifier on whether the timeline is live
|
||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||
let elapsedDays = 0;
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
@@ -50,7 +52,14 @@ function Timeline(props: TimelineProps) {
|
||||
eventStatus = 'live';
|
||||
}
|
||||
|
||||
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
|
||||
// we only need to check for next day if we have a previous event
|
||||
if (
|
||||
previousEventStartTime !== null &&
|
||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||
) {
|
||||
elapsedDays++;
|
||||
}
|
||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
||||
|
||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||
startHour * MILLIS_PER_HOUR,
|
||||
@@ -60,6 +69,9 @@ function Timeline(props: TimelineProps) {
|
||||
screenWidth,
|
||||
);
|
||||
|
||||
// prepare values for next iteration
|
||||
previousEventStartTime = normalisedStart;
|
||||
|
||||
return (
|
||||
<TimelineEntry
|
||||
key={event.id}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||
let firstStart = null;
|
||||
let totalDuration = 0;
|
||||
let lastEntry: PlayableEvent | undefined;
|
||||
let lastEntry: PlayableEvent | null = null;
|
||||
|
||||
for (let i = 0; i < rundown.length; i++) {
|
||||
const currentEntry = rundown[i];
|
||||
@@ -142,7 +142,12 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
||||
firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
|
||||
const timeFromPrevious: number = getTimeFromPrevious(
|
||||
currentEntry.timeStart,
|
||||
lastEntry?.timeStart,
|
||||
lastEntry?.timeEnd,
|
||||
lastEntry?.duration,
|
||||
);
|
||||
|
||||
if (timeFromPrevious === 0) {
|
||||
totalDuration += currentEntry.duration;
|
||||
@@ -151,7 +156,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
||||
} else if (timeFromPrevious < 0) {
|
||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||
}
|
||||
if (isNewLatest(currentEntry, lastEntry)) {
|
||||
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
||||
lastEntry = currentEntry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "3.10.3",
|
||||
"name": "ontime",
|
||||
"version": "3.9.5",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -109,13 +109,8 @@ function askToQuit() {
|
||||
* Allows processes to escalate errors to be shown in electron
|
||||
* @param {string} error
|
||||
*/
|
||||
function escalateError(error, unrecoverable = false) {
|
||||
if (unrecoverable) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
appShutdown();
|
||||
} else {
|
||||
dialog.showErrorBox('An error occurred', error);
|
||||
}
|
||||
function escalateError(error) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-10
@@ -2,25 +2,25 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.10.3",
|
||||
"version": "3.9.5",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.21.1",
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"express": "^4.18.2",
|
||||
"express-static-gzip": "^2.1.7",
|
||||
"express-validator": "^7.0.1",
|
||||
"fast-equals": "^5.0.1",
|
||||
"google-auth-library": "^9.4.2",
|
||||
"got": "^14.4.5",
|
||||
"got": "^14.0.0",
|
||||
"lowdb": "^7.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-osc": "^9.1.4",
|
||||
"node-osc": "^9.0.2",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"steno": "^4.0.2",
|
||||
"ws": "^8.18.0",
|
||||
"ws": "^8.13.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "catalog:",
|
||||
"@types/node-osc": "^6.0.3",
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
@@ -40,8 +40,8 @@
|
||||
"prettier": "catalog:",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-essentials": "^10.0.3",
|
||||
"tsx": "^4.19.2",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
|
||||
}
|
||||
|
||||
// clear the data
|
||||
excelData = xlsx.utils.book_new();
|
||||
excelData = undefined;
|
||||
|
||||
return { rundown, customFields };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import { GetInfo, SessionStats } from 'ontime-types';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { publicDir } from '../../setup/index.js';
|
||||
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
|
||||
import { socket } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getLastRequest } from '../../api-integration/integration.controller.js';
|
||||
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const validateRequestConnection = [
|
||||
.exists()
|
||||
.isString()
|
||||
.isLength({
|
||||
min: 20,
|
||||
min: 40,
|
||||
max: 100,
|
||||
})
|
||||
.withMessage('Sheet ID is usually 44 characters long'),
|
||||
|
||||
+73
-16
@@ -1,14 +1,12 @@
|
||||
import { LogOrigin, Playback, runtimeStorePlaceholder, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
import http, { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import serverTiming from 'server-timing';
|
||||
|
||||
// Import middleware configuration
|
||||
import { bodyParser } from './middleware/bodyParser.js';
|
||||
import { compressedStatic } from './middleware/staticGZip.js';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
// import utils
|
||||
import { publicDir, srcDir, srcFiles } from './setup/index.js';
|
||||
@@ -42,8 +40,8 @@ import { initialiseProject } from './services/project-service/ProjectService.js'
|
||||
// Utilities
|
||||
import { clearUploadfolder } from './utils/upload.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
|
||||
import { timerConfig } from './config/config.js';
|
||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
@@ -79,8 +77,8 @@ app.use(cors());
|
||||
app.options('*', cors());
|
||||
|
||||
// Implement middleware
|
||||
app.use(bodyParser);
|
||||
app.use(prefix, compressedStatic);
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Implement route endpoints
|
||||
app.use(`${prefix}/data`, appRouter); // router for application data
|
||||
@@ -94,6 +92,31 @@ app.use(`${prefix}/external`, (req, res) => {
|
||||
});
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||
|
||||
// serve static - react, in dev/test mode we fetch the React app from module
|
||||
app.use(
|
||||
prefix,
|
||||
expressStaticGzip(srcDir.clientDir, {
|
||||
enableBrotli: true,
|
||||
orderPreference: ['br'],
|
||||
// when we build the client the file names contain a unique hash for the build
|
||||
// this allows us to use the immutable tag
|
||||
// as the contents of a build file will never change without also changing its name
|
||||
// meaning that the client does not need to revalidate the contents with the server
|
||||
serveStatic: {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
immutable: true,
|
||||
maxAge: '1y',
|
||||
setHeaders: (res, file) => {
|
||||
// make sure the HTML files are always revalidated
|
||||
if (extname(file) === '.html') {
|
||||
res.setHeader('Cache-Control', 'public, max-age=0');
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(`${prefix}/*`, (_req, res) => {
|
||||
res.sendFile(srcFiles.clientIndexHtml);
|
||||
});
|
||||
@@ -153,17 +176,15 @@ export const initAssets = async () => {
|
||||
* Starts servers
|
||||
*/
|
||||
export const startServer = async (
|
||||
escalateErrorFn?: (error: string, unrecoverable: boolean) => void,
|
||||
escalateErrorFn?: (error: string) => void,
|
||||
): Promise<{ message: string; serverPort: number }> => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
// initialise logging service, escalateErrorFn only exists in electron
|
||||
logger.init(escalateErrorFn);
|
||||
const settings = getDataProvider().getSettings();
|
||||
const { serverPort: desiredPort } = settings;
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
|
||||
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
||||
// the express server must be started before the socket otherwise the on error eventlissner will not attach properly
|
||||
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||
|
||||
@@ -177,7 +198,7 @@ export const startServer = async (
|
||||
clock: state.clock,
|
||||
timer: state.timer,
|
||||
onAir: state.timer.playback !== Playback.Stop,
|
||||
message: { ...runtimeStorePlaceholder.message },
|
||||
message: messageService.getState(),
|
||||
runtime: state.runtime,
|
||||
eventNow: state.eventNow,
|
||||
currentBlock: {
|
||||
@@ -196,14 +217,14 @@ export const startServer = async (
|
||||
ping: -1,
|
||||
});
|
||||
|
||||
// initialise logging service, escalateErrorFn is only exists in electron
|
||||
logger.init(escalateErrorFn);
|
||||
|
||||
// initialise rundown service
|
||||
const persistedRundown = getDataProvider().getRundown();
|
||||
const persistedCustomFields = getDataProvider().getCustomFields();
|
||||
initRundown(persistedRundown, persistedCustomFields);
|
||||
|
||||
// initialise message service
|
||||
messageService.init(eventStore.set, eventStore.get);
|
||||
|
||||
// load restore point if it exists
|
||||
const maybeRestorePoint = await restoreService.load();
|
||||
|
||||
@@ -310,3 +331,39 @@ process.on('uncaughtException', async (error) => {
|
||||
process.once('SIGHUP', async () => shutdown(0));
|
||||
process.once('SIGINT', async () => shutdown(0));
|
||||
process.once('SIGTERM', async () => shutdown(0));
|
||||
|
||||
/**
|
||||
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to an efemeral port
|
||||
* @param {http.Server}server http server object
|
||||
* @param {number}desiredPort the desired port
|
||||
* @returns {number} the resulting port number
|
||||
* @throws any other server errors will result in a throw
|
||||
*/
|
||||
async function serverTryDesiredPort(server: http.Server, desiredPort: number): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
expressServer.once('error', (e) => {
|
||||
if (testForPortInUser(e)) {
|
||||
logger.crash(LogOrigin.Server, `Failed open the desired port: ${desiredPort} | to moving to Ephemeral port`);
|
||||
server.listen(0, '0.0.0.0', () => {
|
||||
// @ts-expect-error TODO: find proper documentation for this api
|
||||
const port: number = server.address().port;
|
||||
res(port);
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
server.listen(desiredPort, '0.0.0.0', () => {
|
||||
// @ts-expect-error TODO: find proper documentation for this api
|
||||
const port: number = server.address().port;
|
||||
res(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function testForPortInUser(err: unknown) {
|
||||
if (typeof err === 'object' && 'code' in err && err.code === 'EADDRINUSE') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isProduction } from '../externals.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
private escalateErrorFn: ((error: string, unrecoverable: boolean) => void) | null;
|
||||
private escalateErrorFn: ((error: string) => void) | null;
|
||||
private canLog = false;
|
||||
|
||||
constructor() {
|
||||
@@ -20,7 +20,7 @@ class Logger {
|
||||
/**
|
||||
* Enabling setup logger after init
|
||||
*/
|
||||
init(escalateErrorFn?: (error: string, unrecoverable: boolean) => void) {
|
||||
init(escalateErrorFn?: (error: string) => void) {
|
||||
// flush logs from queue
|
||||
this.queue.forEach((log) => {
|
||||
this._push(log);
|
||||
@@ -103,11 +103,8 @@ class Logger {
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
error(origin: string, text: string, escalate = false) {
|
||||
error(origin: string, text: string) {
|
||||
this.emit(LogLevel.Error, origin, text);
|
||||
if (escalate) {
|
||||
this.escalateErrorFn?.(text, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +114,7 @@ class Logger {
|
||||
*/
|
||||
crash(origin: string, text: string) {
|
||||
this.emit(LogLevel.Severe, origin, text);
|
||||
this.escalateErrorFn?.(text, true);
|
||||
this.escalateErrorFn?.(text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,7 +86,7 @@ function getCustomFields(): Readonly<CustomFields> {
|
||||
}
|
||||
|
||||
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
|
||||
db.data.rundown = newData;
|
||||
db.data.rundown = [...newData];
|
||||
await persist();
|
||||
return db.data.rundown;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,6 @@ describe('safeMerge', () => {
|
||||
publicInfo: '',
|
||||
backstageUrl: '',
|
||||
backstageInfo: '',
|
||||
projectLogo: null,
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
export const bodyParser = [express.urlencoded({ extended: true }), express.json({ limit: '1mb' })];
|
||||
@@ -1,26 +0,0 @@
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
import { srcDir } from '../setup/index.js';
|
||||
|
||||
// serve static - react, in dev/test mode we fetch the React app from module
|
||||
export const compressedStatic = expressStaticGzip(srcDir.clientDir, {
|
||||
enableBrotli: true,
|
||||
orderPreference: ['br'],
|
||||
// when we build the client the file names contain a unique hash for the build
|
||||
// this allows us to use the immutable tag
|
||||
// as the contents of a build file will never change without also changing its name
|
||||
// meaning that the client does not need to revalidate the contents with the server
|
||||
serveStatic: {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
immutable: true,
|
||||
maxAge: '1y',
|
||||
setHeaders: (res, file) => {
|
||||
// make sure the HTML files are always revalidated
|
||||
if (extname(file) === '.html') {
|
||||
res.setHeader('Cache-Control', 'public, max-age=0');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.01',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 36000000,
|
||||
@@ -20,9 +20,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -38,7 +35,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.02',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 37500000,
|
||||
@@ -48,9 +45,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -66,7 +60,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.03',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 39000000,
|
||||
@@ -76,9 +70,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -94,7 +85,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.04',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 40500000,
|
||||
@@ -104,9 +95,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -122,7 +110,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.05',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 42000000,
|
||||
@@ -132,9 +120,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -155,7 +140,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.06',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 47100000,
|
||||
@@ -165,9 +150,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -183,7 +165,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.07',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 48600000,
|
||||
@@ -193,9 +175,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -211,7 +190,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.08',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 50100000,
|
||||
@@ -221,9 +200,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -239,7 +215,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.09',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 51600000,
|
||||
@@ -249,9 +225,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -267,7 +240,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.10',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 53100000,
|
||||
@@ -277,9 +250,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -300,7 +270,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.11',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 56100000,
|
||||
@@ -310,9 +280,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -328,7 +295,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.12',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 57600000,
|
||||
@@ -338,9 +305,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -356,7 +320,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.13',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 59100000,
|
||||
@@ -366,9 +330,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
@@ -384,7 +345,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'SF1.14',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
timeStart: 60600000,
|
||||
@@ -394,9 +355,6 @@ export const demoDb: DatabaseModel = {
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 500000,
|
||||
timeDanger: 100000,
|
||||
custom: {
|
||||
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
|
||||
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
||||
title: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
linkStart: null,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
@@ -24,9 +24,6 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { loadRoll } from '../rollUtils.js';
|
||||
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js';
|
||||
|
||||
const baseEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
};
|
||||
|
||||
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
|
||||
return {
|
||||
...baseEvent,
|
||||
...patch,
|
||||
} as OntimeEvent;
|
||||
}
|
||||
|
||||
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
|
||||
return events.map(makeOntimeEvent);
|
||||
}
|
||||
|
||||
describe('loadRoll()', () => {
|
||||
const eventlist = [
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('getExpectedFinish()', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 10,
|
||||
@@ -195,7 +195,7 @@ describe('getExpectedFinish()', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -346,12 +346,12 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(35);
|
||||
});
|
||||
|
||||
describe('on timers of type count-to-end', () => {
|
||||
describe('on timers of type time-to-end', () => {
|
||||
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
timer: {
|
||||
@@ -376,7 +376,7 @@ describe('getCurrent()', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
timer: {
|
||||
@@ -401,7 +401,7 @@ describe('getCurrent()', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
timer: {
|
||||
@@ -427,7 +427,7 @@ describe('getCurrent()', () => {
|
||||
eventNow: {
|
||||
timeStart: 79200000, // 22:00:00
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
timer: {
|
||||
@@ -456,7 +456,7 @@ describe('getCurrent()', () => {
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -910,7 +910,7 @@ describe('getRuntimeOffset()', () => {
|
||||
linkStart: null,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
@@ -963,7 +963,7 @@ describe('getRuntimeOffset()', () => {
|
||||
linkStart: null,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
@@ -1014,7 +1014,7 @@ describe('getRuntimeOffset()', () => {
|
||||
linkStart: null,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: true,
|
||||
isTimeToEnd: true,
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
|
||||
@@ -2,21 +2,12 @@ import { MessageState, runtimeStorePlaceholder } from 'ontime-types';
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import { throttle } from '../../utils/throttle.js';
|
||||
import type { StoreGetter, PublishFn } from '../../stores/EventStore.js';
|
||||
import { eventStore, type PublishFn } from '../../stores/EventStore.js';
|
||||
|
||||
/**
|
||||
* Create a throttled version of the set function
|
||||
*/
|
||||
let throttledSet: PublishFn = () => undefined;
|
||||
let storeGet: StoreGetter = (_key: string) => undefined;
|
||||
|
||||
/**
|
||||
* Allows providing store interfaces
|
||||
*/
|
||||
export function init(storeSetter: PublishFn, storeGetter: StoreGetter) {
|
||||
throttledSet = throttle(storeSetter, 100);
|
||||
storeGet = storeGetter;
|
||||
}
|
||||
const throttledSet: PublishFn = throttle(eventStore.set, 100);
|
||||
|
||||
/**
|
||||
* Exposes function to reset the internal state
|
||||
@@ -31,7 +22,7 @@ export function clear() {
|
||||
* Exposes the internal state of the message service
|
||||
*/
|
||||
export function getState(): MessageState {
|
||||
return storeGet('message');
|
||||
return eventStore.get('message');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,32 +2,31 @@ import * as messageService from '../MessageService.js';
|
||||
|
||||
describe('MessageService', () => {
|
||||
beforeEach(() => {
|
||||
// at runtime, the store is instantiated before the message service
|
||||
const store = {};
|
||||
const storeSetter = (key, value) => (store[key] = value);
|
||||
const storeGetter = (key) => store[key];
|
||||
messageService.init(storeSetter, storeGetter);
|
||||
messageService.clear();
|
||||
});
|
||||
|
||||
it('should patch the message state', () => {
|
||||
const newState = messageService.patch({
|
||||
const message = {
|
||||
timer: { text: 'new text', visible: true },
|
||||
external: 'external',
|
||||
});
|
||||
};
|
||||
|
||||
expect(newState).toMatchObject({
|
||||
const newState = messageService.patch(message);
|
||||
|
||||
expect(newState).toEqual({
|
||||
timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
|
||||
external: 'external',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect other properties when patching', () => {
|
||||
const newState = messageService.patch({
|
||||
const initialMessage = {
|
||||
timer: { text: 'initial text', visible: true },
|
||||
});
|
||||
};
|
||||
|
||||
expect(newState).toMatchObject({
|
||||
const newState = messageService.patch(initialMessage);
|
||||
|
||||
expect(newState).toEqual({
|
||||
timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
|
||||
external: '',
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
|
||||
|
||||
const baseEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility to create a Ontime event
|
||||
*/
|
||||
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
|
||||
return {
|
||||
...baseEvent,
|
||||
...patch,
|
||||
} as OntimeEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to create a delay event
|
||||
*/
|
||||
export function makeOntimeDelay(duration: number): OntimeDelay {
|
||||
return { id: 'delay', type: SupportedEvent.Delay, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to generate a rundown of OntimeEvents form partial objects
|
||||
*/
|
||||
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
|
||||
return events.map(makeOntimeEvent);
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
import { apply } from '../delayUtils.js';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { apply } from '../delayUtils.js';
|
||||
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||
/**
|
||||
* Small utility to fill in the necessary data for the test
|
||||
*/
|
||||
function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
|
||||
return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small utility to make a delay event
|
||||
*/
|
||||
function makeOntimeDelay(duration: number): OntimeDelay {
|
||||
return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
|
||||
}
|
||||
|
||||
describe('apply()', () => {
|
||||
it('applies a positive delay to the rundown', () => {
|
||||
@@ -138,11 +150,11 @@ describe('apply()', () => {
|
||||
makeOntimeDelay(100),
|
||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
// gap 50
|
||||
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
|
||||
// gap 0
|
||||
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
|
||||
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
|
||||
// gap 50
|
||||
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
|
||||
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
|
||||
// gap 50
|
||||
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
|
||||
// linked
|
||||
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
||||
];
|
||||
@@ -153,7 +165,7 @@ describe('apply()', () => {
|
||||
// gap 50 (100 - 50)
|
||||
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
||||
// gap 50 (50 - 50)
|
||||
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
|
||||
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
|
||||
// gap (delay is 0)
|
||||
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
||||
// linked
|
||||
@@ -166,8 +178,6 @@ describe('apply()', () => {
|
||||
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
||||
makeOntimeEvent({
|
||||
id: '1',
|
||||
gap: 0,
|
||||
dayOffset: 0,
|
||||
timeStart: 46800000, // 13:00:00
|
||||
timeEnd: 50400000, // 14:00:00
|
||||
duration: MILLIS_PER_HOUR,
|
||||
@@ -175,8 +185,6 @@ describe('apply()', () => {
|
||||
// gap 1h
|
||||
makeOntimeEvent({
|
||||
id: '2',
|
||||
gap: 1 * MILLIS_PER_HOUR,
|
||||
dayOffset: 0,
|
||||
timeStart: 54000000, // 15:00:00
|
||||
timeEnd: 57600000, // 16:00:00
|
||||
duration: MILLIS_PER_HOUR,
|
||||
|
||||
@@ -438,7 +438,7 @@ describe('add() mutation', () => {
|
||||
test('adds an event to the rundown', () => {
|
||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||
const testRundown: OntimeRundown = [];
|
||||
const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown });
|
||||
const { newRundown } = add({ atIndex: 0, event: mockEvent, persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(1);
|
||||
expect(newRundown[0]).toMatchObject(mockEvent);
|
||||
});
|
||||
@@ -448,7 +448,7 @@ describe('remove() mutation', () => {
|
||||
test('deletes an event from the rundown', () => {
|
||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||
const testRundown: OntimeRundown = [mockEvent];
|
||||
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown });
|
||||
const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(0);
|
||||
});
|
||||
test('deletes multiple events from the rundown', () => {
|
||||
@@ -460,7 +460,7 @@ describe('remove() mutation', () => {
|
||||
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
|
||||
];
|
||||
const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown });
|
||||
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(3);
|
||||
expect(newRundown.at(0)?.id).toBe('4');
|
||||
});
|
||||
@@ -474,7 +474,7 @@ describe('edit() mutation', () => {
|
||||
const { newRundown, newEvent } = edit({
|
||||
eventId: mockEvent.id,
|
||||
patch: mockEventPatch,
|
||||
rundown: testRundown,
|
||||
persistedRundown: testRundown,
|
||||
});
|
||||
expect(newRundown.length).toBe(1);
|
||||
expect(newEvent).toMatchObject({
|
||||
@@ -487,7 +487,7 @@ describe('edit() mutation', () => {
|
||||
|
||||
describe('batchEdit() mutation', () => {
|
||||
it('should correctly apply the patch to the events with the given IDs', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
|
||||
@@ -495,7 +495,7 @@ describe('batchEdit() mutation', () => {
|
||||
const eventIds = ['1', '3'];
|
||||
const patch = { cue: 'newData' };
|
||||
|
||||
const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch });
|
||||
const { newRundown } = batchEdit({ persistedRundown, eventIds, patch });
|
||||
|
||||
expect(newRundown).toMatchObject([
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
|
||||
@@ -507,16 +507,16 @@ describe('batchEdit() mutation', () => {
|
||||
|
||||
describe('reorder() mutation', () => {
|
||||
it('should correctly reorder two events', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
|
||||
];
|
||||
const { newRundown } = reorder({
|
||||
rundown: testRundown,
|
||||
eventId: testRundown[0].id,
|
||||
persistedRundown,
|
||||
eventId: persistedRundown[0].id,
|
||||
from: 0,
|
||||
to: testRundown.length - 1,
|
||||
to: persistedRundown.length - 1,
|
||||
});
|
||||
|
||||
expect(newRundown).toMatchObject([
|
||||
@@ -529,15 +529,15 @@ describe('reorder() mutation', () => {
|
||||
|
||||
describe('swap() mutation', () => {
|
||||
it('should correctly swap data between events', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
|
||||
];
|
||||
const { newRundown } = swap({
|
||||
rundown: testRundown,
|
||||
fromId: testRundown[0].id,
|
||||
toId: testRundown[1].id,
|
||||
persistedRundown,
|
||||
fromId: persistedRundown[0].id,
|
||||
toId: persistedRundown[1].id,
|
||||
});
|
||||
|
||||
expect((newRundown[0] as OntimeEvent).id).toBe('1');
|
||||
@@ -565,7 +565,7 @@ describe('calculateRuntimeDelays', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -576,9 +576,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '659e1',
|
||||
@@ -595,7 +592,7 @@ describe('calculateRuntimeDelays', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -606,9 +603,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '1c48f',
|
||||
@@ -625,7 +619,7 @@ describe('calculateRuntimeDelays', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -636,9 +630,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: 'd48c2',
|
||||
@@ -655,7 +646,7 @@ describe('calculateRuntimeDelays', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -666,9 +657,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '2f185',
|
||||
@@ -694,7 +682,7 @@ describe('getDelayAt()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -709,8 +697,6 @@ describe('getDelayAt()', () => {
|
||||
timeDanger: 60000,
|
||||
id: '659e1',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
cue: '1',
|
||||
custom: {},
|
||||
},
|
||||
@@ -724,7 +710,7 @@ describe('getDelayAt()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -735,8 +721,6 @@ describe('getDelayAt()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '1c48f',
|
||||
@@ -754,7 +738,7 @@ describe('getDelayAt()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -765,8 +749,6 @@ describe('getDelayAt()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: 'd48c2',
|
||||
@@ -784,7 +766,7 @@ describe('getDelayAt()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -795,8 +777,6 @@ describe('getDelayAt()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '2f185',
|
||||
@@ -840,7 +820,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -851,8 +831,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '659e1',
|
||||
@@ -870,7 +848,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -881,8 +859,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '1c48f',
|
||||
@@ -900,7 +876,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 600000,
|
||||
@@ -911,8 +887,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: 'd48c2',
|
||||
@@ -930,7 +904,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
isTimeToEnd: false,
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
timeStart: 1200000,
|
||||
@@ -941,8 +915,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
colour: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
id: '2f185',
|
||||
@@ -963,7 +935,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
|
||||
describe('custom fields', () => {
|
||||
describe('createCustomField()', () => {
|
||||
it('creates a field from given parameters', () => {
|
||||
it('creates a field from given parameters', async () => {
|
||||
const expected = {
|
||||
Lighting: {
|
||||
label: 'Lighting',
|
||||
@@ -972,14 +944,14 @@ describe('custom fields', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const customField = createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
|
||||
const customField = await createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
|
||||
expect(customField).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editCustomField()', () => {
|
||||
it('edits a field with a given label', () => {
|
||||
createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
|
||||
it('edits a field with a given label', async () => {
|
||||
await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
|
||||
|
||||
const expected = {
|
||||
Lighting: {
|
||||
@@ -994,14 +966,14 @@ describe('custom fields', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
||||
const customField = await editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
|
||||
expect(customField).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('renames a field to a new label', () => {
|
||||
const created = createCustomField({ label: 'Video', type: 'string', colour: 'red' });
|
||||
it('renames a field to a new label', async () => {
|
||||
const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' });
|
||||
|
||||
const expected = {
|
||||
Lighting: {
|
||||
@@ -1043,10 +1015,10 @@ describe('custom fields', () => {
|
||||
|
||||
// We need to flush all scheduled tasks for the generate function to settle
|
||||
vi.useFakeTimers();
|
||||
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
||||
const customField = await editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
||||
expect(customField).toStrictEqual(expectedAfter);
|
||||
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
|
||||
editCustomField('AV', { label: 'Video' });
|
||||
await editCustomField('AV', { label: 'Video' });
|
||||
vi.runAllTimers();
|
||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||
vi.useRealTimers();
|
||||
@@ -1054,7 +1026,7 @@ describe('custom fields', () => {
|
||||
});
|
||||
|
||||
describe('removeCustomField()', () => {
|
||||
it('deletes a field with a given label', () => {
|
||||
it('deletes a field with a given label', async () => {
|
||||
const expected = {
|
||||
Lighting: {
|
||||
label: 'Lighting',
|
||||
@@ -1068,7 +1040,7 @@ describe('custom fields', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const customField = removeCustomField('Sound');
|
||||
const customField = await removeCustomField('Sound');
|
||||
|
||||
expect(customField).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
addToCustomAssignment,
|
||||
calculateDayOffset,
|
||||
getLink,
|
||||
handleCustomField,
|
||||
handleLink,
|
||||
hasChanges,
|
||||
isDataStale,
|
||||
} from '../rundownCacheUtils.js';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
describe('getLink()', () => {
|
||||
it('should return null if there is no link', () => {
|
||||
@@ -249,61 +247,3 @@ describe('hasChanges()', () => {
|
||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDayOffset', () => {
|
||||
it('returns 0 if there is no previous event', () => {
|
||||
expect(calculateDayOffset({ timeStart: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 if the previous event duration is 0', () => {
|
||||
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 if event starts after previous', () => {
|
||||
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 if event starts before previous', () => {
|
||||
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 if event starts at the same time as one before', () => {
|
||||
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight and there is a overlap', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight and there is a gap', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should account for an event that finishes exactly at midnight', () => {
|
||||
expect(
|
||||
calculateDayOffset(
|
||||
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
|
||||
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
import { deleteAtIndex } from 'ontime-utils';
|
||||
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Calculates all delays in a given rundown
|
||||
@@ -133,14 +133,21 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
||||
|
||||
// if the event is not linked, we try and maintain gaps
|
||||
if (lastEntry !== null) {
|
||||
const timeFromPrevious: number = getTimeFromPrevious(
|
||||
currentEntry.timeStart,
|
||||
lastEntry.timeStart,
|
||||
lastEntry.timeEnd,
|
||||
lastEntry.duration,
|
||||
);
|
||||
|
||||
// when applying negative delays, we need to unlink the event
|
||||
// if the previous event was fully consumed by the delay
|
||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
||||
shouldUnlink = true;
|
||||
}
|
||||
|
||||
if (currentEntry.gap > 0) {
|
||||
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
||||
if (timeFromPrevious > 0) {
|
||||
delayValue = Math.max(delayValue - timeFromPrevious, 0);
|
||||
}
|
||||
|
||||
if (delayValue === 0) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user