Compare commits

..

6 Commits

Author SHA1 Message Date
alex-arc 6dbe504b23 more duration logic to server 2026-08-02 22:02:52 +02:00
alex-arc 6d5c35eedc extract event match group logic and add unit test 2026-07-26 13:15:43 +02:00
alex-arc d511946e71 more comprihensive disable logic and better description + icons 2026-07-26 13:15:43 +02:00
alex-arc 3ddf61c651 fill Dependency List 2026-07-26 13:15:43 +02:00
arc-alex 50ae9640f3 make event hit group target duration 2026-07-26 13:15:43 +02:00
arc-alex 494e2a9aef match group target duration to actual event duration 2026-07-26 13:15:43 +02:00
21 changed files with 303 additions and 59 deletions
+7
View File
@@ -176,6 +176,13 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); 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 * HTTP request for grouping a list of entries into a group
*/ */
@@ -9,7 +9,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -25,7 +24,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -37,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId); const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId); const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,34 +1,26 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types'; import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress'; import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket'; import { usePlayback } from './useSocket';
/** /**
* Returns the live completion percentage (0100) of a countdown, interpolated locally. * Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/ */
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number { export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback(); const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll; const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() }); const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0); const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate = // there is only something to animate while a running timer is counting down towards 0
baseline.current.current !== current || // handle timer updates const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
if (hasAuthoritativeUpdate) { // re-anchor to the authoritative value whenever the server pushes a new timer update
// Reset during render so an event change is reflected in this very paint. useEffect(() => {
baseline.current = { current, duration, eventId, playback, at: now }; baseline.current = { current, at: performance.now() };
} }, [current, duration, playback]);
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth // while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => { useEffect(() => {
@@ -42,8 +34,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber,
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [shouldAnimate]); }, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running. // derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current; const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored; const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration); return getProgress(value, duration);
} }
+19 -1
View File
@@ -49,6 +49,7 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -466,7 +467,22 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd; 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');
}
await requestFitGroupTarget(rundownId, eventId);
},
[getCurrentRundownData],
); );
/** /**
@@ -1009,6 +1025,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1026,6 +1043,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} }
@@ -163,7 +163,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -10,13 +10,12 @@ interface StatusBarProgressProps {
} }
export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) { export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) {
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData(); const { current, duration, timeWarning, timeDanger } = useProgressData();
return ( return (
<MultiPartProgressBar <MultiPartProgressBar
now={current} now={current}
complete={duration} complete={duration}
eventId={eventId}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={timeWarning} warning={timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
@@ -1,5 +1,5 @@
import { MaybeNumber } from 'ontime-types'; 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 IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
@@ -37,7 +37,7 @@ export default function TargetDurationInput({ duration, targetDuration, submitHa
data-testid='lock__duration' data-testid='lock__duration'
render={<IconButton variant='subtle-white' className={isLocked ? style.active : style.inactive} />} render={<IconButton variant='subtle-white' className={isLocked ? style.active : style.inactive} />}
> >
{isLocked ? <IoLockClosed /> : <IoLockOpenOutline />} {isLocked ? <TbTargetArrow /> : <TbTarget />}
</Tooltip> </Tooltip>
</TimeInputGroup> </TimeInputGroup>
</div> </div>
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; 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 { isPlaybackActive } from 'ontime-utils';
import { MouseEvent, useEffect, useRef } from 'react'; import { MouseEvent, useEffect, useRef } from 'react';
import { import {
@@ -13,9 +13,10 @@ import {
IoTrash, IoTrash,
IoUnlink, IoUnlink,
} from 'react-icons/io5'; } 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 { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
@@ -102,7 +103,10 @@ export default function RundownEvent({
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId); const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen); 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 isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect); const unselect = useEventSelection((state) => state.unselect);
@@ -172,6 +176,20 @@ export default function RundownEvent({
updateEntry({ id: eventId, flag: !flag }); 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);
},
disabled:
!parentGroup ||
parentGroup.targetDuration === null ||
parentGroup.duration === parentGroup.targetDuration,
},
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'item', type: 'item',
@@ -1,13 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress'; import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useSelectedEventId, useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import style from './RundownEventProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const eventId = useSelectedEventId();
const progress = useAnimatedProgress(timer.current, timer.duration, eventId); const progress = useAnimatedProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />; return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -90,7 +90,12 @@
} }
.lockIcon { .lockIcon {
opacity: 0.6; &.inactive {
color: $muted-gray;
}
&.active {
color: $active-indicator;
}
} }
.over { .over {
@@ -2,16 +2,16 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useRef } from 'react'; import { MouseEvent, useCallback, useRef } from 'react';
import { import {
IoChevronDown, IoChevronDown,
IoChevronUp, IoChevronUp,
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo, IoReorderTwo,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbTargetArrow, TbClockPin } from 'react-icons/tb';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag'; import Tag from '../../../common/components/tag/Tag';
@@ -40,12 +40,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
'use memo'; 'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActionsContext(); const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection); const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId); 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>(() => [ const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{ {
type: 'item', type: 'item',
@@ -62,6 +68,15 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
disabled: data.entries.length === 0, disabled: data.entries.length === 0,
}, },
{ type: 'divider' }, { 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', type: 'item',
label: 'Delete Group', label: 'Delete Group',
@@ -186,7 +201,9 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<Tag className={style.offsetLabel}>{planOffset}</Tag> <Tag className={style.offsetLabel}>{planOffset}</Tag>
</span> </span>
)} )}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />} {data.targetDuration !== null && (
<TbTargetArrow className={cx([style.lockIcon, isDurationMatching ? style.active : style.inactive])} />
)}
</div> </div>
</div> </div>
</div> </div>
@@ -112,14 +112,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<BackstageClock timeformat={timeformat} /> <BackstageClock timeformat={timeformat} />
</div> </div>
{showProgress && ( {showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
eventId={selectedEventId}
/>
)}
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />} {!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
@@ -6,13 +6,12 @@ import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() { export default function CuesheetProgress() {
const { data } = useViewSettings(); const { data } = useViewSettings();
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData(); const { current, duration, timeWarning, timeDanger } = useProgressData();
return ( return (
<MultiPartProgressBar <MultiPartProgressBar
now={current} now={current}
complete={duration} complete={duration}
eventId={eventId}
normalColor={data.normalColor} normalColor={data.normalColor}
warning={timeWarning} warning={timeWarning}
warningColor={data.warningColor} warningColor={data.warningColor}
@@ -96,7 +96,6 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
@@ -2,7 +2,7 @@ import { Day } from 'ontime-types';
import { CSSProperties, RefObject } from 'react'; import { CSSProperties, RefObject } from 'react';
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress'; import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
import { useExpectedStartData, useSelectedEventId, useTimer } from '../../common/hooks/useSocket'; import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { alpha, cx } from '../../common/utils/styleUtils'; import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time'; import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider'; import { useTranslation } from '../../translation/TranslationProvider';
@@ -169,8 +169,7 @@ function TimelineEntryStatus({
/** Generates a block level progress bar */ /** Generates a block level progress bar */
function ActiveBlock() { function ActiveBlock() {
const { current, duration } = useTimer(); const { current, duration } = useTimer();
const eventId = useSelectedEventId(); const progress = useAnimatedProgress(current, duration);
const progress = useAnimatedProgress(current, duration, eventId);
return ( return (
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} /> <div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
); );
-1
View File
@@ -195,7 +195,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning} warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
@@ -8,7 +8,7 @@ import {
TimerType, TimerType,
Trigger, Trigger,
} from 'ontime-types'; } 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 { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -22,6 +22,7 @@ import {
makeDeepClone, makeDeepClone,
mergeRundownPreservingFields, mergeRundownPreservingFields,
isLoadedPlayable, isLoadedPlayable,
eventDurationMatchGroupTarget,
} from '../rundown.utils.js'; } from '../rundown.utils.js';
describe('test event validator', () => { describe('test event validator', () => {
@@ -610,3 +611,98 @@ describe('isLoadedPlayable()', () => {
expect(isLoadedPlayable('keynote', rundown)).toBe(false); 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);
});
});
@@ -35,6 +35,7 @@ import {
reorderEntry, reorderEntry,
swapEvents, swapEvents,
ungroupEntries, ungroupEntries,
entryFitGroupDuration,
} from './rundown.service.js'; } from './rundown.service.js';
import { normalisedToRundownArray } from './rundown.utils.js'; import { normalisedToRundownArray } from './rundown.utils.js';
import { 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 * Deletes a list of entries by their ID
*/ */
@@ -47,6 +47,7 @@ import {
hasChanges, hasChanges,
mergeRundownPreservingFields, mergeRundownPreservingFields,
isLoadedPlayable, isLoadedPlayable,
eventDurationMatchGroupTarget,
} from './rundown.utils.js'; } from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.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; 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 * Groups a list of entries into a new group
*/ */
@@ -3,6 +3,7 @@ import {
EntryCustomFields, EntryCustomFields,
EntryId, EntryId,
ImportedFields, ImportedFields,
Maybe,
OntimeBaseEvent, OntimeBaseEvent,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -601,3 +602,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
precision, 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 ? null : newDuration;
}