Compare commits

...

14 Commits

Author SHA1 Message Date
Carlos Valente 7d9c2a9cfd refactor: create mock data utilities 2025-01-12 09:05:23 +01:00
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
Carlos Valente 35424cfff2 bump version to 3.10.3 2025-01-06 20:33:39 +01:00
Alex Christoffer Rasmussen 13eadeb0af fix download (#1434)
* fix download

* update test db

* create download test
2025-01-06 20:26:06 +01:00
Carlos Valente 9cfed5a150 fix: issue with persistent script 2025-01-06 20:11:15 +01:00
Alex Christoffer Rasmussen a22c2d380d bump: v3.10.2 (#1431) 2025-01-06 14:08:36 +01:00
Alex Christoffer Rasmussen cd913fc144 small strict improvements (#1428)
* small `strict` improvements

* fix type

* use isObject
2025-01-06 14:07:36 +01:00
Alex Christoffer Rasmussen 743d7fa782 fix: validateLinkStart (#1430)
* fix validateLinkStart

* update comment

* use fallback

* cast to MaybeString

* update comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-06 13:12:03 +01:00
Carlos Valente 891c43ba77 refactor: countToEnd does not cause rundown recalculation 2025-01-05 15:18:28 +01:00
Carlos Valente fba20b889e refactor: only allow testing saved URLs 2025-01-05 15:17:53 +01:00
Carlos Valente bc4d913ba7 refactor: always show client path 2025-01-05 15:17:53 +01:00
Alex Christoffer Rasmussen 533f9798e8 convert to and from database type (#1410)
* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-05 14:35:58 +01:00
54 changed files with 1613 additions and 1136 deletions
+1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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'
+7 -5
View File
@@ -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>
+3 -15
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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',
+42
View File
@@ -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: {
+4 -1
View File
@@ -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);
}); });
+4 -1
View File
@@ -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
View File
@@ -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"
}
} }
} }
+37
View File
@@ -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();
});
+419 -295
View File
@@ -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
View File
@@ -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();
}); });
}); });
+16 -30
View File
@@ -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();
}); });
}); });
+11 -14
View File
@@ -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
View File
@@ -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