From b83d9fa3350bd9fa237307bb584427f7fc83f38b Mon Sep 17 00:00:00 2001 From: alex-arc Date: Fri, 17 Jul 2026 18:58:55 +0200 Subject: [PATCH] extract event match group logic and add unit test --- .../client/src/common/hooks/useEntryAction.ts | 13 ++- .../src/common/utils/__tests__/time.test.ts | 97 ++++++++++++++++++- apps/client/src/common/utils/time.ts | 28 +++++- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 968e6f38a..6615b53b3 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -53,6 +53,7 @@ import { } from '../api/rundown'; import { logAxiosError } from '../api/utils'; import { useEditorSettings } from '../stores/editorSettings'; +import { eventDurationMatchGroupTarget } from '../utils/time'; export type EventOptions = Partial<{ // options of any new entries (event / delay / group) @@ -473,12 +474,16 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { const rundown = queryClient.getQueryData(resolveCurrentRundownQueryKey()); if (!rundown) return; const group = rundown.entries[groupId]; - if (!group || !isOntimeGroup(group) || group.targetDuration === null) return; + if (!group || !isOntimeGroup(group)) return; const event = rundown.entries[eventId]; if (!event || !isOntimeEvent(event)) return; - const durationDiff = group.targetDuration - group.duration; - const newDuration = event.duration + durationDiff; - if (newDuration < 0) return; + + const newDuration = eventDurationMatchGroupTarget({ + targetDuration: group.targetDuration, + groupDuration: group.duration, + eventDuration: event.duration, + }); + if (!newDuration) return; updateTimer(eventId, 'duration', String(newDuration / MILLIS_PER_SECOND) + 's', false); }, [queryClient, updateTimer, resolveCurrentRundownQueryKey], diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts index 3334055c6..7402c9f4d 100644 --- a/apps/client/src/common/utils/__tests__/time.test.ts +++ b/apps/client/src/common/utils/__tests__/time.test.ts @@ -1,6 +1,6 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; -import { formatDuration, formatTime, nowInMillis } from '../time'; +import { formatDuration, formatTime, nowInMillis, eventDurationMatchGroupTarget } from '../time'; describe('nowInMillis()', () => { afterEach(() => { @@ -45,6 +45,101 @@ describe('formatTime()', () => { }); }); +describe('eventDurationMatchGroupTarget()', () => { + it('returns unchanged duration when group already matches target', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR, + groupDuration: MILLIS_PER_HOUR, + eventDuration: MILLIS_PER_MINUTE * 30, + }); + expect(result).toStrictEqual(null); + }); + + it('increases event duration when group is shorter than target', () => { + // Group is 1h short of target, so event duration increases by 1h + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR * 2, // 2h + groupDuration: MILLIS_PER_HOUR, // 1h + eventDuration: MILLIS_PER_MINUTE * 30, // 30m + }); + expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m + }); + + it('decreases event duration when group is longer than target', () => { + // Group is 30m over target, so event duration decreases by 30m + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR, // 1h + groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m + eventDuration: MILLIS_PER_MINUTE * 30, // 30m + }); + expect(result).toStrictEqual(0); + }); + + it('handles zero target duration', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: 0, + groupDuration: MILLIS_PER_HOUR, + eventDuration: MILLIS_PER_HOUR, + }); + expect(result).toStrictEqual(0); + }); + + it('handles zero group duration', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR, + groupDuration: 0, + eventDuration: MILLIS_PER_MINUTE * 30, + }); + expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); + }); + + it('handles zero event duration', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR, + groupDuration: MILLIS_PER_MINUTE * 30, + eventDuration: 0, + }); + expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30); + }); + + it('handles all zero values', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: 0, + groupDuration: 0, + eventDuration: 0, + }); + expect(result).toStrictEqual(null); + }); + + it('returns null when result would be negative', () => { + // Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration) + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_MINUTE * 30, + groupDuration: MILLIS_PER_HOUR * 2, + eventDuration: MILLIS_PER_HOUR, + }); + expect(result).toStrictEqual(null); + }); + + it('handles large durations', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: MILLIS_PER_HOUR * 24, // 24h + groupDuration: MILLIS_PER_HOUR * 12, // 12h + eventDuration: MILLIS_PER_HOUR, // 1h + }); + expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h + }); + + it('returns null when targetDuration is null', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: null, + groupDuration: MILLIS_PER_HOUR, + eventDuration: MILLIS_PER_MINUTE * 30, + }); + expect(result).toStrictEqual(null); + }); +}); + describe('formatDuration()', () => { it('formats durations correctly', () => { expect(formatDuration(0)).toBe('0m'); diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index 9990092ce..2e10184f5 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -1,4 +1,4 @@ -import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; +import { Maybe, MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, @@ -192,3 +192,29 @@ export function getExpectedTimesFromExtendedEvent( plannedEnd, }; } + +/** + * Adjusts an event's duration so the group matches a target duration. + * The difference between the target and the current group duration is + * added to (or subtracted from) the event's duration. + * @param targetDuration - The desired total duration for the group, or null + * @param groupDuration - The current total duration of all events in the group + * @param eventDuration - The current duration of the event being adjusted + * @returns The adjusted event duration, or null if targetDuration is null or + * the result would be negative + */ +export function eventDurationMatchGroupTarget({ + targetDuration, + groupDuration, + eventDuration, +}: { + targetDuration: Maybe; + groupDuration: number; + eventDuration: number; +}): Maybe { + if (targetDuration === null) return null; + if (targetDuration === groupDuration) return null; + const durationDiff = targetDuration - groupDuration; + const newDuration = eventDuration + durationDiff; + return newDuration < 0 ? null : newDuration; +}