more duration logic to server

This commit is contained in:
alex-arc
2026-08-02 22:02:52 +02:00
parent 6d5c35eedc
commit 6dbe504b23
9 changed files with 222 additions and 143 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
*/ */
+8 -18
View File
@@ -49,10 +49,10 @@ 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';
import { eventDurationMatchGroupTarget } from '../utils/time';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options of any new entries (event / delay / group) // 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 * Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event * @param eventId {EntryId} - id of the event
* @param groupId {EntryId} - id of the enclosing group
*/ */
const matchGroupDuration = useCallback( const matchGroupDuration = useCallback(
async (eventId: EntryId, groupId: EntryId) => { async (eventId: EntryId) => {
const rundown = queryClient.getQueryData<Rundown>(resolveCurrentRundownQueryKey()); const rundownId = getCurrentRundownData()?.id;
if (!rundown) return; if (!rundownId) {
const group = rundown.entries[groupId]; throw new Error('Rundown not initialised');
if (!group || !isOntimeGroup(group)) return; }
const event = rundown.entries[eventId]; await requestFitGroupTarget(rundownId, 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], [getCurrentRundownData],
); );
/** /**
@@ -1,6 +1,6 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils'; 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()', () => { describe('nowInMillis()', () => {
afterEach(() => { 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()', () => { describe('formatDuration()', () => {
it('formats durations correctly', () => { it('formats durations correctly', () => {
expect(formatDuration(0)).toBe('0m'); expect(formatDuration(0)).toBe('0m');
+1 -27
View File
@@ -1,4 +1,4 @@
import { Maybe, MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types'; import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
import { import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
@@ -197,29 +197,3 @@ export function getExpectedTimesFromExtendedEvent(
plannedEnd, 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;
}
@@ -183,7 +183,7 @@ export default function RundownEvent({
icon: TbClockPin, icon: TbClockPin,
onClick: () => { onClick: () => {
if (!parent) return; if (!parent) return;
matchGroupDuration(eventId, parent); matchGroupDuration(eventId);
}, },
disabled: disabled:
!parentGroup || !parentGroup ||
@@ -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;
}