From 6dbe504b23da1a3409fb7e9e76a25fd9c4e4c08d Mon Sep 17 00:00:00 2001 From: alex-arc Date: Sun, 2 Aug 2026 22:02:52 +0200 Subject: [PATCH] more duration logic to server --- apps/client/src/common/api/rundown.ts | 7 ++ .../client/src/common/hooks/useEntryAction.ts | 26 ++--- .../src/common/utils/__tests__/time.test.ts | 97 +----------------- apps/client/src/common/utils/time.ts | 28 +----- .../rundown/rundown-event/RundownEvent.tsx | 2 +- .../rundown/__tests__/rundown.utils.test.ts | 98 ++++++++++++++++++- .../src/api-data/rundown/rundown.router.ts | 18 ++++ .../src/api-data/rundown/rundown.service.ts | 64 ++++++++++++ .../src/api-data/rundown/rundown.utils.ts | 25 +++++ 9 files changed, 222 insertions(+), 143 deletions(-) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 66965eb20..46932257c 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -176,6 +176,13 @@ export async function postCloneEntry( return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); } +/** + * HTTP request events duration to fit inside the group target + */ +export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise> { + return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`); +} + /** * HTTP request for grouping a list of entries into a group */ diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 650ec74a6..07ff4fe6f 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -49,10 +49,10 @@ import { requestEventSwap, requestGroupEntries, requestUngroup, + requestFitGroupTarget, } 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,26 +473,16 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { /** * Updates time of existing event so it satisfies the group target duration * @param eventId {EntryId} - id of the event - * @param groupId {EntryId} - id of the enclosing group */ const matchGroupDuration = useCallback( - async (eventId: EntryId, groupId: EntryId) => { - const rundown = queryClient.getQueryData(resolveCurrentRundownQueryKey()); - if (!rundown) return; - const group = rundown.entries[groupId]; - if (!group || !isOntimeGroup(group)) return; - const event = rundown.entries[eventId]; - if (!event || !isOntimeEvent(event)) 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); + async (eventId: EntryId) => { + const rundownId = getCurrentRundownData()?.id; + if (!rundownId) { + throw new Error('Rundown not initialised'); + } + await requestFitGroupTarget(rundownId, eventId); }, - [queryClient, updateTimer, resolveCurrentRundownQueryKey], + [getCurrentRundownData], ); /** diff --git a/apps/client/src/common/utils/__tests__/time.test.ts b/apps/client/src/common/utils/__tests__/time.test.ts index 7402c9f4d..3334055c6 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, eventDurationMatchGroupTarget } from '../time'; +import { formatDuration, formatTime, nowInMillis } from '../time'; describe('nowInMillis()', () => { afterEach(() => { @@ -45,101 +45,6 @@ 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 3acfd2025..bdca9c8f7 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -1,4 +1,4 @@ -import { Maybe, MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; +import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, @@ -197,29 +197,3 @@ 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; -} diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 52551d241..0d62f34e8 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -183,7 +183,7 @@ export default function RundownEvent({ icon: TbClockPin, onClick: () => { if (!parent) return; - matchGroupDuration(eventId, parent); + matchGroupDuration(eventId); }, disabled: !parentGroup || diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index faae100e0..489a8deb8 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -8,7 +8,7 @@ import { TimerType, Trigger, } from 'ontime-types'; -import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils'; +import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils'; import { assertType } from 'vitest'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; @@ -22,6 +22,7 @@ import { makeDeepClone, mergeRundownPreservingFields, isLoadedPlayable, + eventDurationMatchGroupTarget, } from '../rundown.utils.js'; describe('test event validator', () => { @@ -610,3 +611,98 @@ describe('isLoadedPlayable()', () => { expect(isLoadedPlayable('keynote', rundown)).toBe(false); }); }); + +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); + }); +}); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 69848db76..34ba229f3 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -35,6 +35,7 @@ import { reorderEntry, swapEvents, ungroupEntries, + entryFitGroupDuration, } from './rundown.service.js'; import { normalisedToRundownArray } from './rundown.utils.js'; import { @@ -337,6 +338,23 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R } }); +/** + * Change a events duration to fit inside the group target + */ +router.post( + '/:rundownId/:id/fit-group-duration', + paramsWithId, + async (req: Request, res: Response) => { + try { + const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id); + res.status(200).send(rundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } + }, +); + /** * Deletes a list of entries by their ID */ diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 44c2f1637..b4ceb8f16 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -47,6 +47,7 @@ import { hasChanges, mergeRundownPreservingFields, isLoadedPlayable, + eventDurationMatchGroupTarget, } from './rundown.utils.js'; import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js'; @@ -447,6 +448,69 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I return rundownResult; } +/** + * Change a events duration to fit inside the group target + */ +export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise { + const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true }); + + const entry = rundown.entries[entryId]; + + if (!entry) { + throw new Error('Entry not found'); + } + + if (!isOntimeEvent(entry)) { + throw new Error('Entry must be an event'); + } + + const { parent } = entry; + if (!parent) { + throw new Error('Entry must be in a group'); + } + + const group = rundown.entries[parent]; + + if (!group) { + throw new Error('Group not found'); + } + + if (!isOntimeGroup(group)) { + throw new Error('Group is not a group'); + } + + const newDuration = eventDurationMatchGroupTarget({ + targetDuration: group.targetDuration, + groupDuration: group.duration, + eventDuration: entry.duration, + }); + + if (newDuration === null) { + throw new Error('Unable to fit a duration'); + } + + const newEnd = entry.timeStart + newDuration; + + rundownMutation.edit(rundown, { + id: entryId, + duration: newDuration, + timeEnd: newEnd, + timeStrategy: entry.timeStrategy, + }); + const { rundown: rundownResult, rundownMetadata, revision } = await commit(); + + // schedule the side effects + setImmediate(() => { + // notify runtime that rundown has changed + updateRuntimeOnChange(rundownMetadata); + + // we need to notify the timer since we might be changing a running event + notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true }); + }); + + return rundownResult; +} + /** * Groups a list of entries into a new group */ diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 01b256cd7..ee3c9284b 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -3,6 +3,7 @@ import { EntryCustomFields, EntryId, ImportedFields, + Maybe, OntimeBaseEvent, OntimeDelay, OntimeEntry, @@ -601,3 +602,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber { precision, }; } + +/** + * Adjusts an event's duration to fit inside the group target + * @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; +}