Compare commits

...

5 Commits

Author SHA1 Message Date
alex-arc b83d9fa335 extract event match group logic and add unit test 2026-07-17 18:58:55 +02:00
alex-arc b8628e569e more comprihensive disable logic and better description + icons 2026-07-17 18:58:29 +02:00
alex-arc a4f60c702a fill Dependency List 2026-07-17 18:02:36 +02:00
arc-alex 6afa7bd122 make event hit group target duration 2026-07-17 18:02:36 +02:00
arc-alex b84f1e4650 match group target duration to actual event duration 2026-07-17 18:02:36 +02:00
7 changed files with 202 additions and 13 deletions
+29 -1
View File
@@ -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)
@@ -460,7 +461,32 @@ 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
* @param groupId {EntryId} - id of the enclosing group
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId, groupId: EntryId) => {
const rundown = queryClient.getQueryData<Rundown>(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);
},
[queryClient, updateTimer, resolveCurrentRundownQueryKey],
);
/**
@@ -1003,6 +1029,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1020,6 +1047,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
@@ -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');
+27 -1
View File
@@ -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<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 ? null : newDuration;
}
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types';
import { IoLockClosed, IoLockOpenOutline } from 'react-icons/io5';
import { TbTargetArrow, TbTarget } from 'react-icons/tb';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
@@ -37,7 +37,7 @@ export default function TargetDurationInput({ duration, targetDuration, submitHa
data-testid='lock__duration'
render={<IconButton variant='subtle-white' className={isLocked ? style.active : style.inactive} />}
>
{isLocked ? <IoLockClosed /> : <IoLockOpenOutline />}
{isLocked ? <TbTargetArrow /> : <TbTarget />}
</Tooltip>
</TimeInputGroup>
</div>
@@ -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);
@@ -172,6 +176,20 @@ export default function RundownEvent({
updateEntry({ id: eventId, flag: !flag });
},
},
{
type: 'item',
label: 'Match Group Target Duration',
description: 'Change event duration to fill the group target',
icon: TbClockPin,
onClick: () => {
if (!parent) return;
matchGroupDuration(eventId, parent);
},
disabled:
!parentGroup ||
parentGroup.targetDuration === null ||
parentGroup.duration === parentGroup.targetDuration,
},
{ type: 'divider' },
{
type: 'item',
@@ -90,7 +90,12 @@
}
.lockIcon {
opacity: 0.6;
&.inactive {
color: $muted-gray;
}
&.active {
color: $active-indicator;
}
}
.over {
@@ -2,16 +2,16 @@ 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,
} from 'react-icons/io5';
import { TbTargetArrow, TbClockPin } from 'react-icons/tb';
import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
@@ -40,12 +40,18 @@ 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 matchDuration = useCallback(() => {
updateEntry({ id: data.id, targetDuration: data.duration });
}, [data.duration, data.id, updateEntry]);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{
type: 'item',
@@ -62,6 +68,15 @@ 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: "Change group target duration to match it's contents",
},
{ type: 'divider' },
{
type: 'item',
label: 'Delete Group',
@@ -186,7 +201,9 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<Tag className={style.offsetLabel}>{planOffset}</Tag>
</span>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
{data.targetDuration !== null && (
<TbTargetArrow className={cx([style.lockIcon, isDurationMatching ? style.active : style.inactive])} />
)}
</div>
</div>
</div>