From f2913971dbb85d3f8c70d09425cb585c2c8161ad Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 7 Jul 2025 15:37:54 +0200 Subject: [PATCH] feat: milestones --- .../client/src/common/hooks/useEntryAction.ts | 4 +- .../src/features/rundown/RundownEntry.tsx | 12 ++ .../rundown/entry-editor/BlockEditor.tsx | 4 +- .../rundown/entry-editor/EventEditor.tsx | 2 +- .../rundown/entry-editor/MilestoneEditor.tsx | 77 ++++++++++ .../entry-editor/RundownEntryEditor.tsx | 51 +++++-- .../composite/EventEditorCustomFields.tsx | 10 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 15 ++ .../RundownMilestone.module.scss | 31 +++++ .../rundown-milestone/RundownMilestone.tsx | 131 ++++++++++++++++++ .../src/features/rundown/rundown.utils.ts | 7 +- .../cuesheet-table-elements/CuesheetBody.tsx | 46 +++++- .../MilestoneRow.module.scss | 17 +++ .../cuesheet-table-elements/MilestoneRow.tsx | 71 ++++++++++ .../src/api-data/rundown/rundown.parser.ts | 28 ++-- .../src/api-data/rundown/rundown.service.ts | 1 - .../src/api-data/rundown/rundown.utils.ts | 106 +++++++++++--- .../api-data/rundown/rundown.validation.ts | 2 +- apps/server/src/models/eventsDefinition.ts | 13 ++ .../BackendResponse.type.ts | 2 +- .../{OntimeEvent.type.ts => OntimeEntry.ts} | 17 +++ .../src/definitions/core/Rundown.type.ts | 6 +- .../runtime/CurrentBlockState.type.ts | 2 +- .../definitions/runtime/RuntimeStore.type.ts | 2 +- packages/types/src/index.ts | 14 +- packages/types/src/utils/guards.ts | 16 ++- 26 files changed, 610 insertions(+), 77 deletions(-) create mode 100644 apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx create mode 100644 apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss create mode 100644 apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.module.scss create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx rename packages/types/src/definitions/core/{OntimeEvent.type.ts => OntimeEntry.ts} (74%) diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index be86d12c6..3f9ae04ea 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -232,9 +232,9 @@ export const useEntryActions = () => { * Updates existing entry */ const updateEntry = useCallback( - async (event: Partial) => { + async (entry: Partial) => { try { - await updateEntryMutation(event); + await updateEntryMutation(entry); } catch (error) { logAxiosError('Error updating event', error); } diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 8d28a7e8a..3b42db86b 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { isOntimeDelay, isOntimeEvent, + isOntimeMilestone, MaybeString, OntimeEntry, OntimeEvent, @@ -16,6 +17,7 @@ import { cloneEvent } from '../../common/utils/clone'; import RundownDelay from './rundown-delay/RundownDelay'; import RundownEvent from './rundown-event/RundownEvent'; +import RundownMilestone from './rundown-milestone/RundownMilestone'; import { useEventSelection } from './useEventSelection'; export type EventItemActions = @@ -201,6 +203,16 @@ export default function RundownEntry({ ); } else if (isOntimeDelay(data)) { return ; + } else if (isOntimeMilestone(data)) { + return ( + + ); } return null; } diff --git a/apps/client/src/features/rundown/entry-editor/BlockEditor.tsx b/apps/client/src/features/rundown/entry-editor/BlockEditor.tsx index e319f481d..d224710f5 100644 --- a/apps/client/src/features/rundown/entry-editor/BlockEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/BlockEditor.tsx @@ -13,6 +13,7 @@ import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput'; import EntryEditorCustomFields from './composite/EventEditorCustomFields'; +import EventTextArea from './composite/EventTextArea'; import EntryEditorTextInput from './composite/EventTextInput'; import style from './EntryEditor.module.scss'; @@ -132,6 +133,7 @@ export default function BlockEditor({ block }: BlockEditorProps) { +
@@ -139,7 +141,7 @@ export default function BlockEditor({ block }: BlockEditorProps) { Custom Fields {isEditor && Manage Custom Fields} - +
); diff --git a/apps/client/src/features/rundown/entry-editor/EventEditor.tsx b/apps/client/src/features/rundown/entry-editor/EventEditor.tsx index 0f7f439fb..7325a086e 100644 --- a/apps/client/src/features/rundown/entry-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/EventEditor.tsx @@ -69,7 +69,7 @@ export default function EventEditor({ event }: EventEditorProps) { Custom Fields {isEditor && Manage Custom Fields} - +
diff --git a/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx b/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx new file mode 100644 index 000000000..2c9d5a90e --- /dev/null +++ b/apps/client/src/features/rundown/entry-editor/MilestoneEditor.tsx @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; +import { OntimeMilestone } from 'ontime-types'; + +import * as Editor from '../../../common/components/editor-utils/EditorUtils'; +import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; +import Input from '../../../common/components/input/input/Input'; +import AppLink from '../../../common/components/link/app-link/AppLink'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; +import useCustomFields from '../../../common/hooks-query/useCustomFields'; + +import EntryEditorCustomFields from './composite/EventEditorCustomFields'; +import EventTextArea from './composite/EventTextArea'; +import EntryEditorTextInput from './composite/EventTextInput'; + +import style from './EntryEditor.module.scss'; + +// cue + title + colour + custom field labels +export type MilestoneEditorUpdateTextFields = 'cue' | 'title' | 'colour' | string; + +interface MilestoneEditorProps { + milestone: OntimeMilestone; +} +export default function MilestoneEditor({ milestone }: MilestoneEditorProps) { + const { data: customFields } = useCustomFields(); + const { updateEntry } = useEntryActions(); + + const handleSubmit = useCallback( + (field: MilestoneEditorUpdateTextFields, value: string) => { + // Handle custom fields + if (typeof field === 'string' && field.startsWith('custom-')) { + const fieldLabel = field.split('custom-')[1]; + updateEntry({ id: milestone.id, custom: { [fieldLabel]: value } }); + return; + } + // all other strings are text fields + return updateEntry({ id: milestone.id, [field]: value }); + }, + [milestone.id, updateEntry], + ); + + const isEditor = window.location.pathname.includes('editor'); + + return ( +
+
+ Milestone data +
+
+ Milestone ID (read only) + +
+ +
+
+ Colour + +
+ + +
+ +
+ + Custom Fields + {isEditor && Manage Custom Fields} + + +
+
+ ); +} diff --git a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx index d23cffb7d..538e64c5d 100644 --- a/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx +++ b/apps/client/src/features/rundown/entry-editor/RundownEntryEditor.tsx @@ -1,5 +1,13 @@ import { useEffect, useState } from 'react'; -import { isOntimeBlock, isOntimeDelay, OntimeBlock, OntimeEvent } from 'ontime-types'; +import { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + isOntimeMilestone, + OntimeBlock, + OntimeEvent, + OntimeMilestone, +} from 'ontime-types'; import useRundown from '../../../common/hooks-query/useRundown'; import { useEventSelection } from '../useEventSelection'; @@ -8,6 +16,7 @@ import EventEditorFooter from './composite/EventEditorFooter'; import BlockEditor from './BlockEditor'; import EventEditor from './EventEditor'; import EventEditorEmpty from './EventEditorEmpty'; +import MilestoneEditor from './MilestoneEditor'; import style from './EntryEditor.module.scss'; @@ -15,44 +24,56 @@ export default function RundownEntryEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); const { data } = useRundown(); - const [event, setEvent] = useState(null); + const [entry, setEntry] = useState(null); useEffect(() => { if (data.order.length === 0) { - setEvent(null); + setEntry(null); return; } const selectedEventId = Array.from(selectedEvents).at(0); if (!selectedEventId) { - setEvent(null); + setEntry(null); return; } const event = data.entries[selectedEventId]; if (event && !isOntimeDelay(event)) { - setEvent(event); + setEntry(event); } else { - setEvent(null); + setEntry(null); } }, [data.order, data.entries, selectedEvents]); - if (!event) { + if (!entry) { return ; } - if (isOntimeBlock(event)) { + if (isOntimeEvent(entry)) { return (
- + +
); } - return ( -
- - -
- ); + if (isOntimeMilestone(entry)) { + return ( +
+ +
+ ); + } + + if (isOntimeBlock(entry)) { + return ( +
+ +
+ ); + } + + return null; } diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx index 2577c6450..2357e2b75 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx @@ -1,5 +1,5 @@ import { CSSProperties, Fragment } from 'react'; -import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types'; +import { CustomFields, OntimeBlock, OntimeEvent, OntimeMilestone } from 'ontime-types'; import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { EventEditorUpdateFields } from '../EventEditor'; @@ -12,21 +12,21 @@ import style from '../EntryEditor.module.scss'; interface EntryEditorCustomFieldsProps { fields: CustomFields; - event: OntimeEvent | OntimeBlock; + entry: OntimeEvent | OntimeBlock | OntimeMilestone; handleSubmit: (field: EventEditorUpdateFields, value: string) => void; } export default function EntryEditorCustomFields({ fields: customFields, handleSubmit, - event, + entry, }: EntryEditorCustomFieldsProps) { return ( {Object.keys(customFields).map((fieldKey) => { - const key = `${event.id}-${fieldKey}`; + const key = `${entry.id}-${fieldKey}`; const fieldName = `custom-${fieldKey}`; - const initialValue = event.custom[fieldKey] ?? ''; + const initialValue = entry.custom[fieldKey] ?? ''; const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const labelText = customFields[fieldKey].label; diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index b88a5bcc4..0bcec3098 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -44,6 +44,16 @@ function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickA ); }; + const addMilestone = () => { + addEntry( + { type: SupportedEntry.Milestone, parent: parentBlock }, + { + lastEventId: previousEventId, + after: previousEventId, + }, + ); + }; + const addBlock = () => { if (parentBlock !== null) { return; @@ -76,6 +86,11 @@ function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickA Delay + } onClick={addMilestone}> + + Milestone + + {parentBlock === null && ( } onClick={addBlock}> diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss new file mode 100644 index 000000000..9831033c6 --- /dev/null +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.module.scss @@ -0,0 +1,31 @@ +@use '../blockMixins' as *; + +.milestone { + @include block-styling; + + margin-left: calc(2rem + 1px); // binder + border + margin-block: 0.125rem; + padding-right: 0.25rem; + background-color: color-mix(in srgb, var(--user-bg, $block-bg) 15%, transparent 85%); + + display: grid; + grid-template-columns: 2rem 8rem 1fr auto auto; + align-items: center; + height: $secondary-block-height; + gap: 0.5rem; + + &.hasCursor { + outline: 1px solid $block-cursor-color; + } +} + +.binder { + height: 100%; + display: grid; + place-content: center; + background-color: var(--user-bg, $block-bg); +} + +.drag { + @include drag-style; +} diff --git a/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx new file mode 100644 index 000000000..7795594f4 --- /dev/null +++ b/apps/client/src/features/rundown/rundown-milestone/RundownMilestone.tsx @@ -0,0 +1,131 @@ +import { MouseEvent, useCallback, useRef } from 'react'; +import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { EntryId } from 'ontime-types'; + +import Button from '../../../common/components/buttons/Button'; +import Input from '../../../common/components/input/input/Input'; +import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; +import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; +import { useEventSelection } from '../useEventSelection'; + +import style from './RundownMilestone.module.scss'; + +interface RundownMilestoneProps { + colour: string; + cue: string; + entryId: EntryId; + hasCursor: boolean; + title: string; +} + +export default function RundownMilestone({ colour, cue, entryId, hasCursor, title }: RundownMilestoneProps) { + const handleRef = useRef(null); + const { updateEntry, deleteEntry } = useEntryActions(); + const { selectedEvents, setSelectedBlock } = useEventSelection(); + + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef, + isDragging, + transform, + transition, + } = useSortable({ + id: entryId, + data: { + type: 'milestone', + }, + animateLayoutChanges: () => false, + }); + + const handleFocusClick = (event: MouseEvent) => { + event.stopPropagation(); + + // event.button === 2 is a right-click + // disable selection if the user selected events and right clicks + // so the context menu shows up + if (selectedEvents.size > 1 && event.button === 2) { + return; + } + + // UI indexes are 1 based + setSelectedBlock({ id: entryId }); + }; + + const handleUpdate = (field: 'cue' | 'title', value: string) => { + updateEntry({ id: entryId, [field]: value }); + }; + + const handleDelete = () => { + deleteEntry([entryId]); + }; + + const dragStyle = { + zIndex: isDragging ? 2 : 'inherit', + transform: CSS.Translate.toString(transform), + transition, + }; + + const binderColours = colour && getAccessibleColour(colour); + + return ( +
+
+ + + +
+ + + + +
+ ); +} + +interface MilestoneTextInputProps { + field: 'cue' | 'title'; + initialValue: string; + placeholder?: string; + submitHandler: (field: 'cue' | 'title', value: string) => void; +} + +function MilestoneTextInput({ field, initialValue, placeholder, submitHandler }: MilestoneTextInputProps) { + const ref = useRef(null); + const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); + const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnEnter: true, + }); + + return ( + + ); +} diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 080f8cf80..428c7c5d0 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -7,6 +7,7 @@ import { OntimeDelay, OntimeEntry, OntimeEvent, + OntimeMilestone, PlayableEvent, RundownEntries, SupportedEntry, @@ -83,10 +84,10 @@ function processEntry( processedData.groupColour = entry.colour; } else { // for delays and blocks, we insert the group metadata - if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) { + if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { // if the parent is not the current group, we need to update the groupId - processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent; - if ((entry as OntimeEvent | OntimeDelay).parent === null) { + processedData.groupId = (entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent; + if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent === null) { // if the entry has no parent, it cannot have a group colour processedData.groupColour = undefined; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx index 0fa7dd274..fd29beb4d 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx @@ -1,7 +1,15 @@ import { RefObject, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { RowModel, Table } from '@tanstack/react-table'; -import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types'; +import { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + isOntimeMilestone, + OntimeBlock, + OntimeEntry, + Rundown, +} from 'ontime-types'; import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { RUNDOWN } from '../../../../common/api/constants'; @@ -13,6 +21,7 @@ import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import BlockRow from './BlockRow'; import DelayRow from './DelayRow'; import EventRow from './EventRow'; +import MilestoneRow from './MilestoneRow'; import { cleanup } from './rowObserver'; interface CuesheetBodyProps { @@ -90,6 +99,41 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB } return ; } + if (isOntimeMilestone(entry)) { + if (isPast && hidePast) { + return null; + } + + let rowBgColour: string | undefined; + if (entry.colour) { + // the colour is user defined and might be invalid + const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor); + if (accessibleBackgroundColor !== null) { + rowBgColour = colourToHex({ + ...accessibleBackgroundColor, + alpha: accessibleBackgroundColor.alpha * 0.25, + }); + } + } + + let parentBgColour: string | null = null; + if (entry.parent) { + const rundown = queryClient.getQueryData(RUNDOWN); + const parentEntry = rundown?.entries[entry.parent]; + parentBgColour = (parentEntry as OntimeBlock).colour ?? null; + } + + return ( + + ); + } if (isOntimeEvent(entry)) { eventIndex++; const isSelected = key === selectedEventId; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.module.scss new file mode 100644 index 000000000..6298c5edf --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.module.scss @@ -0,0 +1,17 @@ +@import "../CuesheetTable.module.scss"; + +.milestoneRow { + background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%); + border-left: 4px solid var(--user-bg, $gray-500); + + &:hover { + outline: 1px solid $blue-500; + outline-offset: -1px; + background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%); + } + + td { + background-color: $gray-1250; + border-radius: 2px; + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx new file mode 100644 index 000000000..e3775d6ff --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx @@ -0,0 +1,71 @@ +import { IoEllipsisHorizontal } from 'react-icons/io5'; +import { useSessionStorage } from '@mantine/hooks'; +import { flexRender, Table } from '@tanstack/react-table'; +import { OntimeEntry } from 'ontime-types'; + +import IconButton from '../../../../common/components/buttons/IconButton'; +import { cx, enDash } from '../../../../common/utils/styleUtils'; +import { AppMode, sessionKeys } from '../../../../ontimeConfig'; +import { usePersistedCuesheetOptions } from '../../cuesheet.options'; + +import style from './MilestoneRow.module.scss'; + +interface MilestoneRowProps { + isPast: boolean; + parentBgColour: string | null; + rowBgColour?: string; + rowId: string; + table: Table; +} + +export default function MilestoneRow({ isPast, parentBgColour, rowBgColour, rowId, table }: MilestoneRowProps) { + const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); + const [cuesheetMode] = useSessionStorage({ + key: sessionKeys.cuesheetMode, + defaultValue: AppMode.Edit, + }); + + return ( + + {cuesheetMode === AppMode.Edit && ( + + undefined}> + + + + )} + {!hideIndexColumn && ( + + {enDash} + + )} + {table + .getRow(rowId) + .getVisibleCells() + .map((cell) => { + const canRender = + cell.column.id !== 'duration' && cell.column.id !== 'timeStart' && cell.column.id !== 'timeEnd'; + return ( + + {canRender && flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); +} diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index 79bf781cd..e42d540d1 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -4,8 +4,6 @@ import { ProjectRundowns, Rundown, OntimeEvent, - OntimeDelay, - OntimeBlock, isOntimeEvent, isOntimeDelay, isOntimeBlock, @@ -15,6 +13,7 @@ import { PlayableEvent, RundownEntries, isPlayableEvent, + isOntimeMilestone, } from 'ontime-types'; import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; @@ -22,7 +21,7 @@ import { defaultRundown } from '../../models/dataModel.js'; import { delay as delayDef } from '../../models/eventsDefinition.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; -import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js'; +import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent, createMilestone } from './rundown.utils.js'; import { RundownMetadata } from './rundown.types.js'; /** @@ -93,7 +92,7 @@ export function parseRundown( } const id = entryId; - let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; + let newEvent: OntimeEntry | null; const nestedEntryIds: string[] = []; if (isOntimeEvent(event)) { @@ -108,13 +107,17 @@ export function parseRundown( eventIndex += 1; } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; + } else if (isOntimeMilestone(event)) { + newEvent = createMilestone({ ...event, id }); + cleanupCustomFields(newEvent.custom, parsedCustomFields); } else if (isOntimeBlock(event)) { for (let i = 0; i < event.entries.length; i++) { const nestedEventId = event.entries[i]; const nestedEvent = rundown.entries[nestedEventId]; + let newNestedEvent: OntimeEntry | null = null; if (isOntimeEvent(nestedEvent)) { - const newNestedEvent = createEvent(nestedEvent, eventIndex); + newNestedEvent = createEvent(nestedEvent, eventIndex); // skip if event is invalid if (newNestedEvent == null) { emitError?.('Skipping event without payload'); @@ -123,11 +126,16 @@ export function parseRundown( cleanupCustomFields(newNestedEvent.custom, parsedCustomFields); eventIndex += 1; + } else if (isOntimeDelay(nestedEvent)) { + newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id }; + } else if (isOntimeMilestone(nestedEvent)) { + newNestedEvent = createMilestone({ ...nestedEvent, id }); + cleanupCustomFields(newNestedEvent.custom, parsedCustomFields); + } - if (newNestedEvent) { - nestedEntryIds.push(nestedEventId); - parsedRundown.entries[nestedEventId] = newNestedEvent; - } + if (newNestedEvent) { + nestedEntryIds.push(nestedEventId); + parsedRundown.entries[nestedEventId] = newNestedEvent; } } @@ -151,7 +159,7 @@ export function parseRundown( } } - console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`); + console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.flatOrder.length} entries`); return parsedRundown; } diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 991cc09f8..3872a8b2b 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -247,7 +247,6 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b throw new Error('Event not found'); } - console.log('reorder', eventFrom.id, eventTo.id, order); rundownMutation.reorder(rundown, eventFrom, eventTo, order); const { rundown: rundownResult, rundownMetadata, revision } = commit(); diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 1a509f1b7..e4695286f 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -5,11 +5,13 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, + isOntimeMilestone, OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEntry, OntimeEvent, + OntimeMilestone, Rundown, SupportedEntry, TimeStrategy, @@ -23,7 +25,12 @@ import { validateTimes, } from 'ontime-utils'; -import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; +import { + event as eventDef, + block as blockDef, + delay as delayDef, + milestone as milestoneDef, +} from '../../models/eventsDefinition.js'; import { makeString } from '../../utils/parserUtils.js'; import { RundownMetadata } from './rundown.types.js'; @@ -34,16 +41,16 @@ type CompleteEntry = ? OntimeDelay : T extends Partial ? OntimeBlock - : never; + : T extends Partial + ? OntimeMilestone + : never; /** * Generates a fully formed RundownEntry of the patch type */ -export function generateEvent | Partial | Partial>( - rundown: Rundown, - eventData: T, - afterId: EntryId | null, -): CompleteEntry { +export function generateEvent< + T extends Partial | Partial | Partial | Partial, +>(rundown: Rundown, eventData: T, afterId: EntryId | null): CompleteEntry { if (isOntimeEvent(eventData)) { return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry; } @@ -59,6 +66,10 @@ export function generateEvent | Partial; } + if (isOntimeMilestone(eventData)) { + return createMilestone({ ...eventData, id }) as CompleteEntry; + } + throw new Error('Invalid event type'); } @@ -136,25 +147,52 @@ export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial }; } +export function createMilestonePatch( + originalMilestone: OntimeMilestone, + patchMilestone: Partial, +): OntimeMilestone { + if (Object.keys(patchMilestone).length === 0) { + return originalMilestone; + } + + return { + id: originalMilestone.id, + type: SupportedEntry.Milestone, + cue: makeString(patchMilestone.cue ?? null, originalMilestone.cue), + title: makeString(patchMilestone.title, originalMilestone.title), + note: makeString(patchMilestone.note, originalMilestone.note), + colour: makeString(patchMilestone.colour, originalMilestone.colour), + revision: originalMilestone.revision, + custom: { ...originalMilestone.custom, ...patchMilestone.custom }, + parent: originalMilestone.parent, + }; +} + /** * Utility function for patching an existing event with new data * Increments the revision of the event when applying the patch */ -export function applyPatchToEntry(eventFromRundown: T, patch: Partial): T { +export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial): OntimeEntry { if (isOntimeEvent(eventFromRundown)) { - const newEvent = createEventPatch(eventFromRundown, patch as Partial); + const newEvent = createEventPatch(eventFromRundown as OntimeEvent, patch as Partial); newEvent.revision++; - return newEvent as T; + return newEvent; } if (isOntimeBlock(eventFromRundown)) { - const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial); + const newBlock = createBlockPatch(eventFromRundown as OntimeBlock, patch as Partial); newBlock.revision++; - return newBlock as T; + return newBlock; + } + + if (isOntimeMilestone(eventFromRundown)) { + const newMilestone = createMilestonePatch(eventFromRundown as OntimeMilestone, patch as Partial); + newMilestone.revision++; + return newMilestone; } // only delay is left - return { ...eventFromRundown, ...patch } as T; + return { ...eventFromRundown, ...patch } as OntimeDelay; } /** @@ -205,6 +243,27 @@ export function createBlock(patch?: Partial): OntimeBlock { }; } +/** + * Creates a new milestone from an optional patch + */ +export function createMilestone(patch?: Partial): OntimeMilestone { + if (!patch) { + return { ...milestoneDef, id: generateId() }; + } + + return { + id: patch.id ?? generateId(), + type: SupportedEntry.Milestone, + cue: patch.cue ?? '', + title: patch.title ?? '', + note: patch.note ?? '', + colour: makeString(patch.colour, ''), + custom: patch.custom ?? {}, + parent: patch.parent ?? null, + revision: 0, + }; +} + /** * Function infers strategy for a patch with only partial timer data * @param end @@ -317,6 +376,15 @@ export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay { return newEntry; } +/** + * Gathers business logic for how to clone an OntimeMilestone + */ +export function cloneMilestone(entry: OntimeMilestone, newId: EntryId): OntimeMilestone { + const newEntry = structuredClone(entry); + newEntry.id = newId; + return newEntry; +} + /** * Gathers business logic for how to clone an OntimeBlock */ @@ -333,13 +401,15 @@ export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock { /** * Receives an entry and chooses the correct cloning strategy */ -export function cloneEntry(entry: T, newId: EntryId): T { +export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry { if (isOntimeEvent(entry)) { - return cloneEvent(entry, newId) as T; + return cloneEvent(entry, newId); } else if (isOntimeDelay(entry)) { - return cloneDelay(entry, newId) as T; - } else if (entry.type === 'block') { - return cloneBlock(entry as OntimeBlock, newId) as T; + return cloneDelay(entry, newId); + } else if (isOntimeBlock(entry)) { + return cloneBlock(entry, newId); + } else if (isOntimeMilestone(entry)) { + return cloneMilestone(entry, newId); } throw new Error(`Unsupported entry type for cloning: ${entry}`); } diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index f42efbb77..779ff5605 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -2,7 +2,7 @@ import { body, param } from 'express-validator'; import { requestValidationFunction } from '../validation-utils/validationFunction.js'; export const rundownPostValidator = [ - body('type').isString().isIn(['event', 'delay', 'block']), + body('type').isString().isIn(['event', 'delay', 'block', 'milestone']), body('after').optional().isString(), body('before').optional().isString(), diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 92ef753e7..f03cfdb22 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -3,6 +3,7 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, + OntimeMilestone, SupportedEntry, TimeStrategy, TimerType, @@ -40,6 +41,18 @@ export const delay: Omit = { parent: null, }; +export const milestone: Omit = { + type: SupportedEntry.Milestone, + cue: '', + title: '', + note: '', + colour: '', + custom: {}, + parent: null, + // !==== RUNTIME METADATA ====! // + revision: 0, // calculated at runtime +}; + export const block: Omit = { type: SupportedEntry.Block, title: '', diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index 3247db302..7e8f31efa 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,4 +1,4 @@ -import type { OntimeEntry } from '../../definitions/core/Rundown.type.js'; +import type { OntimeEntry } from '../../definitions/core/OntimeEntry.js'; export type PatchWithId = Partial & { id: string }; diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEntry.ts similarity index 74% rename from packages/types/src/definitions/core/OntimeEvent.type.ts rename to packages/types/src/definitions/core/OntimeEntry.ts index 88d9e90e6..d008184dc 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEntry.ts @@ -6,6 +6,7 @@ export enum SupportedEntry { Event = 'event', Delay = 'delay', Block = 'block', + Milestone = 'milestone', } export type OntimeBaseEvent = { @@ -19,6 +20,18 @@ export type OntimeDelay = OntimeBaseEvent & { parent: EntryId | null; }; +export type OntimeMilestone = OntimeBaseEvent & { + type: SupportedEntry.Milestone; + cue: string; + title: string; + note: string; + colour: string; + custom: EntryCustomFields; + parent: EntryId | null; + // !==== RUNTIME METADATA ====! // + revision: number; +}; + export type OntimeBlock = OntimeBaseEvent & { type: SupportedEntry.Block; title: string; @@ -65,3 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & { export type PlayableEvent = OntimeEvent & { skip: false }; export type TimeField = 'timeStart' | 'timeEnd' | 'duration'; +export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent | OntimeMilestone; + +// we need to create a manual union type since keys cannot be used in type unions +export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock | keyof OntimeMilestone; diff --git a/packages/types/src/definitions/core/Rundown.type.ts b/packages/types/src/definitions/core/Rundown.type.ts index 1f76f38ac..46d053ad1 100644 --- a/packages/types/src/definitions/core/Rundown.type.ts +++ b/packages/types/src/definitions/core/Rundown.type.ts @@ -1,11 +1,7 @@ -import type { EntryId, OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; +import type { EntryId, OntimeEntry } from './OntimeEntry.js'; -export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent; export type RundownEntries = Record; -// we need to create a manual union type since keys cannot be used in type unions -export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock; - type RundownId = string; export type ProjectRundowns = Record; diff --git a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts index f32d8cff3..009157b82 100644 --- a/packages/types/src/definitions/runtime/CurrentBlockState.type.ts +++ b/packages/types/src/definitions/runtime/CurrentBlockState.type.ts @@ -1,5 +1,5 @@ import type { MaybeNumber } from '../../utils/utils.type.js'; -import type { EntryId } from '../core/OntimeEvent.type.js'; +import type { EntryId } from '../core/OntimeEntry.js'; export type BlockState = { id: EntryId; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 0aad41ebc..099d3f0d5 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,4 +1,4 @@ -import type { OntimeEvent } from '../core/OntimeEvent.type.js'; +import type { OntimeEvent } from '../core/OntimeEntry.js'; import type { SimpleTimerState } from './AuxTimer.type.js'; import type { BlockState } from './CurrentBlockState.type.js'; import type { MessageState } from './MessageControl.type.js'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2a7886c8d..a0597fa33 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,18 +8,15 @@ export { type OntimeBaseEvent, type OntimeDelay, type OntimeBlock, + type OntimeEntryCommonKeys, + type OntimeEntry, + type OntimeMilestone, type OntimeEvent, type PlayableEvent, type TimeField, SupportedEntry as SupportedEntry, -} from './definitions/core/OntimeEvent.type.js'; -export type { - OntimeEntryCommonKeys, - OntimeEntry, - RundownEntries, - Rundown, - ProjectRundowns, -} from './definitions/core/Rundown.type.js'; +} from './definitions/core/OntimeEntry.js'; +export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; @@ -114,6 +111,7 @@ export { isOntimeBlock, isOntimeDelay, isOntimeEvent, + isOntimeMilestone, isPlayableEvent, isKeyOfType, isOSCOutput, diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index d8ef383db..799adeaaf 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,7 +1,13 @@ import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js'; -import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; -import { SupportedEntry } from '../definitions/core/OntimeEvent.type.js'; -import type { OntimeEntry } from '../definitions/core/Rundown.type.js'; +import type { + OntimeBlock, + OntimeDelay, + OntimeEntry, + OntimeEvent, + OntimeMilestone, + PlayableEvent, +} from '../definitions/core/OntimeEntry.js'; +import { SupportedEntry } from '../definitions/core/OntimeEntry.js'; import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js'; type MaybeEvent = OntimeEntry | Partial | null | undefined; @@ -22,6 +28,10 @@ export function isOntimeBlock(event: MaybeEvent): event is OntimeBlock { return event?.type === SupportedEntry.Block; } +export function isOntimeMilestone(event: MaybeEvent): event is OntimeMilestone { + return event?.type === SupportedEntry.Milestone; +} + type AnyKeys = keyof T; export function isKeyOfType(key: PropertyKey, obj: T): key is AnyKeys {