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 9386c66a4..a561e416e 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -49,6 +49,7 @@ import { requestEventSwap, requestGroupEntries, requestUngroup, + requestFitGroupTarget, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; import { useEditorSettings } from '../stores/editorSettings'; @@ -466,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { return previousEnd; } }, - [getCurrentRundownData, updateEntryMutation, queryClient], + [getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey], + ); + + /** + * Updates time of existing event so it satisfies the group target duration + * @param eventId {EntryId} - id of the event + */ + const matchGroupDuration = useCallback( + async (eventId: EntryId) => { + const rundownId = getCurrentRundownData()?.id; + if (!rundownId) { + throw new Error('Rundown not initialised'); + } + + try { + await requestFitGroupTarget(rundownId, eventId); + } catch (error) { + logAxiosError('Error updating event', error); + } + }, + [getCurrentRundownData], ); /** @@ -1009,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { swapEvents, updateEntry, updateTimer, + matchGroupDuration, }), [ addEntry, @@ -1026,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { swapEvents, updateEntry, updateTimer, + matchGroupDuration, ], ); } diff --git a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx index f1508cd93..94b4e831e 100644 --- a/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/GroupEditor.tsx @@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
Plan offset diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index aea2c4ef2..2c0428e92 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -1,6 +1,6 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types'; +import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types'; import { isPlaybackActive } from 'ontime-utils'; import { MouseEvent, useEffect, useRef } from 'react'; import { @@ -13,9 +13,10 @@ import { IoTrash, IoUnlink, } from 'react-icons/io5'; -import { TbFlagFilled, TbListNumbers } from 'react-icons/tb'; +import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; +import { useEntry } from '../../../common/hooks-query/useRundown'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; @@ -102,7 +103,10 @@ export default function RundownEvent({ const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId); const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen); - const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext(); + const parentGroup = useEntry(parent) as Maybe; + + const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } = + useEntryActionsContext(); const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId)); const unselect = useEventSelection((state) => state.unselect); @@ -114,6 +118,15 @@ export default function RundownEvent({ const handleRef = useRef(null); + const [enableMatchDuration, groupTargetDurationDescription] = (() => { + if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration) + return [false, '']; + const { targetDuration, duration } = parentGroup; + return targetDuration > duration + ? [true, 'Increase event duration to fit the group target'] + : [true, 'Decrease event duration to fit the group target']; + })(); + const [onContextMenu] = useContextMenu(() => selectedEvents.size > 1 ? [ @@ -172,6 +185,17 @@ export default function RundownEvent({ updateEntry({ id: eventId, flag: !flag }); }, }, + { + type: 'item', + label: 'Match Group Target Duration', + description: groupTargetDurationDescription, + icon: TbClockPin, + onClick: () => { + if (!parent) return; + matchGroupDuration(eventId); + }, + disabled: !enableMatchDuration, + }, { type: 'divider' }, { type: 'item', diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss index ad3e094cf..d26124860 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.module.scss @@ -74,42 +74,36 @@ .metaLabel { color: $muted-gray; font-size: calc(1rem - 3px); + display: flex; + align-items: center; + gap: 0.25rem; } } -.strike { - text-decoration: wavy underline; - margin-right: 0.25rem; - color: $ui-white; -} - .duration { display: flex; align-items: center; gap: 0.25rem; + color: $ui-white; + + &.warning { + .strike { + // color: $playback-over; + text-decoration: wavy underline; + text-decoration-color: $playback-over; + } + .offsetLabel { + background-color: $playback-over; + } + } } .lockIcon { - opacity: 0.6; + color: $muted-gray; } -.over { - color: $playback-over; - .strike { - text-decoration-color: $playback-over; - } - .offsetLabel { - background-color: $playback-over; - } -} -.under { - color: $playback-under; - .strike { - text-decoration-color: $playback-under; - } - .offsetLabel { - background-color: $playback-under; - } +.target { + display: contents; } .drag { diff --git a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx index 69cc3f0ce..91a8ca56d 100644 --- a/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx +++ b/apps/client/src/features/rundown/rundown-group/RundownGroup.tsx @@ -2,24 +2,25 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { EntryId, OntimeGroup } from 'ontime-types'; import { MILLIS_PER_MINUTE } from 'ontime-utils'; -import { MouseEvent, useRef } from 'react'; +import { MouseEvent, useCallback, useRef } from 'react'; import { IoChevronDown, IoChevronUp, IoDuplicateOutline, IoFolderOpenOutline, - IoLockClosed, IoReorderTwo, IoTrash, + IoLockClosed, } from 'react-icons/io5'; +import { TbClockPin } from 'react-icons/tb'; import IconButton from '../../../common/components/buttons/IconButton'; import Tag from '../../../common/components/tag/Tag'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; -import { getOffsetState } from '../../../common/utils/offset'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../common/utils/time'; import TitleEditor from '../common/TitleEditor'; @@ -40,12 +41,31 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: 'use memo'; const handleRef = useRef(null); - const { clone, ungroup, deleteEntry } = useEntryActionsContext(); + const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext(); const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const selectedEvents = useEventSelection((state) => state.selectedEvents); const entryCopyId = useEntryCopy((state) => state.entryCopyId); + const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration; + + const [planOffset, offset] = (() => { + if (data.targetDuration === null) { + return [null, 0]; + } + + const offset = data.duration - data.targetDuration; + if (offset === 0) { + return [null, 0]; + } + const absOffset = Math.abs(offset); + return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset]; + })(); + + const matchDuration = useCallback(() => { + updateEntry({ id: data.id, targetDuration: data.duration }); + }, [data.duration, data.id, updateEntry]); + const [onContextMenu] = useContextMenu(() => [ { type: 'item', @@ -62,6 +82,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: disabled: data.entries.length === 0, }, { type: 'divider' }, + { + type: 'item', + label: 'Match Content Duration', + icon: TbClockPin, + onClick: matchDuration, + disabled: isDurationMatching, + description: + offset > 0 + ? "Increase group target duration to match it's contents" + : "Decrease group target duration to match it's contents", + }, + { type: 'divider' }, { type: 'item', label: 'Delete Group', @@ -105,22 +137,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: const binderColours = data.colour && getAccessibleColour(data.colour); const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent); - const [planOffset, planOffsetLabel] = (() => { - if (data.targetDuration === null) { - return [null, null]; - } - - const offset = data.duration - data.targetDuration; - if (offset === 0) { - return [null, 'under']; - } - const absOffset = Math.abs(offset); - return [ - `${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, - getOffsetState(offset), - ]; - })(); - const dragStyle = { zIndex: isDragging ? 2 : 'inherit', transform: CSS.Translate.toString(transform), @@ -175,20 +191,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
End
{formatTime(data.timeEnd)}
-
-
Duration
-
- {planOffset === null ? ( - formatDuration(data.duration) - ) : ( - - {formatDuration(data.duration)} - {planOffset} - - )} - {data.targetDuration !== null && } + +
+
+ Duration + {data.targetDuration !== null && } +
+
+ {formatDuration(data.duration)} + {planOffset && {planOffset}} +
-
+
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..0e5d9b349 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,107 @@ 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); + }); + + it('returns null when duration would be over 24h', () => { + const result = eventDurationMatchGroupTarget({ + targetDuration: 30 * MILLIS_PER_HOUR, + 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..f1d9e1be9 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, @@ -30,6 +31,7 @@ import { generateId, getCueCandidate, makeString, + maxDuration, validateEndAction, validateTimerType, validateTimes, @@ -601,3 +603,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 || newDuration > maxDuration ? null : newDuration; +}