mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9c2a9cfd | |||
| e9a7cd0400 | |||
| 9ba40c35a1 | |||
| f94db06812 | |||
| 35424cfff2 | |||
| 13eadeb0af | |||
| 9cfed5a150 | |||
| a22c2d380d | |||
| cd913fc144 | |||
| 743d7fa782 | |||
| 891c43ba77 | |||
| fba20b889e | |||
| bc4d913ba7 | |||
| 533f9798e8 |
@@ -28,6 +28,7 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
automated-screenshots
|
automated-screenshots
|
||||||
|
e2e/tests/fixtures/tmp/*
|
||||||
|
|
||||||
# production
|
# production
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
|||||||
* HTTP request to the current DB
|
* HTTP request to the current DB
|
||||||
*/
|
*/
|
||||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||||
return axios.post(`${dbPath}/download/`, { filename });
|
return axios.post(`${dbPath}/download`, { filename });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
|||||||
skip: event.skip,
|
skip: event.skip,
|
||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
custom: {},
|
custom: {},
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ClientList() {
|
|||||||
)}
|
)}
|
||||||
{name}
|
{name}
|
||||||
</td>
|
</td>
|
||||||
{isCurrent ? <td /> : <td className={style.pathList}>{path}</td>}
|
<td className={style.pathList}>{path}</td>
|
||||||
<td className={style.actionButtons}>
|
<td className={style.actionButtons}>
|
||||||
<Button
|
<Button
|
||||||
size='xs'
|
size='xs'
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export default function UrlPresetsForm() {
|
|||||||
{fields.map((preset, index) => {
|
{fields.map((preset, index) => {
|
||||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.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 (
|
return (
|
||||||
<tr key={preset.id}>
|
<tr key={preset.id}>
|
||||||
<td className={style.fit}>
|
<td className={style.fit}>
|
||||||
@@ -191,6 +195,7 @@ export default function UrlPresetsForm() {
|
|||||||
<td className={style.flex}>
|
<td className={style.flex}>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
size='sm'
|
size='sm'
|
||||||
|
isDisabled={!canTest}
|
||||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||||
tooltip='Test preset'
|
tooltip='Test preset'
|
||||||
aria-label='Test preset'
|
aria-label='Test preset'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
|
checkIsNextDay,
|
||||||
getFirstNormal,
|
getFirstNormal,
|
||||||
getLastNormal,
|
getLastNormal,
|
||||||
getNextBlockNormal,
|
getNextBlockNormal,
|
||||||
@@ -267,7 +268,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// all events before the current selected are in the past
|
// all events before the current selected are in the past
|
||||||
let isPast = Boolean(featureData?.selectedEventId);
|
let isPast = Boolean(featureData?.selectedEventId);
|
||||||
|
let isNextDay = false;
|
||||||
const isEditMode = appMode === AppMode.Edit;
|
const isEditMode = appMode === AppMode.Edit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -286,6 +287,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = 0;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
|
isNextDay = false;
|
||||||
previousEntryId = thisId;
|
previousEntryId = thisId;
|
||||||
thisId = entryId;
|
thisId = entryId;
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
@@ -294,8 +296,9 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
lastEvent = thisEvent;
|
lastEvent = thisEvent;
|
||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
// populate previous entry
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||||
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
|
if (isNewLatest(entry, lastEvent)) {
|
||||||
|
// populate previous entry
|
||||||
thisEvent = entry;
|
thisEvent = entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,12 +326,11 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
loaded={isLoaded}
|
loaded={isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousStart={lastEvent?.timeStart}
|
|
||||||
previousEnd={lastEvent?.timeEnd}
|
|
||||||
previousEntryId={previousEntryId}
|
previousEntryId={previousEntryId}
|
||||||
previousEventId={lastEvent?.id}
|
previousEventId={lastEvent?.id}
|
||||||
playback={isLoaded ? featureData.playback : undefined}
|
playback={isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
|
isNextDay={isNextDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ interface RundownEntryProps {
|
|||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
previousStart?: number;
|
isNextDay: boolean;
|
||||||
previousEnd?: number;
|
|
||||||
previousEntryId?: string;
|
previousEntryId?: string;
|
||||||
previousEventId?: string;
|
previousEventId?: string;
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
@@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
loaded,
|
loaded,
|
||||||
hasCursor,
|
hasCursor,
|
||||||
isNext,
|
isNext,
|
||||||
previousStart,
|
|
||||||
previousEnd,
|
|
||||||
previousEntryId,
|
previousEntryId,
|
||||||
previousEventId,
|
previousEventId,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
|
isNextDay,
|
||||||
} = props;
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||||
@@ -165,8 +163,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
title={data.title}
|
title={data.title}
|
||||||
note={data.note}
|
note={data.note}
|
||||||
delay={data.delay ?? 0}
|
delay={data.delay ?? 0}
|
||||||
previousStart={previousStart}
|
|
||||||
previousEnd={previousEnd}
|
|
||||||
colour={data.colour}
|
colour={data.colour}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
@@ -175,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
|
gap={data.gap}
|
||||||
|
isNextDay={isNextDay}
|
||||||
actionHandler={actionHandler}
|
actionHandler={actionHandler}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ interface EventBlockProps {
|
|||||||
title: string;
|
title: string;
|
||||||
note: string;
|
note: string;
|
||||||
delay: number;
|
delay: number;
|
||||||
previousStart?: number;
|
|
||||||
previousEnd?: number;
|
|
||||||
colour: string;
|
colour: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
@@ -49,6 +47,8 @@ interface EventBlockProps {
|
|||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
|
gap: number;
|
||||||
|
isNextDay: boolean;
|
||||||
actionHandler: (
|
actionHandler: (
|
||||||
action: EventItemActions,
|
action: EventItemActions,
|
||||||
payload?:
|
payload?:
|
||||||
@@ -77,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
title,
|
title,
|
||||||
note,
|
note,
|
||||||
delay,
|
delay,
|
||||||
previousStart,
|
|
||||||
previousEnd,
|
|
||||||
colour,
|
colour,
|
||||||
isPast,
|
isPast,
|
||||||
isNext,
|
isNext,
|
||||||
@@ -87,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
hasCursor,
|
hasCursor,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
|
gap,
|
||||||
|
isNextDay,
|
||||||
actionHandler,
|
actionHandler,
|
||||||
} = props;
|
} = props;
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
@@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
id='event-block'
|
id='event-block'
|
||||||
>
|
>
|
||||||
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
|
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
|
||||||
|
|
||||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
calculateDuration,
|
|
||||||
checkIsNextDay,
|
|
||||||
dayInMs,
|
|
||||||
getTimeFromPrevious,
|
|
||||||
millisToString,
|
|
||||||
removeTrailingZero,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
@@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
|
|||||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||||
return `New start ${timeTag}`;
|
return `New start ${timeTag}`;
|
||||||
}
|
}
|
||||||
|
export function formatGap(gap: number, isNextDay: boolean) {
|
||||||
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
|
if (gap === 0) {
|
||||||
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
|
if (isNextDay) {
|
||||||
if (noPreviousElement) return;
|
// We show a next day warning even if there is no gap
|
||||||
|
return '(next day)';
|
||||||
const normalisedDuration = calculateDuration(previousStart, previousEnd);
|
}
|
||||||
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
|
return;
|
||||||
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 overlapString = formatDuration(Math.abs(timeFromPrevious), false);
|
const gapString = formatDuration(Math.abs(gap), false);
|
||||||
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
|
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { formatDelay, formatOverlap } from './EventBlock.utils';
|
import { formatDelay, formatGap } from './EventBlock.utils';
|
||||||
|
|
||||||
import style from './RundownIndicators.module.scss';
|
import style from './RundownIndicators.module.scss';
|
||||||
|
|
||||||
interface RundownIndicatorProps {
|
interface RundownIndicatorProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
previousStart?: number;
|
isNextDay: boolean;
|
||||||
previousEnd?: number;
|
|
||||||
delay: number;
|
delay: number;
|
||||||
|
gap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownIndicators(props: RundownIndicatorProps) {
|
export default function RundownIndicators(props: RundownIndicatorProps) {
|
||||||
const { timeStart, previousStart, previousEnd, delay } = props;
|
const { timeStart, delay, gap, isNextDay } = props;
|
||||||
|
|
||||||
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
|
const hasGap = formatGap(gap, isNextDay);
|
||||||
const hasDelay = formatDelay(timeStart, delay);
|
const hasDelay = formatDelay(timeStart, delay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.indicators}>
|
<div className={style.indicators}>
|
||||||
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
||||||
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
|
{hasGap && <div className={style.gap}>{hasGap}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { formatDelay } from '../EventBlock.utils';
|
||||||
|
|
||||||
import { formatDelay, formatOverlap } from '../EventBlock.utils';
|
|
||||||
|
|
||||||
describe('formatDelay()', () => {
|
describe('formatDelay()', () => {
|
||||||
it('adds a given delay to the start time', () => {
|
it('adds a given delay to the start time', () => {
|
||||||
@@ -10,69 +8,3 @@ describe('formatDelay()', () => {
|
|||||||
expect(result).toEqual('New start 00:02');
|
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
|
|||||||
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
||||||
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
||||||
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
@@ -45,7 +46,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (timeStart > timeEnd) {
|
if (timeStart + duration > dayInMs) {
|
||||||
warnings.push('Over midnight');
|
warnings.push('Over midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<div className={style.timerNote}>
|
<div className={style.timerNote} data-testid='event-warning'>
|
||||||
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
|
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
|
||||||
<IoAlertCircleOutline />
|
<IoAlertCircleOutline />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useViewportSize } from '@mantine/hooks';
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
|
||||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||||
@@ -30,10 +30,8 @@ function Timeline(props: TimelineProps) {
|
|||||||
const startHour = getStartHour(firstStart);
|
const startHour = getStartHour(firstStart);
|
||||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||||
|
|
||||||
let previousEventStartTime: MaybeNumber = null;
|
|
||||||
// we use selectedEventId as a signifier on whether the timeline is live
|
// we use selectedEventId as a signifier on whether the timeline is live
|
||||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||||
let elapsedDays = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.timeline}>
|
<div className={style.timeline}>
|
||||||
@@ -52,14 +50,7 @@ function Timeline(props: TimelineProps) {
|
|||||||
eventStatus = 'live';
|
eventStatus = 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only need to check for next day if we have a previous event
|
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
|
||||||
if (
|
|
||||||
previousEventStartTime !== null &&
|
|
||||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
|
||||||
) {
|
|
||||||
elapsedDays++;
|
|
||||||
}
|
|
||||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
|
||||||
|
|
||||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||||
startHour * MILLIS_PER_HOUR,
|
startHour * MILLIS_PER_HOUR,
|
||||||
@@ -69,9 +60,6 @@ function Timeline(props: TimelineProps) {
|
|||||||
screenWidth,
|
screenWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// prepare values for next iteration
|
|
||||||
previousEventStartTime = normalisedStart;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineEntry
|
<TimelineEntry
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||||
let firstStart = null;
|
let firstStart = null;
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
let lastEntry: PlayableEvent | null = null;
|
let lastEntry: PlayableEvent | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < rundown.length; i++) {
|
for (let i = 0; i < rundown.length; i++) {
|
||||||
const currentEntry = rundown[i];
|
const currentEntry = rundown[i];
|
||||||
@@ -142,12 +142,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
firstStart = currentEntry.timeStart;
|
firstStart = currentEntry.timeStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry?.timeStart,
|
|
||||||
lastEntry?.timeEnd,
|
|
||||||
lastEntry?.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
if (timeFromPrevious === 0) {
|
||||||
totalDuration += currentEntry.duration;
|
totalDuration += currentEntry.duration;
|
||||||
@@ -156,7 +151,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
} else if (timeFromPrevious < 0) {
|
} else if (timeFromPrevious < 0) {
|
||||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||||
}
|
}
|
||||||
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
if (isNewLatest(currentEntry, lastEntry)) {
|
||||||
lastEntry = currentEntry;
|
lastEntry = currentEntry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
// clear the data
|
// clear the data
|
||||||
excelData = undefined;
|
excelData = xlsx.utils.book_new();
|
||||||
|
|
||||||
return { rundown, customFields };
|
return { rundown, customFields };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const validateRequestConnection = [
|
|||||||
.exists()
|
.exists()
|
||||||
.isString()
|
.isString()
|
||||||
.isLength({
|
.isLength({
|
||||||
min: 40,
|
min: 20,
|
||||||
max: 100,
|
max: 100,
|
||||||
})
|
})
|
||||||
.withMessage('Sheet ID is usually 44 characters long'),
|
.withMessage('Sheet ID is usually 44 characters long'),
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function getCustomFields(): Readonly<CustomFields> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
|
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
|
||||||
db.data.rundown = [...newData];
|
db.data.rundown = newData;
|
||||||
await persist();
|
await persist();
|
||||||
return db.data.rundown;
|
return db.data.rundown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ describe('safeMerge', () => {
|
|||||||
publicInfo: '',
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
backstageInfo: '',
|
backstageInfo: '',
|
||||||
|
projectLogo: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -45,6 +48,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -70,6 +76,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -95,6 +104,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -120,6 +132,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -150,6 +165,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -175,6 +193,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -200,6 +221,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -225,6 +249,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -250,6 +277,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -280,6 +310,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -305,6 +338,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -330,6 +366,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -355,6 +394,9 @@ export const demoDb: DatabaseModel = {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TimerType,
|
TimerType,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||||
title: '',
|
title: '',
|
||||||
note: '',
|
note: '',
|
||||||
endAction: EndAction.None,
|
endAction: EndAction.None,
|
||||||
@@ -24,6 +24,9 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
custom: {},
|
custom: {},
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { loadRoll } from '../rollUtils.js';
|
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()', () => {
|
describe('loadRoll()', () => {
|
||||||
const eventlist = [
|
const eventlist = [
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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,20 +1,8 @@
|
|||||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||||
import { apply } from '../delayUtils.js';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
import { apply } from '../delayUtils.js';
|
||||||
* Small utility to fill in the necessary data for the test
|
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||||
*/
|
|
||||||
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()', () => {
|
describe('apply()', () => {
|
||||||
it('applies a positive delay to the rundown', () => {
|
it('applies a positive delay to the rundown', () => {
|
||||||
@@ -150,11 +138,11 @@ describe('apply()', () => {
|
|||||||
makeOntimeDelay(100),
|
makeOntimeDelay(100),
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||||
// gap 50
|
// gap 50
|
||||||
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 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 }),
|
||||||
// gap 50
|
// gap 50
|
||||||
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
|
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
|
||||||
// gap 50
|
|
||||||
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
|
|
||||||
// linked
|
// linked
|
||||||
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
||||||
];
|
];
|
||||||
@@ -165,7 +153,7 @@ describe('apply()', () => {
|
|||||||
// gap 50 (100 - 50)
|
// gap 50 (100 - 50)
|
||||||
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
||||||
// gap 50 (50 - 50)
|
// gap 50 (50 - 50)
|
||||||
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
|
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
|
||||||
// gap (delay is 0)
|
// gap (delay is 0)
|
||||||
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
||||||
// linked
|
// linked
|
||||||
@@ -178,6 +166,8 @@ describe('apply()', () => {
|
|||||||
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
||||||
makeOntimeEvent({
|
makeOntimeEvent({
|
||||||
id: '1',
|
id: '1',
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
timeStart: 46800000, // 13:00:00
|
timeStart: 46800000, // 13:00:00
|
||||||
timeEnd: 50400000, // 14:00:00
|
timeEnd: 50400000, // 14:00:00
|
||||||
duration: MILLIS_PER_HOUR,
|
duration: MILLIS_PER_HOUR,
|
||||||
@@ -185,6 +175,8 @@ describe('apply()', () => {
|
|||||||
// gap 1h
|
// gap 1h
|
||||||
makeOntimeEvent({
|
makeOntimeEvent({
|
||||||
id: '2',
|
id: '2',
|
||||||
|
gap: 1 * MILLIS_PER_HOUR,
|
||||||
|
dayOffset: 0,
|
||||||
timeStart: 54000000, // 15:00:00
|
timeStart: 54000000, // 15:00:00
|
||||||
timeEnd: 57600000, // 16:00:00
|
timeEnd: 57600000, // 16:00:00
|
||||||
duration: MILLIS_PER_HOUR,
|
duration: MILLIS_PER_HOUR,
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ describe('add() mutation', () => {
|
|||||||
test('adds an event to the rundown', () => {
|
test('adds an event to the rundown', () => {
|
||||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||||
const testRundown: OntimeRundown = [];
|
const testRundown: OntimeRundown = [];
|
||||||
const { newRundown } = add({ atIndex: 0, event: mockEvent, persistedRundown: testRundown });
|
const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown });
|
||||||
expect(newRundown.length).toBe(1);
|
expect(newRundown.length).toBe(1);
|
||||||
expect(newRundown[0]).toMatchObject(mockEvent);
|
expect(newRundown[0]).toMatchObject(mockEvent);
|
||||||
});
|
});
|
||||||
@@ -448,7 +448,7 @@ describe('remove() mutation', () => {
|
|||||||
test('deletes an event from the rundown', () => {
|
test('deletes an event from the rundown', () => {
|
||||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||||
const testRundown: OntimeRundown = [mockEvent];
|
const testRundown: OntimeRundown = [mockEvent];
|
||||||
const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown });
|
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown });
|
||||||
expect(newRundown.length).toBe(0);
|
expect(newRundown.length).toBe(0);
|
||||||
});
|
});
|
||||||
test('deletes multiple events from the rundown', () => {
|
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: '5' } as OntimeEvent,
|
||||||
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
|
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
|
||||||
];
|
];
|
||||||
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown });
|
const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown });
|
||||||
expect(newRundown.length).toBe(3);
|
expect(newRundown.length).toBe(3);
|
||||||
expect(newRundown.at(0)?.id).toBe('4');
|
expect(newRundown.at(0)?.id).toBe('4');
|
||||||
});
|
});
|
||||||
@@ -474,7 +474,7 @@ describe('edit() mutation', () => {
|
|||||||
const { newRundown, newEvent } = edit({
|
const { newRundown, newEvent } = edit({
|
||||||
eventId: mockEvent.id,
|
eventId: mockEvent.id,
|
||||||
patch: mockEventPatch,
|
patch: mockEventPatch,
|
||||||
persistedRundown: testRundown,
|
rundown: testRundown,
|
||||||
});
|
});
|
||||||
expect(newRundown.length).toBe(1);
|
expect(newRundown.length).toBe(1);
|
||||||
expect(newEvent).toMatchObject({
|
expect(newEvent).toMatchObject({
|
||||||
@@ -487,7 +487,7 @@ describe('edit() mutation', () => {
|
|||||||
|
|
||||||
describe('batchEdit() mutation', () => {
|
describe('batchEdit() mutation', () => {
|
||||||
it('should correctly apply the patch to the events with the given IDs', () => {
|
it('should correctly apply the patch to the events with the given IDs', () => {
|
||||||
const persistedRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
|
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
|
||||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
|
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
|
||||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
|
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
|
||||||
@@ -495,7 +495,7 @@ describe('batchEdit() mutation', () => {
|
|||||||
const eventIds = ['1', '3'];
|
const eventIds = ['1', '3'];
|
||||||
const patch = { cue: 'newData' };
|
const patch = { cue: 'newData' };
|
||||||
|
|
||||||
const { newRundown } = batchEdit({ persistedRundown, eventIds, patch });
|
const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch });
|
||||||
|
|
||||||
expect(newRundown).toMatchObject([
|
expect(newRundown).toMatchObject([
|
||||||
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
|
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
|
||||||
@@ -507,16 +507,16 @@ describe('batchEdit() mutation', () => {
|
|||||||
|
|
||||||
describe('reorder() mutation', () => {
|
describe('reorder() mutation', () => {
|
||||||
it('should correctly reorder two events', () => {
|
it('should correctly reorder two events', () => {
|
||||||
const persistedRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
|
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
|
||||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', 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,
|
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
|
||||||
];
|
];
|
||||||
const { newRundown } = reorder({
|
const { newRundown } = reorder({
|
||||||
persistedRundown,
|
rundown: testRundown,
|
||||||
eventId: persistedRundown[0].id,
|
eventId: testRundown[0].id,
|
||||||
from: 0,
|
from: 0,
|
||||||
to: persistedRundown.length - 1,
|
to: testRundown.length - 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(newRundown).toMatchObject([
|
expect(newRundown).toMatchObject([
|
||||||
@@ -529,15 +529,15 @@ describe('reorder() mutation', () => {
|
|||||||
|
|
||||||
describe('swap() mutation', () => {
|
describe('swap() mutation', () => {
|
||||||
it('should correctly swap data between events', () => {
|
it('should correctly swap data between events', () => {
|
||||||
const persistedRundown: OntimeRundown = [
|
const testRundown: OntimeRundown = [
|
||||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent,
|
{ 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: '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,
|
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
|
||||||
];
|
];
|
||||||
const { newRundown } = swap({
|
const { newRundown } = swap({
|
||||||
persistedRundown,
|
rundown: testRundown,
|
||||||
fromId: persistedRundown[0].id,
|
fromId: testRundown[0].id,
|
||||||
toId: persistedRundown[1].id,
|
toId: testRundown[1].id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((newRundown[0] as OntimeEvent).id).toBe('1');
|
expect((newRundown[0] as OntimeEvent).id).toBe('1');
|
||||||
@@ -576,6 +576,9 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
@@ -603,6 +606,9 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -630,6 +636,9 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -657,6 +666,9 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
@@ -697,6 +709,8 @@ describe('getDelayAt()', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
custom: {},
|
custom: {},
|
||||||
},
|
},
|
||||||
@@ -721,6 +735,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -749,6 +765,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -777,6 +795,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
@@ -831,6 +851,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
@@ -859,6 +881,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -887,6 +911,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -915,6 +941,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
@@ -935,7 +963,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
|
|
||||||
describe('custom fields', () => {
|
describe('custom fields', () => {
|
||||||
describe('createCustomField()', () => {
|
describe('createCustomField()', () => {
|
||||||
it('creates a field from given parameters', async () => {
|
it('creates a field from given parameters', () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
Lighting: {
|
Lighting: {
|
||||||
label: 'Lighting',
|
label: 'Lighting',
|
||||||
@@ -944,14 +972,14 @@ describe('custom fields', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const customField = await createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
|
const customField = createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
|
||||||
expect(customField).toStrictEqual(expected);
|
expect(customField).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('editCustomField()', () => {
|
describe('editCustomField()', () => {
|
||||||
it('edits a field with a given label', async () => {
|
it('edits a field with a given label', () => {
|
||||||
await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
|
createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
Lighting: {
|
Lighting: {
|
||||||
@@ -966,14 +994,14 @@ describe('custom fields', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const customField = await editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
|
||||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||||
|
|
||||||
expect(customField).toStrictEqual(expected);
|
expect(customField).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renames a field to a new label', async () => {
|
it('renames a field to a new label', () => {
|
||||||
const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' });
|
const created = createCustomField({ label: 'Video', type: 'string', colour: 'red' });
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
Lighting: {
|
Lighting: {
|
||||||
@@ -1015,10 +1043,10 @@ describe('custom fields', () => {
|
|||||||
|
|
||||||
// We need to flush all scheduled tasks for the generate function to settle
|
// We need to flush all scheduled tasks for the generate function to settle
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const customField = await editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
|
||||||
expect(customField).toStrictEqual(expectedAfter);
|
expect(customField).toStrictEqual(expectedAfter);
|
||||||
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
|
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
|
||||||
await editCustomField('AV', { label: 'Video' });
|
editCustomField('AV', { label: 'Video' });
|
||||||
vi.runAllTimers();
|
vi.runAllTimers();
|
||||||
expect(customFieldChangelog).toStrictEqual(new Map());
|
expect(customFieldChangelog).toStrictEqual(new Map());
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -1026,7 +1054,7 @@ describe('custom fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('removeCustomField()', () => {
|
describe('removeCustomField()', () => {
|
||||||
it('deletes a field with a given label', async () => {
|
it('deletes a field with a given label', () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
Lighting: {
|
Lighting: {
|
||||||
label: 'Lighting',
|
label: 'Lighting',
|
||||||
@@ -1040,7 +1068,7 @@ describe('custom fields', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const customField = await removeCustomField('Sound');
|
const customField = removeCustomField('Sound');
|
||||||
|
|
||||||
expect(customField).toStrictEqual(expected);
|
expect(customField).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
addToCustomAssignment,
|
addToCustomAssignment,
|
||||||
|
calculateDayOffset,
|
||||||
getLink,
|
getLink,
|
||||||
handleCustomField,
|
handleCustomField,
|
||||||
handleLink,
|
handleLink,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
isDataStale,
|
isDataStale,
|
||||||
} from '../rundownCacheUtils.js';
|
} from '../rundownCacheUtils.js';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
describe('getLink()', () => {
|
describe('getLink()', () => {
|
||||||
it('should return null if there is no link', () => {
|
it('should return null if there is no link', () => {
|
||||||
@@ -247,3 +249,61 @@ describe('hasChanges()', () => {
|
|||||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
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 { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
|
import { deleteAtIndex } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates all delays in a given rundown
|
* Calculates all delays in a given rundown
|
||||||
@@ -133,21 +133,14 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
|||||||
|
|
||||||
// if the event is not linked, we try and maintain gaps
|
// if the event is not linked, we try and maintain gaps
|
||||||
if (lastEntry !== null) {
|
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
|
// when applying negative delays, we need to unlink the event
|
||||||
// if the previous event was fully consumed by the delay
|
// if the previous event was fully consumed by the delay
|
||||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
||||||
shouldUnlink = true;
|
shouldUnlink = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeFromPrevious > 0) {
|
if (currentEntry.gap > 0) {
|
||||||
delayValue = Math.max(delayValue - timeFromPrevious, 0);
|
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delayValue === 0) {
|
if (delayValue === 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CustomField,
|
CustomField,
|
||||||
CustomFieldLabel,
|
CustomFieldLabel,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
|
isOntimeBlock,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { createPatch } from '../../utils/parser.js';
|
import { createPatch } from '../../utils/parser.js';
|
||||||
import { apply } from './delayUtils.js';
|
import { apply } from './delayUtils.js';
|
||||||
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
|
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
|
||||||
|
|
||||||
type EventID = string;
|
type EventID = string;
|
||||||
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
||||||
@@ -31,16 +32,30 @@ type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
|||||||
let persistedRundown: OntimeRundown = [];
|
let persistedRundown: OntimeRundown = [];
|
||||||
let persistedCustomFields: CustomFields = {};
|
let persistedCustomFields: CustomFields = {};
|
||||||
|
|
||||||
/** Utility function gets to expose data */
|
/**
|
||||||
|
* Get the cached rundown without triggering regeneration
|
||||||
|
*/
|
||||||
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
|
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
|
||||||
export const getCustomFields = (): CustomFields => persistedCustomFields;
|
export const getCustomFields = (): CustomFields => persistedCustomFields;
|
||||||
|
|
||||||
let rundown: NormalisedRundown = {};
|
let normalisedRundown: NormalisedRundown = {};
|
||||||
let order: EventID[] = [];
|
let order: EventID[] = [];
|
||||||
let revision = 0;
|
let revision = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* all mutating functions will set this value if there is a need for re-generation
|
||||||
|
* but will only be cleared by the generate function
|
||||||
|
*/
|
||||||
let isStale = true;
|
let isStale = true;
|
||||||
|
|
||||||
|
/** Allows safely setting the stale state without accidentally clearing it */
|
||||||
|
function setIsStale() {
|
||||||
|
isStale = true;
|
||||||
|
}
|
||||||
|
|
||||||
let totalDelay = 0;
|
let totalDelay = 0;
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
|
let totalDays = 0;
|
||||||
let firstStart: MaybeNumber = null;
|
let firstStart: MaybeNumber = null;
|
||||||
let lastEnd: MaybeNumber = null;
|
let lastEnd: MaybeNumber = null;
|
||||||
|
|
||||||
@@ -72,39 +87,49 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility initialises cache
|
* Utility generate cache
|
||||||
* @param rundown
|
* @private should not be called outside of `rundownCache.ts`
|
||||||
*/
|
*/
|
||||||
export function generate(
|
export function generate(
|
||||||
initialRundown: OntimeRundown = persistedRundown,
|
initialRundown: OntimeRundown = persistedRundown,
|
||||||
customFields: CustomFields = persistedCustomFields,
|
customFields: CustomFields = persistedCustomFields,
|
||||||
) {
|
) {
|
||||||
|
function clearIsStale() {
|
||||||
|
isStale = false;
|
||||||
|
}
|
||||||
|
|
||||||
// we decided to re-write this dataset for every change
|
// we decided to re-write this dataset for every change
|
||||||
// instead of maintaining logic to update it
|
// instead of maintaining logic to update it
|
||||||
|
|
||||||
assignedCustomFields = {};
|
assignedCustomFields = {};
|
||||||
rundown = {};
|
normalisedRundown = {};
|
||||||
order = [];
|
order = [];
|
||||||
links = {};
|
links = {};
|
||||||
firstStart = null;
|
firstStart = null;
|
||||||
lastEnd = null;
|
lastEnd = null;
|
||||||
totalDuration = 0;
|
totalDuration = 0;
|
||||||
|
totalDays = 0;
|
||||||
totalDelay = 0;
|
totalDelay = 0;
|
||||||
|
|
||||||
let lastEntry: PlayableEvent | null = null;
|
let lastEntry: PlayableEvent | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < initialRundown.length; i++) {
|
for (let i = 0; i < initialRundown.length; i++) {
|
||||||
// TODO: filter properties that should not be persisted (eg: delay)
|
|
||||||
// we assign a reference to the current entry, this will be mutated in place
|
// we assign a reference to the current entry, this will be mutated in place
|
||||||
const currentEntry = initialRundown[i];
|
const currentEntry = initialRundown[i];
|
||||||
|
|
||||||
if (isOntimeEvent(currentEntry)) {
|
if (isOntimeEvent(currentEntry)) {
|
||||||
|
currentEntry.delay = 0;
|
||||||
|
currentEntry.gap = 0;
|
||||||
|
|
||||||
// 1. handle links - mutates updatedEvent
|
// 1. handle links - mutates updatedEvent
|
||||||
handleLink(i, initialRundown, currentEntry, links);
|
handleLink(i, initialRundown, currentEntry, links);
|
||||||
|
|
||||||
// 2. handle custom fields - mutates updatedEvent
|
// 2. handle custom fields - mutates updatedEvent
|
||||||
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
||||||
|
|
||||||
|
totalDays += calculateDayOffset(currentEntry, lastEntry);
|
||||||
|
currentEntry.dayOffset = totalDays;
|
||||||
|
|
||||||
// update rundown metadata, it only concerns playable events
|
// update rundown metadata, it only concerns playable events
|
||||||
if (isPlayableEvent(currentEntry)) {
|
if (isPlayableEvent(currentEntry)) {
|
||||||
// fist start is always the first event
|
// fist start is always the first event
|
||||||
@@ -112,57 +137,58 @@ export function generate(
|
|||||||
firstStart = currentEntry.timeStart;
|
firstStart = currentEntry.timeStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry?.timeStart,
|
|
||||||
lastEntry?.timeEnd,
|
|
||||||
lastEntry?.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
if (currentEntry.gap === 0) {
|
||||||
// event starts on previous finish, we add its duration
|
// event starts on previous finish, we add its duration
|
||||||
totalDuration += currentEntry.duration;
|
totalDuration += currentEntry.duration;
|
||||||
} else if (timeFromPrevious > 0) {
|
} else if (currentEntry.gap > 0) {
|
||||||
// event has a gap, we add the gap and the duration
|
// event has a gap, we add the gap and the duration
|
||||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
totalDuration += currentEntry.gap + currentEntry.duration;
|
||||||
} else if (timeFromPrevious < 0) {
|
} else if (currentEntry.gap < 0) {
|
||||||
// there is an overlap, we remove the overlap from the duration
|
// there is an overlap, we remove the overlap from the duration
|
||||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||||
// NOTE: we add the gap since it is a negative number
|
// NOTE: we add the gap since it is a negative number
|
||||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove eventual gaps from the accumulated delay
|
// remove eventual gaps from the accumulated delay
|
||||||
// we only affect positive delays (time forwards)
|
// we only affect positive delays (time forwards)
|
||||||
if (totalDelay > 0 && timeFromPrevious > 0) {
|
if (totalDelay > 0 && currentEntry.gap > 0) {
|
||||||
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
|
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
|
||||||
}
|
}
|
||||||
// current event delay is the current accumulated delay
|
// current event delay is the current accumulated delay
|
||||||
currentEntry.delay = totalDelay;
|
currentEntry.delay = totalDelay;
|
||||||
|
|
||||||
// lastEntry is the event with the latest end time
|
// lastEntry is the event with the latest end time
|
||||||
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
|
if (isNewLatest(currentEntry, lastEntry)) {
|
||||||
lastEntry = currentEntry;
|
lastEntry = currentEntry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (isOntimeDelay(currentEntry)) {
|
||||||
|
// calculate delays
|
||||||
// calculate delays
|
// !!! this must happen after handling the links
|
||||||
// !!! this must happen after handling the links
|
|
||||||
if (isOntimeDelay(currentEntry)) {
|
|
||||||
totalDelay += currentEntry.duration;
|
totalDelay += currentEntry.duration;
|
||||||
|
} else if (isOntimeBlock(currentEntry)) {
|
||||||
|
// calculate block - nothing yet
|
||||||
|
} else {
|
||||||
|
// unknown - type skip it
|
||||||
|
// this is needed to get the type guard working when we assign the entry to the rundown
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add id to order
|
// add id to order
|
||||||
order.push(currentEntry.id);
|
order.push(currentEntry.id);
|
||||||
// add entry to rundown
|
// add entry to rundown
|
||||||
rundown[currentEntry.id] = currentEntry;
|
normalisedRundown[currentEntry.id] = currentEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastEnd = lastEntry?.timeEnd ?? null;
|
lastEnd = lastEntry?.timeEnd ?? null;
|
||||||
isStale = false;
|
clearIsStale();
|
||||||
customFieldChangelog.clear();
|
customFieldChangelog.clear();
|
||||||
return { rundown, order, links, totalDelay, totalDuration, assignedCustomFields };
|
|
||||||
|
//The return value is used for testing
|
||||||
|
return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns an ID guaranteed to be unique */
|
/** Returns an ID guaranteed to be unique */
|
||||||
@@ -173,7 +199,7 @@ export function getUniqueId(): string {
|
|||||||
let id = '';
|
let id = '';
|
||||||
do {
|
do {
|
||||||
id = generateId();
|
id = generateId();
|
||||||
} while (Object.hasOwn(rundown, id));
|
} while (Object.hasOwn(normalisedRundown, id));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,15 +220,15 @@ type RundownCache = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns cached data
|
* Returns the full rundown cache.
|
||||||
* @returns {RundownCache}
|
* Will triggering regeneration if data is stale.
|
||||||
*/
|
*/
|
||||||
export function get(): Readonly<RundownCache> {
|
export function get(): Readonly<RundownCache> {
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
generate();
|
generate();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
rundown,
|
rundown: normalisedRundown,
|
||||||
order,
|
order,
|
||||||
revision,
|
revision,
|
||||||
totalDelay,
|
totalDelay,
|
||||||
@@ -212,6 +238,7 @@ export function get(): Readonly<RundownCache> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns calculated metadata from rundown
|
* Returns calculated metadata from rundown
|
||||||
|
* Will triggering regeneration if data is stale.
|
||||||
*/
|
*/
|
||||||
export function getMetadata() {
|
export function getMetadata() {
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
@@ -227,7 +254,7 @@ export function getMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommonParams = { persistedRundown: OntimeRundown };
|
type CommonParams = { rundown: OntimeRundown };
|
||||||
type MutationParams<T> = T & CommonParams;
|
type MutationParams<T> = T & CommonParams;
|
||||||
type MutatingReturn = {
|
type MutatingReturn = {
|
||||||
newRundown: OntimeRundown;
|
newRundown: OntimeRundown;
|
||||||
@@ -238,23 +265,14 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorators injects data into mutation
|
* Decorators injects data into mutation
|
||||||
* @param mutation
|
* ensures order of operations when performing mutations
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
||||||
async function scopedMutation(params: T) {
|
function scopedMutation(params: T) {
|
||||||
/**
|
const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown });
|
||||||
* Marking the data set as stale
|
|
||||||
* doing it before calling the mutation, gives the function a chance
|
|
||||||
* to prevent recalculation by setting stale = false
|
|
||||||
*/
|
|
||||||
isStale = true;
|
|
||||||
|
|
||||||
const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
|
|
||||||
|
|
||||||
// early return without calling side effects
|
// early return without calling side effects
|
||||||
if (!didMutate) {
|
if (!didMutate) {
|
||||||
isStale = false;
|
|
||||||
return { newEvent, newRundown, didMutate };
|
return { newEvent, newRundown, didMutate };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,31 +296,34 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
|
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
|
||||||
|
/**
|
||||||
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
|
* Add entry to rundown
|
||||||
|
*/
|
||||||
|
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
|
||||||
const newEvent: OntimeRundownEntry = { ...event };
|
const newEvent: OntimeRundownEntry = { ...event };
|
||||||
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
|
const newRundown = insertAtIndex(atIndex, newEvent, rundown);
|
||||||
|
setIsStale();
|
||||||
return { newRundown, newEvent, didMutate: true };
|
return { newRundown, newEvent, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
|
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
|
||||||
|
/**
|
||||||
export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn {
|
* Remove entry to rundown
|
||||||
const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id));
|
*/
|
||||||
|
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
|
||||||
return { newRundown, didMutate: persistedRundown.length !== newRundown.length };
|
const newRundown = rundown.filter((event) => !eventIds.includes(event.id));
|
||||||
|
const didMutate = rundown.length !== newRundown.length;
|
||||||
|
if (didMutate) setIsStale();
|
||||||
|
return { newRundown, didMutate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeAll(): MutatingReturn {
|
export function removeAll(): MutatingReturn {
|
||||||
|
setIsStale();
|
||||||
return { newRundown: [], didMutate: true };
|
return { newRundown: [], didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function for patching events
|
* Utility function for patching an existing event with new data
|
||||||
* @param eventFromRundown
|
|
||||||
* @param patch
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
|
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
|
||||||
if (isOntimeEvent(eventFromRundown)) {
|
if (isOntimeEvent(eventFromRundown)) {
|
||||||
@@ -315,120 +336,133 @@ function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
|
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
|
||||||
|
/**
|
||||||
export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
|
* Apply patch to an entry with given id
|
||||||
const indexAt = persistedRundown.findIndex((event) => event.id === eventId);
|
*/
|
||||||
|
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
|
||||||
|
const indexAt = rundown.findIndex((event) => event.id === eventId);
|
||||||
if (indexAt < 0) {
|
if (indexAt < 0) {
|
||||||
throw new Error('Event not found');
|
throw new Error('Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch?.type && persistedRundown[indexAt].type !== patch.type) {
|
if (patch?.type && rundown[indexAt].type !== patch.type) {
|
||||||
throw new Error('Invalid event type');
|
throw new Error('Invalid event type');
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventInMemory = persistedRundown[indexAt];
|
const eventInMemory = rundown[indexAt];
|
||||||
|
|
||||||
if (!hasChanges(eventInMemory, patch)) {
|
if (!hasChanges(eventInMemory, patch)) {
|
||||||
isStale = false;
|
return { newRundown: rundown, newEvent: eventInMemory, didMutate: false };
|
||||||
return { newRundown: persistedRundown, newEvent: eventInMemory, didMutate: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEvent = makeEvent(eventInMemory, patch);
|
const newEvent = makeEvent(eventInMemory, patch);
|
||||||
|
|
||||||
const newRundown = [...persistedRundown];
|
const newRundown = [...rundown];
|
||||||
newRundown[indexAt] = newEvent;
|
newRundown[indexAt] = newEvent;
|
||||||
|
|
||||||
// check whether the data warrants recalculation of cache
|
// check whether the data warrants recalculation of cache
|
||||||
const makeStale = isDataStale(patch);
|
const makeStale = isDataStale(patch);
|
||||||
|
|
||||||
if (!makeStale) {
|
if (makeStale) {
|
||||||
rundown[newEvent.id] = newEvent;
|
setIsStale();
|
||||||
|
} else {
|
||||||
|
normalisedRundown[newEvent.id] = newEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
isStale = makeStale;
|
|
||||||
return { newRundown, newEvent, didMutate: true };
|
return { newRundown, newEvent, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
|
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
|
||||||
|
/**
|
||||||
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
|
* Apply patch to multiple entries
|
||||||
|
*/
|
||||||
|
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
|
||||||
const ids = new Set(eventIds);
|
const ids = new Set(eventIds);
|
||||||
|
|
||||||
const newRundown = [];
|
const newRundown = [];
|
||||||
for (let i = 0; i < persistedRundown.length; i++) {
|
for (let i = 0; i < rundown.length; i++) {
|
||||||
if (ids.has(persistedRundown[i].id)) {
|
if (ids.has(rundown[i].id)) {
|
||||||
if (patch?.type && persistedRundown[i].type !== patch.type) {
|
if (patch?.type && rundown[i].type !== patch.type) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newEvent = makeEvent(persistedRundown[i], patch);
|
const newEvent = makeEvent(rundown[i], patch);
|
||||||
newRundown.push(newEvent);
|
newRundown.push(newEvent);
|
||||||
} else {
|
} else {
|
||||||
newRundown.push(persistedRundown[i]);
|
newRundown.push(rundown[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsStale();
|
||||||
return { newRundown, didMutate: true };
|
return { newRundown, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
|
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
|
||||||
|
/**
|
||||||
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
|
* Redorder two entries
|
||||||
const event = persistedRundown[from];
|
*/
|
||||||
|
export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
|
||||||
|
const event = rundown[from];
|
||||||
if (!event || eventId !== event.id) {
|
if (!event || eventId !== event.id) {
|
||||||
throw new Error('Event not found');
|
throw new Error('Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRundown = reorderArray(persistedRundown, from, to);
|
const newRundown = reorderArray(rundown, from, to);
|
||||||
for (let i = from; i <= to; i++) {
|
for (let i = from; i <= to; i++) {
|
||||||
const event = newRundown.at(i);
|
const event = newRundown.at(i);
|
||||||
if (isOntimeEvent(event)) {
|
if (isOntimeEvent(event)) {
|
||||||
event.revision += 1;
|
event.revision += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsStale();
|
||||||
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
|
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
|
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
|
||||||
|
/**
|
||||||
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
|
* Apply a delay
|
||||||
const newRundown = apply(eventId, persistedRundown);
|
*/
|
||||||
|
export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn {
|
||||||
|
const newRundown = apply(eventId, rundown);
|
||||||
|
setIsStale();
|
||||||
return { newRundown, didMutate: true };
|
return { newRundown, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
|
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
|
||||||
|
/**
|
||||||
|
* Swap two entries
|
||||||
|
*/
|
||||||
|
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
|
||||||
|
const indexA = rundown.findIndex((event) => event.id === fromId);
|
||||||
|
const eventA = rundown.at(indexA);
|
||||||
|
|
||||||
export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn {
|
const indexB = rundown.findIndex((event) => event.id === toId);
|
||||||
const indexA = persistedRundown.findIndex((event) => event.id === fromId);
|
const eventB = rundown.at(indexB);
|
||||||
const eventA = persistedRundown.at(indexA);
|
|
||||||
|
|
||||||
const indexB = persistedRundown.findIndex((event) => event.id === toId);
|
|
||||||
const eventB = persistedRundown.at(indexB);
|
|
||||||
|
|
||||||
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
||||||
throw new Error('Swap only available for OntimeEvents');
|
throw new Error('Swap only available for OntimeEvents');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newA, newB } = swapEventData(eventA, eventB);
|
const { newA, newB } = swapEventData(eventA, eventB);
|
||||||
const newRundown = [...persistedRundown];
|
const newRundown = [...rundown];
|
||||||
|
|
||||||
newRundown[indexA] = newA;
|
newRundown[indexA] = newA;
|
||||||
(newRundown[indexA] as OntimeEvent).revision += 1;
|
(newRundown[indexA] as OntimeEvent).revision += 1;
|
||||||
newRundown[indexB] = newB;
|
newRundown[indexB] = newB;
|
||||||
(newRundown[indexB] as OntimeEvent).revision += 1;
|
(newRundown[indexB] as OntimeEvent).revision += 1;
|
||||||
|
|
||||||
|
setIsStale();
|
||||||
return { newRundown, didMutate: true };
|
return { newRundown, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidates service cache if a custom field is used
|
* Utility for invalidating service cache if a custom field is used
|
||||||
* @param label
|
|
||||||
*/
|
*/
|
||||||
function invalidateIfUsed(label: CustomFieldLabel) {
|
function invalidateIfUsed(label: CustomFieldLabel) {
|
||||||
if (label in assignedCustomFields) {
|
if (label in assignedCustomFields) {
|
||||||
isStale = true;
|
setIsStale();
|
||||||
}
|
}
|
||||||
// if the field was in use, we mark the cache as stale
|
// if the field was in use, we mark the cache as stale
|
||||||
if (label in assignedCustomFields) {
|
if (label in assignedCustomFields) {
|
||||||
isStale = true;
|
setIsStale();
|
||||||
}
|
}
|
||||||
// ... and schedule a cache update
|
// ... and schedule a cache update
|
||||||
// schedule a non priority cache update
|
// schedule a non priority cache update
|
||||||
@@ -439,10 +473,9 @@ function invalidateIfUsed(label: CustomFieldLabel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedules a non priority custom field persist
|
* Utility for scheduling a non priority custom field persist
|
||||||
* @param persistedCustomFields
|
|
||||||
*/
|
*/
|
||||||
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
|
function scheduleCustomFieldPersist() {
|
||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
await getDataProvider().setCustomFields(persistedCustomFields);
|
await getDataProvider().setCustomFields(persistedCustomFields);
|
||||||
});
|
});
|
||||||
@@ -450,12 +483,15 @@ function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitises and creates a custom field in the database
|
* Sanitises and creates a custom field in the database
|
||||||
* @param field
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export const createCustomField = async (field: CustomField) => {
|
export function createCustomField(field: CustomField): CustomFields {
|
||||||
const { label, type, colour } = field;
|
const { label, type, colour } = field;
|
||||||
const key = customFieldLabelToKey(label);
|
const key = customFieldLabelToKey(label);
|
||||||
|
|
||||||
|
if (key === null) {
|
||||||
|
throw new Error('Unable to convert label to a valid key');
|
||||||
|
}
|
||||||
|
|
||||||
// check if label already exists
|
// check if label already exists
|
||||||
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
|
const alreadyExists = Object.hasOwn(persistedCustomFields, key);
|
||||||
|
|
||||||
@@ -466,18 +502,15 @@ export const createCustomField = async (field: CustomField) => {
|
|||||||
// update object and persist
|
// update object and persist
|
||||||
persistedCustomFields[key] = { label, type, colour };
|
persistedCustomFields[key] = { label, type, colour };
|
||||||
|
|
||||||
scheduleCustomFieldPersist(persistedCustomFields);
|
scheduleCustomFieldPersist();
|
||||||
|
|
||||||
return persistedCustomFields;
|
return persistedCustomFields;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edits an existing custom field in the database
|
* Edits an existing custom field in the database
|
||||||
* @param key
|
|
||||||
* @param newField
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export const editCustomField = async (key: string, newField: Partial<CustomField>) => {
|
export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
|
||||||
if (!(key in persistedCustomFields)) {
|
if (!(key in persistedCustomFields)) {
|
||||||
throw new Error('Could not find label');
|
throw new Error('Could not find label');
|
||||||
}
|
}
|
||||||
@@ -487,7 +520,14 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
|
|||||||
throw new Error('Change of field type is not allowed');
|
throw new Error('Change of field type is not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newField.label === undefined) {
|
||||||
|
throw new Error('Missing label');
|
||||||
|
}
|
||||||
|
|
||||||
const newKey = customFieldLabelToKey(newField.label);
|
const newKey = customFieldLabelToKey(newField.label);
|
||||||
|
if (newKey === null) {
|
||||||
|
throw new Error('Unable to convert label to a valid key');
|
||||||
|
}
|
||||||
persistedCustomFields[newKey] = { ...existingField, ...newField };
|
persistedCustomFields[newKey] = { ...existingField, ...newField };
|
||||||
|
|
||||||
if (key !== newKey) {
|
if (key !== newKey) {
|
||||||
@@ -495,23 +535,22 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
|
|||||||
customFieldChangelog.set(key, newKey);
|
customFieldChangelog.set(key, newKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleCustomFieldPersist(persistedCustomFields);
|
scheduleCustomFieldPersist();
|
||||||
invalidateIfUsed(key);
|
invalidateIfUsed(key);
|
||||||
|
|
||||||
return persistedCustomFields;
|
return persistedCustomFields;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a custom field from the database
|
* Deletes a custom field from the database
|
||||||
* @param label
|
|
||||||
*/
|
*/
|
||||||
export const removeCustomField = async (label: string) => {
|
export function removeCustomField(label: string): CustomFields {
|
||||||
if (label in persistedCustomFields) {
|
if (label in persistedCustomFields) {
|
||||||
delete persistedCustomFields[label];
|
delete persistedCustomFields[label];
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleCustomFieldPersist(persistedCustomFields);
|
scheduleCustomFieldPersist();
|
||||||
invalidateIfUsed(label);
|
invalidateIfUsed(label);
|
||||||
|
|
||||||
return persistedCustomFields;
|
return persistedCustomFields;
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
OntimeRundownEntry,
|
OntimeRundownEntry,
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { getLinkedTimes } from 'ontime-utils';
|
import { dayInMs, getLinkedTimes } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get linked event
|
* Get linked event
|
||||||
@@ -86,7 +86,7 @@ export function handleCustomField(
|
|||||||
// rename the property if it is in the changelog
|
// rename the property if it is in the changelog
|
||||||
if (customFieldChangelog.has(field)) {
|
if (customFieldChangelog.has(field)) {
|
||||||
const oldData = mutableEvent.custom[field];
|
const oldData = mutableEvent.custom[field];
|
||||||
const newLabel = customFieldChangelog.get(field);
|
const newLabel = customFieldChangelog.get(field) as string; // it os OK to cast to string here since we already checked that it existed
|
||||||
|
|
||||||
mutableEvent.custom[newLabel] = oldData;
|
mutableEvent.custom[newLabel] = oldData;
|
||||||
delete mutableEvent.custom[field];
|
delete mutableEvent.custom[field];
|
||||||
@@ -112,6 +112,7 @@ export enum regenerateWhitelist {
|
|||||||
'note',
|
'note',
|
||||||
'endAction',
|
'endAction',
|
||||||
'timerType',
|
'timerType',
|
||||||
|
'countToEnd',
|
||||||
'isPublic',
|
'isPublic',
|
||||||
'colour',
|
'colour',
|
||||||
'timeWarning',
|
'timeWarning',
|
||||||
@@ -143,6 +144,39 @@ export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
|
|||||||
*/
|
*/
|
||||||
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
||||||
return Object.keys(newEvent).some(
|
return Object.keys(newEvent).some(
|
||||||
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key] !== newEvent[key],
|
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for calculating if the current events should have a day offset
|
||||||
|
* @param current the current event under test
|
||||||
|
* @param previous the previous event
|
||||||
|
* @returns 0 or 1 for easy accumulation with the total days
|
||||||
|
*/
|
||||||
|
export function calculateDayOffset(
|
||||||
|
current: Pick<OntimeEvent, 'timeStart'>,
|
||||||
|
previous?: Pick<OntimeEvent, 'timeStart' | 'duration'>,
|
||||||
|
) {
|
||||||
|
// if there is no previous there can't be a day offset
|
||||||
|
if (!previous) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the previous events duration is zero it will push the current event to next day
|
||||||
|
if (previous.duration === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the previous event crossed midnight then the current event is in the next day
|
||||||
|
if (previous.timeStart + previous.duration >= dayInMs) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the current events starts at the same time or before the previous event then it is the next day
|
||||||
|
if (current.timeStart <= previous.timeStart) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,10 +74,11 @@ export function revoke(): ReturnType<typeof hasAuth> {
|
|||||||
*/
|
*/
|
||||||
export function handleClientSecret(clientSecret: string): ClientSecret {
|
export function handleClientSecret(clientSecret: string): ClientSecret {
|
||||||
const clientSecretObject = JSON.parse(clientSecret);
|
const clientSecretObject = JSON.parse(clientSecret);
|
||||||
const isValid = validateClientSecret(clientSecretObject);
|
|
||||||
|
|
||||||
if (!isValid) {
|
try {
|
||||||
throw new Error('Client secret invalid');
|
validateClientSecret(clientSecretObject);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Client secret is invalid: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientSecretObject;
|
return clientSecretObject;
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
isPublic: false,
|
isPublic: false,
|
||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
@@ -82,6 +85,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -129,6 +135,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -174,6 +183,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
isPublic: true,
|
isPublic: true,
|
||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
@@ -207,6 +219,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -240,6 +255,9 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { isOntimeBlock, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { sheets_v4 } from '@googleapis/sheets';
|
import { sheets_v4 } from '@googleapis/sheets';
|
||||||
|
import { isObject } from '../../utils/assert.js';
|
||||||
|
|
||||||
// we expect client secret file to contain the following keys
|
// we expect client secret file to contain the following keys
|
||||||
const requiredClientKeys = [
|
const requiredClientKeys = [
|
||||||
@@ -26,10 +27,21 @@ export type ClientSecret = {
|
|||||||
/**
|
/**
|
||||||
* Guard validates a given client secrets file
|
* Guard validates a given client secrets file
|
||||||
* @param clientSecret
|
* @param clientSecret
|
||||||
* @returns
|
* @throws
|
||||||
*/
|
*/
|
||||||
export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret {
|
export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret {
|
||||||
return requiredClientKeys.every((key) => Object.keys(clientSecret['installed']).includes(key));
|
if (!('installed' in clientSecret)) {
|
||||||
|
throw new Error('Missing "installed" object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { installed } = clientSecret;
|
||||||
|
isObject(installed);
|
||||||
|
|
||||||
|
if (requiredClientKeys.every((key) => Object.keys(installed).includes(key))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Missing keys in "installed" object');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,18 +77,18 @@ export function getA1Notation(row: number, column: number): string {
|
|||||||
* @param {OntimeRundownEntry} event
|
* @param {OntimeRundownEntry} event
|
||||||
* @param {number} index - index of the event
|
* @param {number} index - index of the event
|
||||||
* @param {number} worksheetId
|
* @param {number} worksheetId
|
||||||
* @param {any} metadata - object with all the cell positions of the title of each attribute
|
* @param {object} metadata - object with all the cell positions of the title of each attribute
|
||||||
* @returns {sheets_v4.Schema} - list of update requests
|
* @returns {sheets_v4.Schema} - list of update requests
|
||||||
*/
|
*/
|
||||||
export function cellRequestFromEvent(
|
export function cellRequestFromEvent(
|
||||||
event: OntimeRundownEntry,
|
event: OntimeRundownEntry,
|
||||||
index: number,
|
index: number,
|
||||||
worksheetId: number,
|
worksheetId: number,
|
||||||
metadata,
|
metadata: object,
|
||||||
): sheets_v4.Schema$Request {
|
): sheets_v4.Schema$Request {
|
||||||
const rowData = Object.entries(metadata)
|
const rowData = Object.entries(metadata)
|
||||||
.filter(([_, value]) => value !== undefined)
|
.filter(([_, value]) => value !== undefined)
|
||||||
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
|
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [keyof OntimeEvent | 'blank', { col: number; row: number }][];
|
||||||
|
|
||||||
const titleCol = rowData[0][1].col;
|
const titleCol = rowData[0][1].col;
|
||||||
|
|
||||||
@@ -113,7 +125,7 @@ export function cellRequestFromEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCellData(key: string, event: OntimeRundownEntry) {
|
function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeRundownEntry) {
|
||||||
if (isOntimeEvent(event)) {
|
if (isOntimeEvent(event)) {
|
||||||
if (key === 'blank') {
|
if (key === 'blank') {
|
||||||
return {};
|
return {};
|
||||||
@@ -126,14 +138,13 @@ function getCellData(key: string, event: OntimeRundownEntry) {
|
|||||||
return { userEnteredValue: { stringValue: event.custom[customKey] } };
|
return { userEnteredValue: { stringValue: event.custom[customKey] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataType = typeof event[key];
|
if (typeof event[key] === 'number') {
|
||||||
if (dataType === 'number') {
|
|
||||||
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
|
return { userEnteredValue: { stringValue: millisToString(event[key]) } };
|
||||||
}
|
}
|
||||||
if (dataType === 'string') {
|
if (typeof event[key] === 'string') {
|
||||||
return { userEnteredValue: { stringValue: event[key] } };
|
return { userEnteredValue: { stringValue: event[key] } };
|
||||||
}
|
}
|
||||||
if (dataType === 'boolean') {
|
if (typeof event[key] === 'boolean') {
|
||||||
return { userEnteredValue: { boolValue: event[key] } };
|
return { userEnteredValue: { boolValue: event[key] } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { TimerPhase, Playback } from 'ontime-types';
|
||||||
|
import { deepmerge } from 'ontime-utils';
|
||||||
|
import { RuntimeState } from '../runtimeState.js';
|
||||||
|
|
||||||
|
const baseState: RuntimeState = {
|
||||||
|
clock: 0,
|
||||||
|
currentBlock: {
|
||||||
|
block: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
eventNow: null,
|
||||||
|
publicEventNow: null,
|
||||||
|
eventNext: null,
|
||||||
|
publicEventNext: null,
|
||||||
|
runtime: {
|
||||||
|
selectedEventIndex: null,
|
||||||
|
numEvents: 0,
|
||||||
|
offset: 0,
|
||||||
|
plannedStart: 0,
|
||||||
|
plannedEnd: 0,
|
||||||
|
actualStart: null,
|
||||||
|
expectedEnd: null,
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
addedTime: 0,
|
||||||
|
current: null,
|
||||||
|
duration: null,
|
||||||
|
elapsed: null,
|
||||||
|
expectedFinish: null,
|
||||||
|
finishedAt: null,
|
||||||
|
phase: TimerPhase.None,
|
||||||
|
playback: Playback.Stop,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: null,
|
||||||
|
},
|
||||||
|
_timer: {
|
||||||
|
forceFinish: null,
|
||||||
|
totalDelay: 0,
|
||||||
|
pausedAt: null,
|
||||||
|
secondaryTarget: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeRuntimeStateData(patch?: Partial<RuntimeState>) {
|
||||||
|
return deepmerge(baseState, patch);
|
||||||
|
}
|
||||||
@@ -15,19 +15,19 @@ describe('test stringToOSCArgs()', () => {
|
|||||||
|
|
||||||
it('empty is nothing', () => {
|
it('empty is nothing', () => {
|
||||||
const test = undefined;
|
const test = undefined;
|
||||||
const expected = [];
|
const expected: any[] = [];
|
||||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty is nothing', () => {
|
it('empty is nothing', () => {
|
||||||
const test = '';
|
const test = '';
|
||||||
const expected = [];
|
const expected: any[] = [];
|
||||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('1 space is nothing', () => {
|
it('1 space is nothing', () => {
|
||||||
const test = ' ';
|
const test = ' ';
|
||||||
const expected = [];
|
const expected: any[] = [];
|
||||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ describe('test aliases import', () => {
|
|||||||
pathAndParams: 'testpathAndParams',
|
pathAndParams: 'testpathAndParams',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as DatabaseModel;
|
} as unknown as DatabaseModel;
|
||||||
|
|
||||||
const parsed = parseUrlPresets(testData);
|
const parsed = parseUrlPresets(testData);
|
||||||
expect(parsed.length).toBe(1);
|
expect(parsed.length).toBe(1);
|
||||||
@@ -602,7 +602,7 @@ describe('test views import', () => {
|
|||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
},
|
},
|
||||||
} as DatabaseModel;
|
} as unknown as DatabaseModel;
|
||||||
const parsed = parseViewSettings(testData);
|
const parsed = parseViewSettings(testData);
|
||||||
expect(parsed).toStrictEqual(dbModel.viewSettings);
|
expect(parsed).toStrictEqual(dbModel.viewSettings);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
timeEnd,
|
timeEnd,
|
||||||
duration,
|
duration,
|
||||||
timeStrategy,
|
timeStrategy,
|
||||||
linkStart: validateLinkStart(patchEvent.linkStart),
|
linkStart: validateLinkStart(patchEvent.linkStart, originalEvent.linkStart),
|
||||||
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||||
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||||
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
||||||
@@ -384,6 +384,9 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
note: makeString(patchEvent.note, originalEvent.note),
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||||
|
delay: 0, // is always regenerated by the cache
|
||||||
|
dayOffset: 0, // is always regenerated by the cache
|
||||||
|
gap: 0, // is always regenerated by the cache
|
||||||
// short circuit empty string
|
// short circuit empty string
|
||||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||||
revision: originalEvent.revision,
|
revision: originalEvent.revision,
|
||||||
|
|||||||
+419
-295
@@ -1,307 +1,420 @@
|
|||||||
{
|
{
|
||||||
"rundown": [
|
"rundown": [
|
||||||
{
|
{
|
||||||
"title": "Albania",
|
"id": "32d31",
|
||||||
"note": "SF1.01",
|
"type": "event",
|
||||||
"endAction": "none",
|
"title": "Albania",
|
||||||
"timerType": "count-down",
|
"timeStart": 36000000,
|
||||||
"timeStart": 36000000,
|
"timeEnd": 37200000,
|
||||||
"timeEnd": 37200000,
|
"duration": 1200000,
|
||||||
"duration": 1200000,
|
"timeStrategy": "lock-duration",
|
||||||
"isPublic": true,
|
"linkStart": null,
|
||||||
"skip": false,
|
"endAction": "none",
|
||||||
"colour": "",
|
"timerType": "count-down",
|
||||||
"type": "event",
|
"countToEnd": false,
|
||||||
"revision": 0,
|
"isPublic": true,
|
||||||
"id": "32d31",
|
"skip": false,
|
||||||
"cue": "SF1.01",
|
"note": "SF1.01",
|
||||||
"custom": {
|
"colour": "",
|
||||||
"song": "Sekret",
|
"delay": 0,
|
||||||
"artist": "Ronela Hajati"
|
"dayOffset": 0,
|
||||||
}
|
"gap": 0,
|
||||||
},
|
"cue": "SF1.01",
|
||||||
{
|
"revision": 0,
|
||||||
"title": "Latvia",
|
"timeWarning": 120000,
|
||||||
"note": "SF1.02",
|
"timeDanger": 60000,
|
||||||
"endAction": "none",
|
"custom": {
|
||||||
"timerType": "count-down",
|
"song": "Sekret",
|
||||||
"timeStart": 37500000,
|
"artist": "Ronela Hajati"
|
||||||
"timeEnd": 38700000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "21cd2",
|
|
||||||
"cue": "SF1.02",
|
|
||||||
"custom": {
|
|
||||||
"song": "Eat Your Salad",
|
|
||||||
"artist": "Citi Zeni"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Lithuania",
|
|
||||||
"note": "SF1.03",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 39000000,
|
|
||||||
"timeEnd": 40200000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "0b371",
|
|
||||||
"cue": "SF1.03",
|
|
||||||
"custom": {
|
|
||||||
"song": "Sentimentai",
|
|
||||||
"artist": "Monika Liu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Switzerland",
|
|
||||||
"note": "SF1.04",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 40500000,
|
|
||||||
"timeEnd": 41700000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "3cd28",
|
|
||||||
"cue": "SF1.04",
|
|
||||||
"custom": {
|
|
||||||
"song": "Boys Do Cry",
|
|
||||||
"artist": "Marius Bear"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Slovenia",
|
|
||||||
"note": "SF1.05",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 42000000,
|
|
||||||
"timeEnd": 43200000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "e457f",
|
|
||||||
"cue": "SF1.05",
|
|
||||||
"custom": {
|
|
||||||
"song": "Disko",
|
|
||||||
"artist": "LPS"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Lunch break",
|
|
||||||
"type": "block",
|
|
||||||
"id": "01e85"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Ukraine",
|
|
||||||
"note": "SF1.06",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 47100000,
|
|
||||||
"timeEnd": 48300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "1c420",
|
|
||||||
"cue": "SF1.06",
|
|
||||||
"custom": {
|
|
||||||
"song": "Stefania",
|
|
||||||
"artist": "Kalush Orchestra"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Bulgaria",
|
|
||||||
"note": "SF1.07",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 48600000,
|
|
||||||
"timeEnd": 49800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "b7737",
|
|
||||||
"cue": "SF1.07",
|
|
||||||
"custom": {
|
|
||||||
"song": "Intention",
|
|
||||||
"artist": "Intelligent Music Project"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Netherlands",
|
|
||||||
"note": "SF1.08",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 50100000,
|
|
||||||
"timeEnd": 51300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "d3a80",
|
|
||||||
"cue": "SF1.08",
|
|
||||||
"custom": {
|
|
||||||
"song": "De Diepte",
|
|
||||||
"artist": "S10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Moldova",
|
|
||||||
"note": "SF1.09",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 51600000,
|
|
||||||
"timeEnd": 52800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "8276c",
|
|
||||||
"cue": "SF1.09",
|
|
||||||
"custom": {
|
|
||||||
"song": "Trenuletul",
|
|
||||||
"artist": "Zdob si Zdub"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Portugal",
|
|
||||||
"note": "SF1.10",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 53100000,
|
|
||||||
"timeEnd": 54300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "2340b",
|
|
||||||
"cue": "SF1.10",
|
|
||||||
"custom": {
|
|
||||||
"song": "Saudade Saudade",
|
|
||||||
"artist": "Maro"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Afternoon break",
|
|
||||||
"type": "block",
|
|
||||||
"id": "cb90b"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Croatia",
|
|
||||||
"note": "SF1.11",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 56100000,
|
|
||||||
"timeEnd": 57300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "503c4",
|
|
||||||
"cue": "SF1.11",
|
|
||||||
"custom": {
|
|
||||||
"song": "Guilty Pleasure",
|
|
||||||
"artist": "Mia Dimsic"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Denmark",
|
|
||||||
"note": "SF1.12",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 57600000,
|
|
||||||
"timeEnd": 58800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "5e965",
|
|
||||||
"cue": "SF1.12",
|
|
||||||
"custom": {
|
|
||||||
"song": "The Show",
|
|
||||||
"artist": "Reddi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Austria",
|
|
||||||
"note": "SF1.13",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 59100000,
|
|
||||||
"timeEnd": 60300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "bab4a",
|
|
||||||
"cue": "SF1.13",
|
|
||||||
"custom": {
|
|
||||||
"song": "Halo",
|
|
||||||
"artist": "LUM!X & Pia Maria"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Greece",
|
|
||||||
"note": "SF1.14",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 60600000,
|
|
||||||
"timeEnd": 61800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "d3eb1",
|
|
||||||
"cue": "SF1.14",
|
|
||||||
"custom": {
|
|
||||||
"song": "Die Together",
|
|
||||||
"artist": "Amanda Tenfjord"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
|
{
|
||||||
|
"id": "21cd2",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Latvia",
|
||||||
|
"timeStart": 37500000,
|
||||||
|
"timeEnd": 38700000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.02",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.02",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Eat Your Salad",
|
||||||
|
"artist": "Citi Zeni"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0b371",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Lithuania",
|
||||||
|
"timeStart": 39000000,
|
||||||
|
"timeEnd": 40200000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.03",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.03",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Sentimentai",
|
||||||
|
"artist": "Monika Liu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3cd28",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Switzerland",
|
||||||
|
"timeStart": 40500000,
|
||||||
|
"timeEnd": 41700000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.04",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.04",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Boys Do Cry",
|
||||||
|
"artist": "Marius Bear"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e457f",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Slovenia",
|
||||||
|
"timeStart": 42000000,
|
||||||
|
"timeEnd": 43200000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.05",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.05",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Disko",
|
||||||
|
"artist": "LPS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lunch break",
|
||||||
|
"type": "block",
|
||||||
|
"id": "01e85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1c420",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Ukraine",
|
||||||
|
"timeStart": 47100000,
|
||||||
|
"timeEnd": 48300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.06",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.06",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Stefania",
|
||||||
|
"artist": "Kalush Orchestra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b7737",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Bulgaria",
|
||||||
|
"timeStart": 48600000,
|
||||||
|
"timeEnd": 49800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.07",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.07",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Intention",
|
||||||
|
"artist": "Intelligent Music Project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d3a80",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Netherlands",
|
||||||
|
"timeStart": 50100000,
|
||||||
|
"timeEnd": 51300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.08",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.08",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "De Diepte",
|
||||||
|
"artist": "S10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8276c",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Moldova",
|
||||||
|
"timeStart": 51600000,
|
||||||
|
"timeEnd": 52800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.09",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.09",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Trenuletul",
|
||||||
|
"artist": "Zdob si Zdub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2340b",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Portugal",
|
||||||
|
"timeStart": 53100000,
|
||||||
|
"timeEnd": 54300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.10",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.10",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Saudade Saudade",
|
||||||
|
"artist": "Maro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Afternoon break",
|
||||||
|
"type": "block",
|
||||||
|
"id": "cb90b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "503c4",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Croatia",
|
||||||
|
"timeStart": 56100000,
|
||||||
|
"timeEnd": 57300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.11",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.11",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Guilty Pleasure",
|
||||||
|
"artist": "Mia Dimsic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5e965",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Denmark",
|
||||||
|
"timeStart": 57600000,
|
||||||
|
"timeEnd": 58800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.12",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.12",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "The Show",
|
||||||
|
"artist": "Reddi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bab4a",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Austria",
|
||||||
|
"timeStart": 59100000,
|
||||||
|
"timeEnd": 60300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.13",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.13",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Halo",
|
||||||
|
"artist": "LUM!X & Pia Maria"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d3eb1",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Greece",
|
||||||
|
"timeStart": 60600000,
|
||||||
|
"timeEnd": 61800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.14",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.14",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Die Together",
|
||||||
|
"artist": "Amanda Tenfjord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"project": {
|
"project": {
|
||||||
"title": "Eurovision Song Contest",
|
"title": "Eurovision Song Contest",
|
||||||
"description": "Turin 2022",
|
"description": "Turin 2022",
|
||||||
"publicUrl": "www.getontime.no",
|
"publicUrl": "www.getontime.no",
|
||||||
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
||||||
"backstageUrl": "www.github.com/cpvalente/ontime",
|
"backstageUrl": "www.github.com/cpvalente/ontime",
|
||||||
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
|
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
|
||||||
|
"projectLogo": null
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "2.0.0",
|
"version": "3.10.2",
|
||||||
"serverPort": 4001,
|
"serverPort": 4001,
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
@@ -312,10 +425,9 @@
|
|||||||
"overrideStyles": false,
|
"overrideStyles": false,
|
||||||
"normalColor": "#ffffffcc",
|
"normalColor": "#ffffffcc",
|
||||||
"warningColor": "#FFAB33",
|
"warningColor": "#FFAB33",
|
||||||
"warningThreshold": 120000,
|
|
||||||
"dangerColor": "#ED3333",
|
"dangerColor": "#ED3333",
|
||||||
"dangerThreshold": 60000,
|
"endMessage": "",
|
||||||
"endMessage": ""
|
"freezeEnd": false
|
||||||
},
|
},
|
||||||
"urlPresets": [
|
"urlPresets": [
|
||||||
{
|
{
|
||||||
@@ -335,5 +447,17 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"enabledOut": true,
|
"enabledOut": true,
|
||||||
"subscriptions": []
|
"subscriptions": []
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"song": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "song"
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "artist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
const fileToUpload = 'e2e/tests/fixtures/test-db.json';
|
const fileToUpload = 'e2e/tests/fixtures/test-db.json';
|
||||||
|
const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
|
||||||
|
|
||||||
test('project file upload', async ({ page }) => {
|
test('project file upload', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
@@ -34,3 +37,37 @@ test('project file upload', async ({ page }) => {
|
|||||||
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title');
|
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title');
|
||||||
await expect(thirdTitle).toHaveValue('Lithuania');
|
await expect(thirdTitle).toHaveValue('Lithuania');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('project file download', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
// close the welcome modal if it is open
|
||||||
|
await page.keyboard.down('Escape');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'toggle settings' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||||
|
|
||||||
|
// workaround to download
|
||||||
|
// https://playwright.dev/docs/api/class-download
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: RegExp('^test-db') })
|
||||||
|
.getByLabel('Options')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Download' }).click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||||
|
await download.saveAs(fileToDownload);
|
||||||
|
expect(download.failure()).toMatchObject({});
|
||||||
|
|
||||||
|
const original = JSON.parse(await readFile(fileToUpload, { encoding: 'utf-8' }));
|
||||||
|
const fromServer = JSON.parse(await readFile(fileToDownload, { encoding: 'utf-8' }));
|
||||||
|
|
||||||
|
// when a file is parsed, the server will write the version number to the project file
|
||||||
|
original.settings.version = 'not-important';
|
||||||
|
fromServer.settings.version = 'not-important';
|
||||||
|
expect(original).toMatchObject(fromServer);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('show warning when event crosses midnight', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('lock__end').getByRole('img').click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('23h');
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
|
||||||
|
await page.getByTestId('entry-3').getByTestId('time-input-duration').click();
|
||||||
|
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('2h');
|
||||||
|
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('entry-3').getByTestId('event-warning')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show warning when event starts next day midnight', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
|
await page.getByTestId('entry-2').getByText('E').click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('0');
|
||||||
|
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(2).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('(next day)')).toBeVisible();
|
||||||
|
});
|
||||||
Vendored
+419
-295
@@ -1,307 +1,420 @@
|
|||||||
{
|
{
|
||||||
"rundown": [
|
"rundown": [
|
||||||
{
|
{
|
||||||
"title": "Albania",
|
"id": "32d31",
|
||||||
"note": "SF1.01",
|
"type": "event",
|
||||||
"endAction": "none",
|
"title": "Albania",
|
||||||
"timerType": "count-down",
|
"timeStart": 36000000,
|
||||||
"timeStart": 36000000,
|
"timeEnd": 37200000,
|
||||||
"timeEnd": 37200000,
|
"duration": 1200000,
|
||||||
"duration": 1200000,
|
"timeStrategy": "lock-duration",
|
||||||
"isPublic": true,
|
"linkStart": null,
|
||||||
"skip": false,
|
"endAction": "none",
|
||||||
"colour": "",
|
"timerType": "count-down",
|
||||||
"type": "event",
|
"countToEnd": false,
|
||||||
"revision": 0,
|
"isPublic": true,
|
||||||
"id": "32d31",
|
"skip": false,
|
||||||
"cue": "SF1.01",
|
"note": "SF1.01",
|
||||||
"custom": {
|
"colour": "",
|
||||||
"song": "Sekret",
|
"delay": 0,
|
||||||
"artist": "Ronela Hajati"
|
"dayOffset": 0,
|
||||||
}
|
"gap": 0,
|
||||||
},
|
"cue": "SF1.01",
|
||||||
{
|
"revision": 0,
|
||||||
"title": "Latvia",
|
"timeWarning": 120000,
|
||||||
"note": "SF1.02",
|
"timeDanger": 60000,
|
||||||
"endAction": "none",
|
"custom": {
|
||||||
"timerType": "count-down",
|
"song": "Sekret",
|
||||||
"timeStart": 37500000,
|
"artist": "Ronela Hajati"
|
||||||
"timeEnd": 38700000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "21cd2",
|
|
||||||
"cue": "SF1.02",
|
|
||||||
"custom": {
|
|
||||||
"song": "Eat Your Salad",
|
|
||||||
"artist": "Citi Zeni"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Lithuania",
|
|
||||||
"note": "SF1.03",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 39000000,
|
|
||||||
"timeEnd": 40200000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "0b371",
|
|
||||||
"cue": "SF1.03",
|
|
||||||
"custom": {
|
|
||||||
"song": "Sentimentai",
|
|
||||||
"artist": "Monika Liu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Switzerland",
|
|
||||||
"note": "SF1.04",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 40500000,
|
|
||||||
"timeEnd": 41700000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "3cd28",
|
|
||||||
"cue": "SF1.04",
|
|
||||||
"custom": {
|
|
||||||
"song": "Boys Do Cry",
|
|
||||||
"artist": "Marius Bear"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Slovenia",
|
|
||||||
"note": "SF1.05",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 42000000,
|
|
||||||
"timeEnd": 43200000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "e457f",
|
|
||||||
"cue": "SF1.05",
|
|
||||||
"custom": {
|
|
||||||
"song": "Disko",
|
|
||||||
"artist": "LPS"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Lunch break",
|
|
||||||
"type": "block",
|
|
||||||
"id": "01e85"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Ukraine",
|
|
||||||
"note": "SF1.06",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 47100000,
|
|
||||||
"timeEnd": 48300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "1c420",
|
|
||||||
"cue": "SF1.06",
|
|
||||||
"custom": {
|
|
||||||
"song": "Stefania",
|
|
||||||
"artist": "Kalush Orchestra"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Bulgaria",
|
|
||||||
"note": "SF1.07",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 48600000,
|
|
||||||
"timeEnd": 49800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "b7737",
|
|
||||||
"cue": "SF1.07",
|
|
||||||
"custom": {
|
|
||||||
"song": "Intention",
|
|
||||||
"artist": "Intelligent Music Project"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Netherlands",
|
|
||||||
"note": "SF1.08",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 50100000,
|
|
||||||
"timeEnd": 51300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "d3a80",
|
|
||||||
"cue": "SF1.08",
|
|
||||||
"custom": {
|
|
||||||
"song": "De Diepte",
|
|
||||||
"artist": "S10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Moldova",
|
|
||||||
"note": "SF1.09",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 51600000,
|
|
||||||
"timeEnd": 52800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "8276c",
|
|
||||||
"cue": "SF1.09",
|
|
||||||
"custom": {
|
|
||||||
"song": "Trenuletul",
|
|
||||||
"artist": "Zdob si Zdub"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Portugal",
|
|
||||||
"note": "SF1.10",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 53100000,
|
|
||||||
"timeEnd": 54300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "2340b",
|
|
||||||
"cue": "SF1.10",
|
|
||||||
"custom": {
|
|
||||||
"song": "Saudade Saudade",
|
|
||||||
"artist": "Maro"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Afternoon break",
|
|
||||||
"type": "block",
|
|
||||||
"id": "cb90b"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Croatia",
|
|
||||||
"note": "SF1.11",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 56100000,
|
|
||||||
"timeEnd": 57300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "503c4",
|
|
||||||
"cue": "SF1.11",
|
|
||||||
"custom": {
|
|
||||||
"song": "Guilty Pleasure",
|
|
||||||
"artist": "Mia Dimsic"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Denmark",
|
|
||||||
"note": "SF1.12",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 57600000,
|
|
||||||
"timeEnd": 58800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "5e965",
|
|
||||||
"cue": "SF1.12",
|
|
||||||
"custom": {
|
|
||||||
"song": "The Show",
|
|
||||||
"artist": "Reddi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Austria",
|
|
||||||
"note": "SF1.13",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 59100000,
|
|
||||||
"timeEnd": 60300000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "bab4a",
|
|
||||||
"cue": "SF1.13",
|
|
||||||
"custom": {
|
|
||||||
"song": "Halo",
|
|
||||||
"artist": "LUM!X & Pia Maria"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Greece",
|
|
||||||
"note": "SF1.14",
|
|
||||||
"endAction": "none",
|
|
||||||
"timerType": "count-down",
|
|
||||||
"timeStart": 60600000,
|
|
||||||
"timeEnd": 61800000,
|
|
||||||
"duration": 1200000,
|
|
||||||
"isPublic": true,
|
|
||||||
"skip": false,
|
|
||||||
"colour": "",
|
|
||||||
"type": "event",
|
|
||||||
"revision": 0,
|
|
||||||
"id": "d3eb1",
|
|
||||||
"cue": "SF1.14",
|
|
||||||
"custom": {
|
|
||||||
"song": "Die Together",
|
|
||||||
"artist": "Amanda Tenfjord"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
},
|
||||||
|
{
|
||||||
|
"id": "21cd2",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Latvia",
|
||||||
|
"timeStart": 37500000,
|
||||||
|
"timeEnd": 38700000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.02",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.02",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Eat Your Salad",
|
||||||
|
"artist": "Citi Zeni"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0b371",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Lithuania",
|
||||||
|
"timeStart": 39000000,
|
||||||
|
"timeEnd": 40200000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.03",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.03",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Sentimentai",
|
||||||
|
"artist": "Monika Liu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3cd28",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Switzerland",
|
||||||
|
"timeStart": 40500000,
|
||||||
|
"timeEnd": 41700000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.04",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.04",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Boys Do Cry",
|
||||||
|
"artist": "Marius Bear"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e457f",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Slovenia",
|
||||||
|
"timeStart": 42000000,
|
||||||
|
"timeEnd": 43200000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.05",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.05",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Disko",
|
||||||
|
"artist": "LPS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lunch break",
|
||||||
|
"type": "block",
|
||||||
|
"id": "01e85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1c420",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Ukraine",
|
||||||
|
"timeStart": 47100000,
|
||||||
|
"timeEnd": 48300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.06",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.06",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Stefania",
|
||||||
|
"artist": "Kalush Orchestra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b7737",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Bulgaria",
|
||||||
|
"timeStart": 48600000,
|
||||||
|
"timeEnd": 49800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.07",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.07",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Intention",
|
||||||
|
"artist": "Intelligent Music Project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d3a80",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Netherlands",
|
||||||
|
"timeStart": 50100000,
|
||||||
|
"timeEnd": 51300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.08",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.08",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "De Diepte",
|
||||||
|
"artist": "S10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8276c",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Moldova",
|
||||||
|
"timeStart": 51600000,
|
||||||
|
"timeEnd": 52800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.09",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.09",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Trenuletul",
|
||||||
|
"artist": "Zdob si Zdub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2340b",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Portugal",
|
||||||
|
"timeStart": 53100000,
|
||||||
|
"timeEnd": 54300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.10",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.10",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Saudade Saudade",
|
||||||
|
"artist": "Maro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Afternoon break",
|
||||||
|
"type": "block",
|
||||||
|
"id": "cb90b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "503c4",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Croatia",
|
||||||
|
"timeStart": 56100000,
|
||||||
|
"timeEnd": 57300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.11",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.11",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Guilty Pleasure",
|
||||||
|
"artist": "Mia Dimsic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5e965",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Denmark",
|
||||||
|
"timeStart": 57600000,
|
||||||
|
"timeEnd": 58800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.12",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.12",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "The Show",
|
||||||
|
"artist": "Reddi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bab4a",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Austria",
|
||||||
|
"timeStart": 59100000,
|
||||||
|
"timeEnd": 60300000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.13",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.13",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Halo",
|
||||||
|
"artist": "LUM!X & Pia Maria"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d3eb1",
|
||||||
|
"type": "event",
|
||||||
|
"title": "Greece",
|
||||||
|
"timeStart": 60600000,
|
||||||
|
"timeEnd": 61800000,
|
||||||
|
"duration": 1200000,
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
"linkStart": null,
|
||||||
|
"endAction": "none",
|
||||||
|
"timerType": "count-down",
|
||||||
|
"countToEnd": false,
|
||||||
|
"isPublic": true,
|
||||||
|
"skip": false,
|
||||||
|
"note": "SF1.14",
|
||||||
|
"colour": "",
|
||||||
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
|
"cue": "SF1.14",
|
||||||
|
"revision": 0,
|
||||||
|
"timeWarning": 120000,
|
||||||
|
"timeDanger": 60000,
|
||||||
|
"custom": {
|
||||||
|
"song": "Die Together",
|
||||||
|
"artist": "Amanda Tenfjord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"project": {
|
"project": {
|
||||||
"title": "Eurovision Song Contest",
|
"title": "Eurovision Song Contest",
|
||||||
"description": "Turin 2022",
|
"description": "Turin 2022",
|
||||||
"publicUrl": "www.getontime.no",
|
"publicUrl": "www.getontime.no",
|
||||||
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
||||||
"backstageUrl": "www.github.com/cpvalente/ontime",
|
"backstageUrl": "www.github.com/cpvalente/ontime",
|
||||||
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
|
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
|
||||||
|
"projectLogo": null
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "2.0.0",
|
"version": "3.10.2",
|
||||||
"serverPort": 4001,
|
"serverPort": 4001,
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
@@ -312,10 +425,9 @@
|
|||||||
"overrideStyles": false,
|
"overrideStyles": false,
|
||||||
"normalColor": "#ffffffcc",
|
"normalColor": "#ffffffcc",
|
||||||
"warningColor": "#FFAB33",
|
"warningColor": "#FFAB33",
|
||||||
"warningThreshold": 120000,
|
|
||||||
"dangerColor": "#ED3333",
|
"dangerColor": "#ED3333",
|
||||||
"dangerThreshold": 60000,
|
"endMessage": "",
|
||||||
"endMessage": ""
|
"freezeEnd": false
|
||||||
},
|
},
|
||||||
"urlPresets": [
|
"urlPresets": [
|
||||||
{
|
{
|
||||||
@@ -335,5 +447,17 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"enabledOut": true,
|
"enabledOut": true,
|
||||||
"subscriptions": []
|
"subscriptions": []
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"song": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "song"
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "artist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.10.1",
|
"version": "3.10.3",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
skip: boolean;
|
skip: boolean;
|
||||||
colour: string;
|
colour: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
delay?: number; // calculated at runtime
|
delay: number; // calculated at runtime
|
||||||
|
dayOffset: number; // calculated at runtime
|
||||||
|
gap: number; // calculated at runtime
|
||||||
timeWarning: number;
|
timeWarning: number;
|
||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
custom: EventCustomFields;
|
custom: EventCustomFields;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Transforms a Custom field label into a valid key or returns null if not possible
|
* @description Transforms a Custom field label into a valid key or returns null if not possible
|
||||||
* @returns {string | null}
|
|
||||||
*/
|
*/
|
||||||
export const customFieldLabelToKey = (label: string): string | null => {
|
export const customFieldLabelToKey = (label: string): string | null => {
|
||||||
if (isAlphanumericWithSpace(label)) {
|
if (isAlphanumericWithSpace(label)) {
|
||||||
|
|||||||
@@ -2,66 +2,51 @@ import { checkIsNextDay } from './checkIsNextDay';
|
|||||||
import { MILLIS_PER_HOUR } from './conversionUtils';
|
import { MILLIS_PER_HOUR } from './conversionUtils';
|
||||||
|
|
||||||
describe('checkIsNextDay', () => {
|
describe('checkIsNextDay', () => {
|
||||||
it('returns false if the previous event duration is 0', () => {
|
it('returns false if there is no previous event', () => {
|
||||||
const previousStart = 0;
|
const current = { timeStart: 0, dayOffset: 0 };
|
||||||
const previousDuration = 0;
|
const previous = undefined;
|
||||||
const timeStart = 0;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false if event starts after one before', () => {
|
it('returns false if event starts after one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 11, dayOffset: 0 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 11;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true if event starts after one before', () => {
|
it('returns true if event starts after one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 9, dayOffset: 1 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 9;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true if event starts at the same time as one before', () => {
|
it('returns true if event starts at the same time as one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 10, dayOffset: 1 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 10;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight', () => {
|
it('should account for an event that crossed midnight', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
|
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
|
||||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight and there is a gap', () => {
|
it('should account for an event that crossed midnight and there is a gap', () => {
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00
|
||||||
const previousDuration = 2 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight with no overlaps', () => {
|
it('should account for an event that crossed midnight with no overlaps', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
|
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
|
||||||
const timeStart = 19 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that finishes exactly at midnight', () => {
|
it('should account for an event that finishes exactly at midnight', () => {
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 1 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00
|
||||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for normalised start over multiple days', () => {
|
|
||||||
const previousStart = 90000000; // 25:00:00
|
|
||||||
const previousDuration = 1 * MILLIS_PER_HOUR;
|
|
||||||
const timeStart = 0;
|
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +1,27 @@
|
|||||||
|
import type { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { dayInMs } from './conversionUtils.js';
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function checks whether a given event is the day after from its predecessor
|
* Utility function checks whether a given event is the day after from its predecessor
|
||||||
* We consider an event to be the day after, if it begins before the start of the previous
|
|
||||||
* @example day after
|
|
||||||
* 09:00 - 10:00
|
|
||||||
* 08:00 - 10:30
|
|
||||||
* @example day after
|
|
||||||
* 23:00 - 00:00
|
|
||||||
* 02:00 - 03:00
|
|
||||||
* @example same day
|
|
||||||
* 09:00 - 10:00
|
|
||||||
* 09:30 - 10:30
|
|
||||||
* @example same day, but previous crosses midnight
|
|
||||||
* 23:00 - 01:00
|
|
||||||
* 02:00 - 03:00
|
|
||||||
* @example same day, but previous crosses midnight (with overlap)
|
|
||||||
* 22:00 - 02:00
|
|
||||||
* 01:00 - 03:00
|
|
||||||
*/
|
*/
|
||||||
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration: number): boolean {
|
export function checkIsNextDay(
|
||||||
if (previousDuration === 0) {
|
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
|
||||||
|
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
): boolean {
|
||||||
|
if (!previous) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedStart = previousStart % dayInMs;
|
// if the day offsets are the same it can't be the next day
|
||||||
if (timeStart <= cappedStart) {
|
if (current.dayOffset <= previous.dayOffset) {
|
||||||
const normalisedPreviousEnd = cappedStart + previousDuration;
|
return false;
|
||||||
if (normalisedPreviousEnd === dayInMs) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// handle exception for an event that finishes exactly at midnight
|
|
||||||
if (normalisedPreviousEnd > dayInMs) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// if the previous event crossed midnight then the current is the same day
|
||||||
|
if (previous.timeStart + previous.duration > dayInMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,58 @@
|
|||||||
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
|
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
|
||||||
import { getTimeFromPrevious } from './getTimeFromPrevious';
|
import { getTimeFromPrevious } from './getTimeFromPrevious';
|
||||||
|
|
||||||
describe('getTimeFromPrevious', () => {
|
describe('getTimeFromPrevious', () => {
|
||||||
it('returns the time elapsed (gap or overlap) from the previous', () => {
|
it('returns the time elapsed (gap or overlap) from the previous', () => {
|
||||||
const previousStart = 69600000; // 19:20
|
|
||||||
const previousEnd = 71700000; // 19:55
|
|
||||||
const previousDuration = 2100000; // 35 minutes
|
|
||||||
const currentStart = 75600000; // 21:00
|
|
||||||
const expected = 75600000 - 71700000; // current start - previousEnd
|
const expected = 75600000 - 71700000; // current start - previousEnd
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
getTimeFromPrevious(
|
||||||
|
{ timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
{ timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 },
|
||||||
|
),
|
||||||
|
).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for partially overlapping events', () => {
|
it('accounts for partially overlapping events', () => {
|
||||||
const previousStart = 10;
|
const expected = -1;
|
||||||
const previousEnd = 12;
|
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
|
||||||
const previousDuration = 2;
|
expected,
|
||||||
const currentStart = 11;
|
);
|
||||||
const expected = -(previousEnd - currentStart);
|
|
||||||
|
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for events that are fully contained', () => {
|
it('accounts for events that are fully contained', () => {
|
||||||
const previousStart = 8;
|
const expected = -6;
|
||||||
const previousEnd = 16;
|
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
|
||||||
const previousDuration = 8;
|
expected,
|
||||||
const currentStart = 10;
|
);
|
||||||
const expected = -(previousEnd - currentStart);
|
|
||||||
|
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fully overlapping events are the next day', () => {
|
it('fully overlapping events are the next day', () => {
|
||||||
const previousStart = 10 * MILLIS_PER_HOUR;
|
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
|
||||||
const previousEnd = 12 * MILLIS_PER_HOUR;
|
expect(
|
||||||
const previousDuration = previousEnd - previousStart;
|
getTimeFromPrevious(
|
||||||
const currentStart = 10 * MILLIS_PER_HOUR;
|
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
|
||||||
const expected = dayInMs - previousDuration;
|
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
),
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for events that are the day after', () => {
|
it('accounts for events that are the day after', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
|
||||||
const previousEnd = 23 * MILLIS_PER_HOUR;
|
|
||||||
const previousDuration = 3 * MILLIS_PER_HOUR;
|
|
||||||
const currentStart = 22 * MILLIS_PER_HOUR;
|
|
||||||
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
getTimeFromPrevious(
|
||||||
|
{ timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
),
|
||||||
|
).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for events that cross midnight', () => {
|
it('accounts for events that cross midnight', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
|
||||||
const previousEnd = 2 * MILLIS_PER_HOUR;
|
|
||||||
const previousDuration = 6 * MILLIS_PER_HOUR;
|
|
||||||
const currentStart = 1 * MILLIS_PER_HOUR;
|
|
||||||
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
getTimeFromPrevious(
|
||||||
|
{ timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 },
|
||||||
|
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
),
|
||||||
|
).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +1,33 @@
|
|||||||
import { checkIsNextDay } from './checkIsNextDay.js';
|
import type { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { dayInMs } from './conversionUtils.js';
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility returns the time elapsed (gap or overlap) from the previous
|
* Utility returns the gap from previous event
|
||||||
* It uses deconstructed parameters to simplify implementation in UI
|
|
||||||
*/
|
*/
|
||||||
export function getTimeFromPrevious(
|
export function getTimeFromPrevious(
|
||||||
currentStart: number,
|
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
|
||||||
previousStart?: number,
|
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
previousEnd?: number,
|
|
||||||
previousDuration?: number,
|
|
||||||
): number {
|
): number {
|
||||||
// there is no previous event
|
// there is no previous event
|
||||||
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
|
if (!previous) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
|
||||||
|
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
|
||||||
|
|
||||||
// event is linked to previous
|
// event is linked to previous
|
||||||
if (currentStart === previousEnd) {
|
if (normalisedCurrentStart === normalisedPreviousEnd) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// event is the day after
|
|
||||||
if (checkIsNextDay(previousStart, currentStart, previousDuration)) {
|
|
||||||
// time from previous is difference between normalised start and previous end
|
|
||||||
return currentStart + dayInMs - previousEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// event has a gap from previous
|
// event has a gap from previous
|
||||||
if (currentStart > previousEnd) {
|
if (normalisedCurrentStart > normalisedPreviousEnd) {
|
||||||
// time from previous is difference between start and previous end
|
// time from previous is difference between start and previous end
|
||||||
return currentStart - previousEnd;
|
return normalisedCurrentStart - normalisedPreviousEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// event overlaps with previous
|
// event overlaps with previous
|
||||||
const overlap = previousEnd - currentStart;
|
return normalisedCurrentStart - normalisedPreviousEnd;
|
||||||
if (overlap > 0) {
|
|
||||||
// time is a negative number indicating the amount of overlap
|
|
||||||
return -overlap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to make sure we return a number, but there are no business cases for this
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,52 @@
|
|||||||
import { MILLIS_PER_HOUR } from './conversionUtils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
|
||||||
import { isNewLatest } from './isNewLatest';
|
import { isNewLatest } from './isNewLatest';
|
||||||
|
|
||||||
describe('isNewLatest', () => {
|
describe('isNewLatest', () => {
|
||||||
it('should be true if there is no previous', () => {
|
it('should be true if there is no previous', () => {
|
||||||
expect(isNewLatest(0, 60000)).toBeTruthy();
|
const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
|
const previous = undefined;
|
||||||
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false if current is contained in the previous', () => {
|
||||||
|
const current = {
|
||||||
|
timeStart: 21 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
|
||||||
|
duration: 10 * MILLIS_PER_MINUTE,
|
||||||
|
dayOffset: 0,
|
||||||
|
};
|
||||||
|
const previous = { timeStart: 21 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
|
|
||||||
|
expect(isNewLatest(current, previous)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it starts when the previous finishes', () => {
|
it('should be true if it starts when the previous finishes', () => {
|
||||||
const nowStart = 10 * MILLIS_PER_HOUR;
|
const current = { timeStart: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const nowEnd = 11 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 10 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it starts the same day the previous finishes', () => {
|
it('should be true if it starts the same day the previous finishes', () => {
|
||||||
const nowStart = 22 * MILLIS_PER_HOUR;
|
const current = { timeStart: 22 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const nowEnd = 23 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 20 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
|
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
|
||||||
const nowStart = 1 * MILLIS_PER_HOUR;
|
const current = { timeStart: 1 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 3 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
|
||||||
const previousEnd = 2 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it the next day', () => {
|
it('should be true if it the next day', () => {
|
||||||
const nowStart = 8 * MILLIS_PER_HOUR;
|
const current = { timeStart: 8 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 10 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 11 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it the next day (2)', () => {
|
it('should be true if it the next day (2)', () => {
|
||||||
const nowStart = 9 * MILLIS_PER_HOUR;
|
const current = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 11 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 11 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 11 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { checkIsNextDay } from './checkIsNextDay.js';
|
import type { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a new element is the latest in the list
|
* Checks whether a new element is the latest in the list
|
||||||
*/
|
*/
|
||||||
export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean {
|
export function isNewLatest(
|
||||||
|
currentEvent: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
previousEvent?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
) {
|
||||||
// true if there is no previous
|
// true if there is no previous
|
||||||
if (previousStart === undefined || previousEnd === undefined) {
|
if (!previousEvent) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// true if it starts after the previous is finished
|
const normalisedCurrentEnd = currentEvent.timeStart + currentEvent.duration + currentEvent.dayOffset * dayInMs;
|
||||||
if (timeStart >= previousEnd) {
|
const normalisedPreviousEnd = previousEvent.timeStart + previousEvent.duration + previousEvent.dayOffset * dayInMs;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// true if it finishes later than previous
|
return normalisedCurrentEnd >= normalisedPreviousEnd;
|
||||||
if (timeEnd > previousEnd) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// true if it is the day after
|
|
||||||
return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import type { MaybeString } from 'ontime-types';
|
|||||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a given value is a valid type of string, returns null otherwise
|
* Check if a given value is a valid type linkStart, returns the fallback otherwise
|
||||||
|
* linkStart can be a string (id of an event to link) or null (unlinked)
|
||||||
* @param {MaybeString} maybeLinkStart
|
* @param {MaybeString} maybeLinkStart
|
||||||
* @returns {MaybeString}
|
* @returns {MaybeString}
|
||||||
*/
|
*/
|
||||||
export function validateLinkStart(maybeLinkStart: unknown): MaybeString {
|
export function validateLinkStart(maybeLinkStart: unknown, fallback: MaybeString = null): MaybeString {
|
||||||
return typeof maybeLinkStart === 'string' ? maybeLinkStart : null;
|
if (typeof maybeLinkStart === 'string' || maybeLinkStart === null) {
|
||||||
|
return maybeLinkStart as MaybeString;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-2
@@ -3,10 +3,12 @@
|
|||||||
"globalEnv": ["NODE_ENV", "GITHUB_TOKEN"],
|
"globalEnv": ["NODE_ENV", "GITHUB_TOKEN"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
},
|
},
|
||||||
"dev:server": {
|
"dev:server": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
},
|
},
|
||||||
"dev:test": {
|
"dev:test": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
|||||||
Reference in New Issue
Block a user