Group duration context menu utils (#1748)

This commit is contained in:
Alex Christoffer Rasmussen
2026-08-09 16:45:47 +02:00
committed by GitHub
parent ac0ef06459
commit a006331fea
10 changed files with 338 additions and 63 deletions
+7
View File
@@ -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<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/**
* HTTP request for grouping a list of entries into a group
*/
+24 -1
View File
@@ -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,
],
);
}
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
<TextLikeInput
offset={planOffsetLabel}
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
disabled
>
@@ -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<OntimeGroup>;
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 | HTMLSpanElement>(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<HTMLDivElement>(() =>
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',
@@ -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 {
@@ -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 | HTMLSpanElement>(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<HTMLDivElement>(() => [
{
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 }:
<div className={style.metaLabel}>End</div>
<div>{formatTime(data.timeEnd)}</div>
</div>
<div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div>
<div className={style.duration}>
{planOffset === null ? (
formatDuration(data.duration)
) : (
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
<Tag className={style.offsetLabel}>{planOffset}</Tag>
</span>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
<div className={style.metaEntry}>
<div className={style.metaLabel}>
Duration
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
<div className={cx([style.duration, planOffset && style.warning])}>
<span className={style.strike}>{formatDuration(data.duration)}</span>
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
</div>
</div>
</div>
</Tooltip>
</div>
</div>
</div>
@@ -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);
});
});
@@ -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<Rundown | ErrorResponse>) => {
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
*/
@@ -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<Rundown> {
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
*/
@@ -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<number>;
groupDuration: number;
eventDuration: number;
}): Maybe<number> {
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;
}