diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index b16288296..c9c90fec9 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -25,6 +25,8 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => { colour: event.colour, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: event.timeWarning, timeDanger: event.timeDanger, custom: {}, diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 3343c7e20..7099ab0f1 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -13,6 +13,7 @@ import { SupportedEvent, } from 'ontime-types'; import { + checkIsNextDay, getFirstNormal, getLastNormal, getNextBlockNormal, @@ -267,7 +268,7 @@ export default function Rundown({ data }: RundownProps) { let eventIndex = 0; // all events before the current selected are in the past let isPast = Boolean(featureData?.selectedEventId); - + let isNextDay = false; const isEditMode = appMode === AppMode.Edit; return ( @@ -286,6 +287,7 @@ export default function Rundown({ data }: RundownProps) { if (index === 0) { eventIndex = 0; } + isNextDay = false; previousEntryId = thisId; thisId = entryId; if (isOntimeEvent(entry)) { @@ -294,8 +296,9 @@ export default function Rundown({ data }: RundownProps) { lastEvent = thisEvent; if (isPlayableEvent(entry)) { - // populate previous entry - if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) { + isNextDay = checkIsNextDay(entry, lastEvent); + if (isNewLatest(entry, lastEvent)) { + // populate previous entry thisEvent = entry; } } @@ -323,12 +326,11 @@ export default function Rundown({ data }: RundownProps) { loaded={isLoaded} hasCursor={hasCursor} isNext={isNext} - previousStart={lastEvent?.timeStart} - previousEnd={lastEvent?.timeEnd} previousEntryId={previousEntryId} previousEventId={lastEvent?.id} playback={isLoaded ? featureData.playback : undefined} isRolling={featureData.playback === Playback.Roll} + isNextDay={isNextDay} /> diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 12f9c1659..d7fb2ce7d 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -32,8 +32,7 @@ interface RundownEntryProps { eventIndex: number; hasCursor: boolean; isNext: boolean; - previousStart?: number; - previousEnd?: number; + isNextDay: boolean; previousEntryId?: string; previousEventId?: string; playback?: Playback; // we only care about this if this event is playing @@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) { loaded, hasCursor, isNext, - previousStart, - previousEnd, previousEntryId, previousEventId, playback, isRolling, eventIndex, + isNextDay, } = props; const { emitError } = useEmitLog(); const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); @@ -165,8 +163,6 @@ export default function RundownEntry(props: RundownEntryProps) { title={data.title} note={data.note} delay={data.delay ?? 0} - previousStart={previousStart} - previousEnd={previousEnd} colour={data.colour} isPast={isPast} isNext={isNext} @@ -175,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) { hasCursor={hasCursor} playback={playback} isRolling={isRolling} + gap={data.gap} + isNextDay={isNextDay} actionHandler={actionHandler} /> ); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 10e864ab1..1f1de2ef3 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -39,8 +39,6 @@ interface EventBlockProps { title: string; note: string; delay: number; - previousStart?: number; - previousEnd?: number; colour: string; isPast: boolean; isNext: boolean; @@ -49,6 +47,8 @@ interface EventBlockProps { hasCursor: boolean; playback?: Playback; isRolling: boolean; + gap: number; + isNextDay: boolean; actionHandler: ( action: EventItemActions, payload?: @@ -77,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) { title, note, delay, - previousStart, - previousEnd, colour, isPast, isNext, @@ -87,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) { hasCursor, playback, isRolling, + gap, + isNextDay, actionHandler, } = props; const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); @@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) { onContextMenu={onContextMenu} id='event-block' > - +
diff --git a/apps/client/src/features/rundown/event-block/EventBlock.utils.ts b/apps/client/src/features/rundown/event-block/EventBlock.utils.ts index 787be6fda..443a74a4b 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.utils.ts +++ b/apps/client/src/features/rundown/event-block/EventBlock.utils.ts @@ -1,11 +1,4 @@ -import { - calculateDuration, - checkIsNextDay, - dayInMs, - getTimeFromPrevious, - millisToString, - removeTrailingZero, -} from 'ontime-utils'; +import { millisToString, removeTrailingZero } from 'ontime-utils'; import { formatDuration } from '../../../common/utils/time'; @@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine const timeTag = removeTrailingZero(millisToString(delayedStart)); return `New start ${timeTag}`; } - -export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined { - const noPreviousElement = previousEnd === undefined || previousStart === undefined; - if (noPreviousElement) return; - - const normalisedDuration = calculateDuration(previousStart, previousEnd); - const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration); - if (timeFromPrevious === 0) return; - - if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) { - const previousCrossMidnight = previousStart > previousEnd; - const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd; - - const gap = dayInMs - normalisedPreviousEnd + timeStart; - if (gap === 0) return; - const gapString = formatDuration(Math.abs(gap), false); - return `Gap ${gapString} (next day)`; +export function formatGap(gap: number, isNextDay: boolean) { + if (gap === 0) { + if (isNextDay) { + // We show a next day warning even if there is no gap + return '(next day)'; + } + return; } - const overlapString = formatDuration(Math.abs(timeFromPrevious), false); - return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`; + const gapString = formatDuration(Math.abs(gap), false); + return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`; } diff --git a/apps/client/src/features/rundown/event-block/RundownIndicators.tsx b/apps/client/src/features/rundown/event-block/RundownIndicators.tsx index 82a34e336..a0e4d5de2 100644 --- a/apps/client/src/features/rundown/event-block/RundownIndicators.tsx +++ b/apps/client/src/features/rundown/event-block/RundownIndicators.tsx @@ -1,24 +1,24 @@ -import { formatDelay, formatOverlap } from './EventBlock.utils'; +import { formatDelay, formatGap } from './EventBlock.utils'; import style from './RundownIndicators.module.scss'; interface RundownIndicatorProps { timeStart: number; - previousStart?: number; - previousEnd?: number; + isNextDay: boolean; delay: number; + gap: number; } 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); return (
{hasDelay &&
{hasDelay}
} - {hasOverlap &&
{hasOverlap}
} + {hasGap &&
{hasGap}
}
); } diff --git a/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts b/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts index 544ff8506..52f698f3c 100644 --- a/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts +++ b/apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts @@ -1,6 +1,4 @@ -import { MILLIS_PER_HOUR } from 'ontime-utils'; - -import { formatDelay, formatOverlap } from '../EventBlock.utils'; +import { formatDelay } from '../EventBlock.utils'; describe('formatDelay()', () => { it('adds a given delay to the start time', () => { @@ -10,69 +8,3 @@ describe('formatDelay()', () => { expect(result).toEqual('New start 00:02'); }); }); - -describe('formatOverlap()', () => { - it('recognises an overlap between two times', () => { - const previousStart = 0; - const previousEnd = 60000; // 1 min - const timeStart = 30000; // 30 sec - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toEqual('Overlap 30s'); - }); - - it('bug #949 recognises an overlap between two times', () => { - const previousStart = 46800000; // 13:00:00 - const previousEnd = 48600000; // 13:30:00 - const timeStart = 48300000; // 13:25:00 - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toEqual('Overlap 5m'); - }); - - it('handles events the day after, without overlap', () => { - const previousStart = 11 * MILLIS_PER_HOUR; - const previousEnd = 12 * MILLIS_PER_HOUR; - const timeStart = 6 * MILLIS_PER_HOUR; - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBe('Gap 18h (next day)'); - }); - - it('handles events the day after, with gap', () => { - const previousStart = 17 * MILLIS_PER_HOUR; - const previousEnd = 23 * MILLIS_PER_HOUR; - const timeStart = 9 * MILLIS_PER_HOUR; - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBe('Gap 10h (next day)'); - }); - - it('handles events the day after, with previous ending at midnight', () => { - const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00 - const previousEnd = 0; // 00:00:00 - const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00 - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBe('Gap 1h (next day)'); - }); - - it('handles sequential events the day after, with previous ending over midnight', () => { - const previousStart = 23 * MILLIS_PER_HOUR; - const previousEnd = 1 * MILLIS_PER_HOUR; - const timeStart = 1 * MILLIS_PER_HOUR; - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBeUndefined(); - }); - - it('handles events the day after, with previous ending over midnight with overlap', () => { - const previousStart = 23 * MILLIS_PER_HOUR; - const previousEnd = 2 * MILLIS_PER_HOUR; - const timeStart = 1 * MILLIS_PER_HOUR; - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBe('Overlap 1h'); - }); - - it('handles events the day after, with previous ending over midnight with gap', () => { - const previousStart = 23 * MILLIS_PER_HOUR; - const previousEnd = 1 * MILLIS_PER_HOUR; - const timeStart = 2 * MILLIS_PER_HOUR; - const result = formatOverlap(timeStart, previousStart, previousEnd); - expect(result).toBe('Gap 1h'); - }); -}); diff --git a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx index b4f70d834..d9d54263f 100644 --- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx +++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx @@ -6,6 +6,7 @@ import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed'; import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline'; import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink'; import { MaybeString, TimeField, TimeStrategy } from 'ontime-types'; +import { dayInMs } from 'ontime-utils'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import { useEventAction } from '../../../common/hooks/useEventAction'; @@ -45,7 +46,7 @@ function TimeInputFlow(props: EventBlockTimerProps) { }; const warnings = []; - if (timeStart > timeEnd) { + if (timeStart + duration > dayInMs) { warnings.push('Over midnight'); } @@ -129,7 +130,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
{warnings.length > 0 && ( -
+
diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx index 6cfeb8ab6..c191bcb4e 100644 --- a/apps/client/src/views/timeline/Timeline.tsx +++ b/apps/client/src/views/timeline/Timeline.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useViewportSize } from '@mantine/hooks'; -import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types'; -import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils'; +import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types'; +import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils'; import TimelineMarkers from './timeline-markers/TimelineMarkers'; import { getElementPosition, getEndHour, getStartHour } from './timeline.utils'; @@ -30,10 +30,8 @@ function Timeline(props: TimelineProps) { const startHour = getStartHour(firstStart); const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0)); - let previousEventStartTime: MaybeNumber = null; // we use selectedEventId as a signifier on whether the timeline is live let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future'; - let elapsedDays = 0; return (
@@ -52,14 +50,7 @@ function Timeline(props: TimelineProps) { eventStatus = 'live'; } - // we only need to check for next day if we have a previous event - if ( - previousEventStartTime !== null && - checkIsNextDay(previousEventStartTime, event.timeStart, event.duration) - ) { - elapsedDays++; - } - const normalisedStart = event.timeStart + elapsedDays * dayInMs; + const normalisedStart = event.timeStart + event.dayOffset * dayInMs; const { left: elementLeftPosition, width: elementWidth } = getElementPosition( startHour * MILLIS_PER_HOUR, @@ -69,9 +60,6 @@ function Timeline(props: TimelineProps) { screenWidth, ); - // prepare values for next iteration - previousEventStartTime = normalisedStart; - return ( = { type: SupportedEvent.Event, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, custom: {}, diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index 4c06f79d0..c78ce5300 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -150,11 +150,11 @@ describe('apply()', () => { makeOntimeDelay(100), makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), // gap 50 - makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }), + 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 - makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }), - // gap 50 - makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }), + makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), // linked makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), ]; @@ -165,7 +165,7 @@ describe('apply()', () => { // gap 50 (100 - 50) { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, // gap 50 (50 - 50) - { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 }, + { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, // gap (delay is 0) { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, // linked @@ -178,6 +178,8 @@ describe('apply()', () => { makeOntimeDelay(2 * MILLIS_PER_HOUR), makeOntimeEvent({ id: '1', + gap: 0, + dayOffset: 0, timeStart: 46800000, // 13:00:00 timeEnd: 50400000, // 14:00:00 duration: MILLIS_PER_HOUR, @@ -185,6 +187,8 @@ describe('apply()', () => { // gap 1h makeOntimeEvent({ id: '2', + gap: 1 * MILLIS_PER_HOUR, + dayOffset: 0, timeStart: 54000000, // 15:00:00 timeEnd: 57600000, // 16:00:00 duration: MILLIS_PER_HOUR, diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index f7a7b58b3..3e9684587 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -577,6 +577,8 @@ describe('calculateRuntimeDelays', () => { type: SupportedEvent.Event, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '659e1', @@ -605,6 +607,8 @@ describe('calculateRuntimeDelays', () => { type: SupportedEvent.Event, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '1c48f', @@ -633,6 +637,8 @@ describe('calculateRuntimeDelays', () => { type: SupportedEvent.Event, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: 'd48c2', @@ -661,6 +667,8 @@ describe('calculateRuntimeDelays', () => { type: SupportedEvent.Event, revision: 0, delay: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '2f185', @@ -701,6 +709,8 @@ describe('getDelayAt()', () => { timeDanger: 60000, id: '659e1', delay: 0, + dayOffset: 0, + gap: 0, cue: '1', custom: {}, }, @@ -725,6 +735,8 @@ describe('getDelayAt()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '1c48f', @@ -753,6 +765,8 @@ describe('getDelayAt()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: 'd48c2', @@ -781,6 +795,8 @@ describe('getDelayAt()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '2f185', @@ -835,6 +851,8 @@ describe('calculateRuntimeDelaysFrom()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '659e1', @@ -863,6 +881,8 @@ describe('calculateRuntimeDelaysFrom()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '1c48f', @@ -891,6 +911,8 @@ describe('calculateRuntimeDelaysFrom()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: 'd48c2', @@ -919,6 +941,8 @@ describe('calculateRuntimeDelaysFrom()', () => { colour: '', type: SupportedEvent.Event, revision: 0, + dayOffset: 0, + gap: 0, timeWarning: 120000, timeDanger: 60000, id: '2f185', diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts index b927c6c4b..5904e38fd 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts @@ -9,12 +9,14 @@ import { } from 'ontime-types'; import { addToCustomAssignment, + calculateDayOffset, getLink, handleCustomField, handleLink, hasChanges, isDataStale, } from '../rundownCacheUtils.js'; +import { MILLIS_PER_HOUR } from 'ontime-utils'; describe('getLink()', () => { it('should return null if there is no link', () => { @@ -247,3 +249,61 @@ describe('hasChanges()', () => { expect(hasChanges(existing, newEvent)).toBe(false); }); }); + +describe('calculateDayOffset', () => { + it('returns 0 if there is no previous event', () => { + expect(calculateDayOffset({ timeStart: 0 })).toBe(0); + }); + + it('returns 0 if the previous event duration is 0', () => { + expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0); + }); + + it('returns 0 if event starts after previous', () => { + expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0); + }); + + it('returns 1 if event starts before previous', () => { + expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1); + }); + + it('returns 1 if event starts at the same time as one before', () => { + expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1); + }); + + it('should account for an event that crossed midnight and there is a overlap', () => { + expect( + calculateDayOffset( + { timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00 + { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00 + ), + ).toBe(1); + }); + + it('should account for an event that crossed midnight and there is a gap', () => { + expect( + calculateDayOffset( + { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00 + { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00 + ), + ).toBe(1); + }); + + it('should account for an event that crossed midnight with no overlaps or gaps', () => { + expect( + calculateDayOffset( + { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00 + { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00 + ), + ).toBe(1); + }); + + it('should account for an event that finishes exactly at midnight', () => { + expect( + calculateDayOffset( + { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00 + { timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00 + ), + ).toBe(1); + }); +}); diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index a1abeb83a..f625263fd 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,5 +1,5 @@ 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 @@ -133,21 +133,14 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { // if the event is not linked, we try and maintain gaps if (lastEntry !== null) { - const timeFromPrevious: number = getTimeFromPrevious( - currentEntry.timeStart, - lastEntry.timeStart, - lastEntry.timeEnd, - lastEntry.duration, - ); - // when applying negative delays, we need to unlink the event // if the previous event was fully consumed by the delay if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) { shouldUnlink = true; } - if (timeFromPrevious > 0) { - delayValue = Math.max(delayValue - timeFromPrevious, 0); + if (currentEntry.gap > 0) { + delayValue = Math.max(delayValue - currentEntry.gap, 0); } if (delayValue === 0) { diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 9ffd5c71d..954c507ce 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -24,7 +24,7 @@ import { import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.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 NormalisedRundown = Record; @@ -55,6 +55,7 @@ function setIsStale() { let totalDelay = 0; let totalDuration = 0; +let totalDays = 0; let firstStart: MaybeNumber = null; let lastEnd: MaybeNumber = null; @@ -107,6 +108,7 @@ export function generate( firstStart = null; lastEnd = null; totalDuration = 0; + totalDays = 0; totalDelay = 0; let lastEntry: PlayableEvent | null = null; @@ -117,6 +119,7 @@ export function generate( if (isOntimeEvent(currentEntry)) { currentEntry.delay = 0; + currentEntry.gap = 0; // 1. handle links - mutates updatedEvent handleLink(i, initialRundown, currentEntry, links); @@ -124,6 +127,9 @@ export function generate( // 2. handle custom fields - mutates updatedEvent handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields); + totalDays += calculateDayOffset(currentEntry, lastEntry); + currentEntry.dayOffset = totalDays; + // update rundown metadata, it only concerns playable events if (isPlayableEvent(currentEntry)) { // fist start is always the first event @@ -131,36 +137,31 @@ export function generate( firstStart = currentEntry.timeStart; } - const timeFromPrevious: number = getTimeFromPrevious( - currentEntry.timeStart, - lastEntry?.timeStart, - lastEntry?.timeEnd, - lastEntry?.duration, - ); + currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry); - if (timeFromPrevious === 0) { + if (currentEntry.gap === 0) { // event starts on previous finish, we add its duration totalDuration += currentEntry.duration; - } else if (timeFromPrevious > 0) { + } else if (currentEntry.gap > 0) { // event has a gap, we add the gap and the duration - totalDuration += timeFromPrevious + currentEntry.duration; - } else if (timeFromPrevious < 0) { + totalDuration += currentEntry.gap + currentEntry.duration; + } else if (currentEntry.gap < 0) { // there is an overlap, we remove the overlap from the duration // ensuring that the sum is not negative (ie: fully overlapped events) // 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 // we only affect positive delays (time forwards) - if (totalDelay > 0 && timeFromPrevious > 0) { - totalDelay = Math.max(totalDelay - timeFromPrevious, 0); + if (totalDelay > 0 && currentEntry.gap > 0) { + totalDelay = Math.max(totalDelay - currentEntry.gap, 0); } // current event delay is the current accumulated delay currentEntry.delay = totalDelay; // 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; } } diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts index 31d66a2d8..8f165fc81 100644 --- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts +++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts @@ -7,7 +7,7 @@ import { OntimeRundownEntry, OntimeBaseEvent, } from 'ontime-types'; -import { getLinkedTimes } from 'ontime-utils'; +import { dayInMs, getLinkedTimes } from 'ontime-utils'; /** * Get linked event @@ -147,3 +147,36 @@ export function hasChanges(existingEvent: T, newEvent (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, + previous?: Pick, +) { + // 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; +} diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 5ccd6cffd..4f7861ecf 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -37,6 +37,8 @@ describe('cellRequestFromEvent()', () => { skip: false, colour: 'red', delay: 0, + gap: 0, + dayOffset: 0, revision: 0, id: '1358', timeWarning: 0, @@ -84,6 +86,8 @@ describe('cellRequestFromEvent()', () => { colour: 'red', revision: 0, delay: 0, + gap: 0, + dayOffset: 0, id: '1358', timeWarning: 0, timeDanger: 0, @@ -132,6 +136,8 @@ describe('cellRequestFromEvent()', () => { colour: 'red', revision: 0, delay: 0, + gap: 0, + dayOffset: 0, id: '1358', timeWarning: 0, timeDanger: 0, @@ -178,6 +184,8 @@ describe('cellRequestFromEvent()', () => { skip: false, colour: 'red', delay: 0, + gap: 0, + dayOffset: 0, revision: 0, id: '1358', timeWarning: 0, @@ -212,6 +220,8 @@ describe('cellRequestFromEvent()', () => { colour: 'red', revision: 0, delay: 0, + gap: 0, + dayOffset: 0, id: '1358', timeWarning: 0, timeDanger: 0, @@ -246,6 +256,8 @@ describe('cellRequestFromEvent()', () => { colour: 'red', revision: 0, delay: 0, + gap: 0, + dayOffset: 0, id: '1358', timeWarning: 0, timeDanger: 0, diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index f76c1f73f..68b14ca40 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -384,7 +384,9 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial { + 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(); +}); diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json index c84df4205..704802e74 100644 --- a/e2e/tests/fixtures/test-db.json +++ b/e2e/tests/fixtures/test-db.json @@ -16,7 +16,9 @@ "skip": false, "note": "SF1.01", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.01", "revision": 0, "timeWarning": 120000, @@ -42,7 +44,9 @@ "skip": false, "note": "SF1.02", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.02", "revision": 0, "timeWarning": 120000, @@ -68,7 +72,9 @@ "skip": false, "note": "SF1.03", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.03", "revision": 0, "timeWarning": 120000, @@ -94,7 +100,9 @@ "skip": false, "note": "SF1.04", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.04", "revision": 0, "timeWarning": 120000, @@ -120,7 +128,9 @@ "skip": false, "note": "SF1.05", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.05", "revision": 0, "timeWarning": 120000, @@ -151,7 +161,9 @@ "skip": false, "note": "SF1.06", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.06", "revision": 0, "timeWarning": 120000, @@ -177,7 +189,9 @@ "skip": false, "note": "SF1.07", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.07", "revision": 0, "timeWarning": 120000, @@ -203,7 +217,9 @@ "skip": false, "note": "SF1.08", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.08", "revision": 0, "timeWarning": 120000, @@ -229,7 +245,9 @@ "skip": false, "note": "SF1.09", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.09", "revision": 0, "timeWarning": 120000, @@ -255,7 +273,9 @@ "skip": false, "note": "SF1.10", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.10", "revision": 0, "timeWarning": 120000, @@ -286,7 +306,9 @@ "skip": false, "note": "SF1.11", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.11", "revision": 0, "timeWarning": 120000, @@ -312,7 +334,9 @@ "skip": false, "note": "SF1.12", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.12", "revision": 0, "timeWarning": 120000, @@ -338,7 +362,9 @@ "skip": false, "note": "SF1.13", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.13", "revision": 0, "timeWarning": 120000, @@ -364,7 +390,9 @@ "skip": false, "note": "SF1.14", "colour": "", - "delay": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, "cue": "SF1.14", "revision": 0, "timeWarning": 120000, diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 02f1ce225..f2b2921a9 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -39,6 +39,8 @@ export type OntimeEvent = OntimeBaseEvent & { colour: string; revision: number; delay: number; // calculated at runtime + dayOffset: number; // calculated at runtime + gap: number; // calculated at runtime timeWarning: number; timeDanger: number; custom: EventCustomFields; diff --git a/packages/utils/src/date-utils/checkIsNextDay.test.ts b/packages/utils/src/date-utils/checkIsNextDay.test.ts index 8a0a7782a..9abbee852 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.test.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.test.ts @@ -2,66 +2,51 @@ import { checkIsNextDay } from './checkIsNextDay'; import { MILLIS_PER_HOUR } from './conversionUtils'; describe('checkIsNextDay', () => { - it('returns false if the previous event duration is 0', () => { - const previousStart = 0; - const previousDuration = 0; - const timeStart = 0; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy(); + it('returns false if there is no previous event', () => { + const current = { timeStart: 0, dayOffset: 0 }; + const previous = undefined; + expect(checkIsNextDay(current, previous)).toBeFalsy(); }); it('returns false if event starts after one before', () => { - const previousStart = 10; - const previousDuration = 2; - const timeStart = 11; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy(); + const current = { timeStart: 11, dayOffset: 0 }; + const previous = { timeStart: 10, duration: 2, dayOffset: 0 }; + expect(checkIsNextDay(current, previous)).toBeFalsy(); }); it('returns true if event starts after one before', () => { - const previousStart = 10; - const previousDuration = 2; - const timeStart = 9; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy(); + const current = { timeStart: 9, dayOffset: 1 }; + const previous = { timeStart: 10, duration: 2, dayOffset: 0 }; + expect(checkIsNextDay(current, previous)).toBeTruthy(); }); it('returns true if event starts at the same time as one before', () => { - const previousStart = 10; - const previousDuration = 2; - const timeStart = 10; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy(); + const current = { timeStart: 10, dayOffset: 1 }; + const previous = { timeStart: 10, duration: 2, dayOffset: 0 }; + expect(checkIsNextDay(current, previous)).toBeTruthy(); }); it('should account for an event that crossed midnight', () => { - const previousStart = 20 * MILLIS_PER_HOUR; - const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00 - const timeStart = 1 * MILLIS_PER_HOUR; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy(); + const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00 + expect(checkIsNextDay(current, previous)).toBeFalsy(); }); it('should account for an event that crossed midnight and there is a gap', () => { - const previousStart = 23 * MILLIS_PER_HOUR; - const timeStart = 2 * MILLIS_PER_HOUR; - const previousDuration = 2 * MILLIS_PER_HOUR; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy(); + const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00 + expect(checkIsNextDay(current, previous)).toBeFalsy(); }); it('should account for an event that crossed midnight with no overlaps', () => { - const previousStart = 20 * MILLIS_PER_HOUR; - const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00 - const timeStart = 19 * MILLIS_PER_HOUR; - expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy(); + const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00 + expect(checkIsNextDay(current, previous)).toBeFalsy(); }); it('should account for an event that finishes exactly at midnight', () => { - const previousStart = 23 * MILLIS_PER_HOUR; - const previousDuration = 1 * MILLIS_PER_HOUR; - const timeStart = 2 * MILLIS_PER_HOUR; - 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(); + const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00 + expect(checkIsNextDay(current, previous)).toBeTruthy(); }); }); diff --git a/packages/utils/src/date-utils/checkIsNextDay.ts b/packages/utils/src/date-utils/checkIsNextDay.ts index e6a9732f3..71a629ba6 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.ts @@ -1,41 +1,27 @@ +import type { OntimeEvent } from 'ontime-types'; + import { dayInMs } from './conversionUtils.js'; /** * 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 { - if (previousDuration === 0) { +export function checkIsNextDay( + current: Pick, + previous?: Pick, +): boolean { + if (!previous) { return false; } - const cappedStart = previousStart % dayInMs; - if (timeStart <= cappedStart) { - const normalisedPreviousEnd = cappedStart + previousDuration; - if (normalisedPreviousEnd === dayInMs) { - return true; - } - // handle exception for an event that finishes exactly at midnight - if (normalisedPreviousEnd > dayInMs) { - return false; - } - return true; + // if the day offsets are the same it can't be the next day + if (current.dayOffset <= previous.dayOffset) { + return false; } - 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; } diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts b/packages/utils/src/date-utils/getTimeFromPrevious.test.ts index 4fc37443b..a8633a2e0 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts +++ b/packages/utils/src/date-utils/getTimeFromPrevious.test.ts @@ -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'; describe('getTimeFromPrevious', () => { 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 - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + expect( + 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', () => { - const previousStart = 10; - const previousEnd = 12; - const previousDuration = 2; - const currentStart = 11; - const expected = -(previousEnd - currentStart); - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + const expected = -1; + expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe( + expected, + ); }); it('accounts for events that are fully contained', () => { - const previousStart = 8; - const previousEnd = 16; - const previousDuration = 8; - const currentStart = 10; - const expected = -(previousEnd - currentStart); - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + const expected = -6; + expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe( + expected, + ); }); it('fully overlapping events are the next day', () => { - const previousStart = 10 * MILLIS_PER_HOUR; - const previousEnd = 12 * MILLIS_PER_HOUR; - const previousDuration = previousEnd - previousStart; - const currentStart = 10 * MILLIS_PER_HOUR; - const expected = dayInMs - previousDuration; - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + const expected = dayInMs - 2 * MILLIS_PER_HOUR; + expect( + getTimeFromPrevious( + { timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 }, + { timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }, + ), + ).toBe(expected); }); 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); - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + expect( + 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', () => { - 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); - - expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected); + expect( + getTimeFromPrevious( + { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 }, + { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }, + ), + ).toBe(expected); }); }); diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.ts b/packages/utils/src/date-utils/getTimeFromPrevious.ts index 7cdd521dd..f9227c18a 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.ts +++ b/packages/utils/src/date-utils/getTimeFromPrevious.ts @@ -1,45 +1,33 @@ -import { checkIsNextDay } from './checkIsNextDay.js'; +import type { OntimeEvent } from 'ontime-types'; + import { dayInMs } from './conversionUtils.js'; /** - * Utility returns the time elapsed (gap or overlap) from the previous - * It uses deconstructed parameters to simplify implementation in UI + * Utility returns the gap from previous event */ export function getTimeFromPrevious( - currentStart: number, - previousStart?: number, - previousEnd?: number, - previousDuration?: number, + current: Pick, + previous?: Pick, ): number { // there is no previous event - if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) { + if (!previous) { return 0; } + const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs; + const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs; + // event is linked to previous - if (currentStart === previousEnd) { + if (normalisedCurrentStart === normalisedPreviousEnd) { 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 - if (currentStart > previousEnd) { + if (normalisedCurrentStart > normalisedPreviousEnd) { // time from previous is difference between start and previous end - return currentStart - previousEnd; + return normalisedCurrentStart - normalisedPreviousEnd; } // event overlaps with previous - const overlap = previousEnd - currentStart; - 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; + return normalisedCurrentStart - normalisedPreviousEnd; } diff --git a/packages/utils/src/date-utils/isNewLatest.test.ts b/packages/utils/src/date-utils/isNewLatest.test.ts index c7809d6a6..3dbef16b3 100644 --- a/packages/utils/src/date-utils/isNewLatest.test.ts +++ b/packages/utils/src/date-utils/isNewLatest.test.ts @@ -1,48 +1,52 @@ -import { MILLIS_PER_HOUR } from './conversionUtils'; +import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils'; import { isNewLatest } from './isNewLatest'; describe('isNewLatest', () => { 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', () => { - const nowStart = 10 * MILLIS_PER_HOUR; - const nowEnd = 11 * MILLIS_PER_HOUR; - const previousStart = 9 * MILLIS_PER_HOUR; - const previousEnd = 10 * MILLIS_PER_HOUR; - expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy(); + const current = { timeStart: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 }; + const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 }; + expect(isNewLatest(current, previous)).toBe(true); }); it('should be true if it starts the same day the previous finishes', () => { - const nowStart = 22 * MILLIS_PER_HOUR; - const nowEnd = 23 * MILLIS_PER_HOUR; - const previousStart = 9 * MILLIS_PER_HOUR; - const previousEnd = 20 * MILLIS_PER_HOUR; - expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy(); + const current = { timeStart: 22 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 }; + const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 }; + expect(isNewLatest(current, previous)).toBe(true); }); it('should be true if it finishes after the previous, accounting for passing midnight', () => { - const nowStart = 1 * MILLIS_PER_HOUR; - const nowEnd = 3 * MILLIS_PER_HOUR; - const previousStart = 23 * MILLIS_PER_HOUR; - const previousEnd = 2 * MILLIS_PER_HOUR; - expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy(); + const current = { timeStart: 1 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; + + expect(isNewLatest(current, previous)).toBe(true); }); it('should be true if it the next day', () => { - const nowStart = 8 * MILLIS_PER_HOUR; - const nowEnd = 10 * MILLIS_PER_HOUR; - const previousStart = 9 * MILLIS_PER_HOUR; - const previousEnd = 11 * MILLIS_PER_HOUR; - expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy(); + const current = { timeStart: 8 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; + expect(isNewLatest(current, previous)).toBe(true); }); it('should be true if it the next day (2)', () => { - const nowStart = 9 * MILLIS_PER_HOUR; - const nowEnd = 11 * MILLIS_PER_HOUR; - const previousStart = 9 * MILLIS_PER_HOUR; - const previousEnd = 11 * MILLIS_PER_HOUR; - expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy(); + const current = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 }; + const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 11 * MILLIS_PER_HOUR, dayOffset: 0 }; + expect(isNewLatest(current, previous)).toBe(true); }); }); diff --git a/packages/utils/src/date-utils/isNewLatest.ts b/packages/utils/src/date-utils/isNewLatest.ts index 3e86e1a9a..7bdcfa342 100644 --- a/packages/utils/src/date-utils/isNewLatest.ts +++ b/packages/utils/src/date-utils/isNewLatest.ts @@ -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 */ -export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean { +export function isNewLatest( + currentEvent: Pick, + previousEvent?: Pick, +) { // true if there is no previous - if (previousStart === undefined || previousEnd === undefined) { + if (!previousEvent) { return true; } - // true if it starts after the previous is finished - if (timeStart >= previousEnd) { - return true; - } + const normalisedCurrentEnd = currentEvent.timeStart + currentEvent.duration + currentEvent.dayOffset * dayInMs; + const normalisedPreviousEnd = previousEvent.timeStart + previousEvent.duration + previousEvent.dayOffset * dayInMs; - // true if it finishes later than previous - if (timeEnd > previousEnd) { - return true; - } - - // true if it is the day after - return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart); + return normalisedCurrentEnd >= normalisedPreviousEnd; }