mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 905db382cd | |||
| c191786e50 | |||
| 6911e37c18 | |||
| 86b2132cdc | |||
| 25f0dba83a |
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../stores/savedViewParams';
|
||||
import { handleLinks } from '../../../utils/linkUtils';
|
||||
import IconButton from '../../buttons/IconButton';
|
||||
import Tooltip from '../../tooltip/Tooltip';
|
||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||
|
||||
import style from './ClientLink.module.scss';
|
||||
@@ -105,16 +106,20 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
|
||||
{isCustomised && (
|
||||
<span className={style.trailing}>
|
||||
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Clear saved view settings'
|
||||
title='Clear saved view settings'
|
||||
onClick={clearViewSettings}
|
||||
<Tooltip
|
||||
text='Reset to default'
|
||||
render={
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Reset to default'
|
||||
onClick={clearViewSettings}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IoCloseOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
|
||||
@@ -4,40 +4,60 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title-card__title,
|
||||
.title-card__placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
.title-card__title {
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding-right: 1em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
border-left: 1vw solid;
|
||||
|
||||
.title-card__placeholder {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
.title-card__title:empty::before {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.4em;
|
||||
padding-right: 1em;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
text-transform: uppercase;
|
||||
.title-card__secondary {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
&.md {
|
||||
.title-card__title {
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
&.lg {
|
||||
.title-card__title {
|
||||
font-size: $large-font-size;
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
.schedule__ {
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
text-transform: uppercase;
|
||||
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,60 @@
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { ExtendedEntry } from '../../utils/rundownMetadata';
|
||||
import { cx, enDash } from '../../utils/styleUtils';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
type TitleCardMainProps = {
|
||||
title?: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
}
|
||||
colour?: string;
|
||||
textAlign?: 'left' | 'right' | 'center';
|
||||
size?: 'md' | 'lg';
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type TitleCardExpectedProps = TitleCardMainProps & {
|
||||
event: ExtendedEntry<OntimeEvent>;
|
||||
expectedStart: number;
|
||||
showExpected: boolean;
|
||||
};
|
||||
|
||||
type TitleCardNoExpectedProps = TitleCardMainProps & {
|
||||
event?: undefined;
|
||||
expectedStart?: undefined;
|
||||
showExpected?: false;
|
||||
};
|
||||
|
||||
type TitleCardProps = TitleCardExpectedProps | TitleCardNoExpectedProps;
|
||||
|
||||
export default function TitleCard({
|
||||
label,
|
||||
title,
|
||||
secondary,
|
||||
className = '',
|
||||
colour = 'transparent',
|
||||
textAlign = 'left',
|
||||
size = 'md',
|
||||
placeholder = enDash,
|
||||
}: TitleCardProps) {
|
||||
'use memo';
|
||||
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className={cx(['title-card', className])} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<div className={cx(['title-card', className, size])} style={{ borderColor: colour }}>
|
||||
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
|
||||
{title === '' ? null : title}
|
||||
</span>
|
||||
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||
import Button from '../buttons/Button';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import Info from '../info/Info';
|
||||
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
const clearSavedParams = useSavedViewParams((store) => store.clear);
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
||||
@@ -36,6 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
clearSavedParams(target);
|
||||
setSearchParams(getPreservedParams());
|
||||
};
|
||||
|
||||
|
||||
@@ -139,17 +139,6 @@ export const setEventPlayback = {
|
||||
pause: () => sendSocket('pause', undefined),
|
||||
};
|
||||
|
||||
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
phase: state.timer.phase,
|
||||
addedTime: state.timer.addedTime,
|
||||
secondaryTimer: state.timer.secondaryTimer,
|
||||
current: state.timer.current,
|
||||
expectedFinish: state.timer.expectedFinish,
|
||||
startedAt: state.timer.startedAt,
|
||||
isCountToEnd: state.eventNow?.countToEnd ?? false,
|
||||
}));
|
||||
|
||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
formatFromMillis,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
} from 'ontime-utils';
|
||||
|
||||
@@ -173,28 +172,23 @@ export function getExpectedTimesFromExtendedEvent(
|
||||
) {
|
||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||
|
||||
const expectedStartState = {
|
||||
totalGap: event.totalGap,
|
||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||
...state,
|
||||
};
|
||||
|
||||
const expectedStart = getExpectedStart(
|
||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||
expectedStartState,
|
||||
{
|
||||
totalGap: event.totalGap,
|
||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||
...state,
|
||||
},
|
||||
);
|
||||
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const delayToAdd = event.countToEnd ? 0 : event.delay;
|
||||
const plannedEnd = event.timeStart + event.duration + delayToAdd;
|
||||
|
||||
// we let timeToStart go negative to allow the UI to show due timers
|
||||
const timeToStart = expectedStart - state.clock;
|
||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||
|
||||
return {
|
||||
expectedStart,
|
||||
timeToStart,
|
||||
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||
timeToStart: expectedStart - state.clock,
|
||||
expectedEnd: event.countToEnd
|
||||
? Math.max(expectedStart + event.duration, plannedEnd)
|
||||
: expectedStart + event.duration,
|
||||
plannedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<Input
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
|
||||
+51
-28
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
|
||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||
|
||||
{ value: 'message-set', label: 'Primary Message: set' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
||||
{ value: 'message-set', label: 'Primary Message' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message' },
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Timer is finished'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`)}
|
||||
options={[
|
||||
{ value: null, label: 'Select secondary source' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Next up: keynote'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'no-change' | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// no-change -> leave the current secondary source untouched
|
||||
if (value === 'no-change') {
|
||||
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
|
||||
options={[
|
||||
{ value: 'no-change', label: 'No change' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create an automation to attach triggers to'
|
||||
label='Create a trigger to run an automation'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+55
-17
@@ -1,29 +1,67 @@
|
||||
.wrapper {
|
||||
.inputShell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
background: $gray-1250;
|
||||
color: $ui-white;
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
.fluid {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: $zindex-floating;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
.input {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.positioner {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.popup {
|
||||
box-sizing: border-box;
|
||||
min-width: var(--anchor-width);
|
||||
max-width: var(--available-width);
|
||||
border: 1px solid $gray-1000;
|
||||
border-radius: $component-border-radius-md;
|
||||
background: $gray-1250;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.list {
|
||||
box-sizing: border-box;
|
||||
max-height: min(20rem, var(--available-height));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.25rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&[data-highlighted] {
|
||||
color: $ui-white;
|
||||
background: $blue-700;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedEditor {
|
||||
min-height: min(18rem, 45vh);
|
||||
font-family: monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
margin-right: auto;
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
+236
-47
@@ -1,69 +1,258 @@
|
||||
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||
import { forwardRef, useMemo, useState } from 'react';
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import type { ChangeEvent, ReactNode, Ref } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IoExpandOutline } from 'react-icons/io5';
|
||||
|
||||
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||
import { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { makeAutoCompleteList } from './templateInput.utils';
|
||||
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
|
||||
|
||||
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
|
||||
import style from './TemplateInput.module.scss';
|
||||
|
||||
interface TemplateInputProps extends InputProps {}
|
||||
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
interface TemplateEditorModalProps {
|
||||
autocompleteList: string[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
|
||||
|
||||
interface TemplateAutocompleteRootProps {
|
||||
autocomplete: TemplateAutocompleteState;
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
|
||||
onChange?.({
|
||||
target: { name, value },
|
||||
currentTarget: { name, value },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
export default function TemplateInput({
|
||||
className,
|
||||
disabled,
|
||||
fluid,
|
||||
height = 'medium',
|
||||
onChange,
|
||||
readOnly,
|
||||
ref,
|
||||
value,
|
||||
variant = 'subtle',
|
||||
...rest
|
||||
}: TemplateInputProps) {
|
||||
const { data } = useCustomFields();
|
||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const autocompleteList = useMemo(() => {
|
||||
return makeAutoCompleteList(data);
|
||||
}, [data]);
|
||||
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const updateInputValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
setInputValue(nextValue);
|
||||
emitInputChange(rest.name, nextValue, onChange);
|
||||
},
|
||||
[onChange, rest.name],
|
||||
);
|
||||
|
||||
const updateSuggestions = (value: string) => {
|
||||
const template = selectFromLastTemplate(value);
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
||||
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
|
||||
const { setCursorForValue } = autocomplete;
|
||||
|
||||
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
|
||||
useEffect(() => {
|
||||
const nextValue = value || '';
|
||||
setInputValue(nextValue);
|
||||
setCursorForValue(nextValue, nextValue.length);
|
||||
}, [setCursorForValue, value]);
|
||||
|
||||
const setInputRef = useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
inputRef.current = element;
|
||||
assignRef(ref, element);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const openExpandedEditor = () => {
|
||||
autocomplete.setShowSuggestions(false);
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
|
||||
if (event.target.value.endsWith('{')) {
|
||||
setShowSuggestions(true);
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||
setShowSuggestions(false);
|
||||
} else if (showSuggestions) {
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
}
|
||||
|
||||
onChange?.(event);
|
||||
const closeExpandedEditor = () => {
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const handleSuggestion = (value: string) => {
|
||||
setInputValue((prev) => {
|
||||
const remaining = matchRemaining(prev as string, value);
|
||||
return prev + remaining;
|
||||
});
|
||||
setShowSuggestions(false);
|
||||
const saveExpandedEditor = (nextValue: string) => {
|
||||
updateInputValue(nextValue);
|
||||
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className={style.suggestions}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
|
||||
<div className={cx([style.inputShell, fluid && style.fluid])}>
|
||||
<BaseAutocomplete.Input
|
||||
ref={setInputRef}
|
||||
className={cx([
|
||||
inputStyle.input,
|
||||
inputStyle[variant],
|
||||
inputStyle[height],
|
||||
fluid && inputStyle.fluid,
|
||||
style.input,
|
||||
className,
|
||||
])}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Expand template editor'
|
||||
className={style.expandButton}
|
||||
disabled={disabled || readOnly}
|
||||
onClick={openExpandedEditor}
|
||||
size='small'
|
||||
title='Expand template editor'
|
||||
variant='ghosted-white'
|
||||
>
|
||||
<IoExpandOutline />
|
||||
</IconButton>
|
||||
</div>
|
||||
</TemplateAutocompleteRoot>
|
||||
<TemplateEditorModal
|
||||
autocompleteList={autocompleteList}
|
||||
isOpen={isExpanded}
|
||||
onClose={closeExpandedEditor}
|
||||
onSave={saveExpandedEditor}
|
||||
value={inputValue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default TemplateInput;
|
||||
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Root
|
||||
items={autocomplete.suggestions}
|
||||
autoHighlight
|
||||
highlightItemOnHover
|
||||
mode='none'
|
||||
open={autocomplete.open}
|
||||
value={value}
|
||||
onOpenChange={autocomplete.setShowSuggestions}
|
||||
onValueChange={autocomplete.handleValueChange}
|
||||
>
|
||||
{children}
|
||||
<TemplateSuggestionPopup />
|
||||
</BaseAutocomplete.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
|
||||
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
|
||||
const { setShowSuggestions } = autocomplete;
|
||||
|
||||
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftValue(value);
|
||||
setShowSuggestions(false);
|
||||
}, [isOpen, setShowSuggestions, value]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowSuggestions(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setShowSuggestions(false);
|
||||
onSave(draftValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Edit template'
|
||||
bodyElements={
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
|
||||
<BaseAutocomplete.Input
|
||||
autoFocus
|
||||
className={style.expandedEditor}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
|
||||
/>
|
||||
</TemplateAutocompleteRoot>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSuggestionPopup() {
|
||||
return (
|
||||
<BaseAutocomplete.Portal>
|
||||
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
|
||||
<BaseAutocomplete.Popup className={style.popup}>
|
||||
<BaseAutocomplete.List className={style.list}>
|
||||
<BaseAutocomplete.Collection>
|
||||
{(suggestion: string) => (
|
||||
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
|
||||
{suggestion}
|
||||
</BaseAutocomplete.Item>
|
||||
)}
|
||||
</BaseAutocomplete.Collection>
|
||||
</BaseAutocomplete.List>
|
||||
</BaseAutocomplete.Popup>
|
||||
</BaseAutocomplete.Positioner>
|
||||
</BaseAutocomplete.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
+52
-1
@@ -1,4 +1,4 @@
|
||||
import { matchRemaining } from '../templateInput.utils';
|
||||
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
||||
|
||||
describe('matchRemaining()', () => {
|
||||
it('should return a partial string needed for autocomplete', () => {
|
||||
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
|
||||
expect(matchRemaining('test', 'test')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectActiveTemplate()', () => {
|
||||
it('returns the last unclosed template fragment', () => {
|
||||
expect(selectActiveTemplate('send {{event')).toBe('{{event');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
|
||||
});
|
||||
|
||||
it('ignores single braces and closed templates', () => {
|
||||
expect(selectActiveTemplate('send {')).toBe('');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
|
||||
});
|
||||
|
||||
it('only considers templates before the cursor', () => {
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
|
||||
});
|
||||
|
||||
it('selects a partial template when the cursor is inside a completed template', () => {
|
||||
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTemplateAtCursor()', () => {
|
||||
it('completes the active template before the cursor', () => {
|
||||
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
|
||||
value: 'send {{timer.current}} after',
|
||||
cursorIndex: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves text before and after the cursor', () => {
|
||||
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
|
||||
value: 'before {{eventNow.title}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a following template when completing between templates', () => {
|
||||
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
|
||||
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
|
||||
cursorIndex: 31,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the whole template when the cursor is inside a completed template', () => {
|
||||
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
|
||||
value: 'before {{timer.duration}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+66
-8
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
|
||||
'{{eventNext.delay}}',
|
||||
];
|
||||
|
||||
const groupStaticPropertiesNow = [
|
||||
'{{groupNow.id}}',
|
||||
'{{groupNow.title}}',
|
||||
'{{groupNow.note}}',
|
||||
'{{groupNow.colour}}',
|
||||
'{{groupNow.timeStart}}',
|
||||
'{{groupNow.timeEnd}}',
|
||||
'{{groupNow.duration}}',
|
||||
];
|
||||
|
||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||
`{{auxtimer${index}.current}}`,
|
||||
`{{auxtimer${index}.duration}}`,
|
||||
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||
...groupStaticPropertiesNow,
|
||||
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
|
||||
...staticAuxProperties(1),
|
||||
...staticAuxProperties(2),
|
||||
...staticAuxProperties(3),
|
||||
];
|
||||
}
|
||||
|
||||
interface TemplateCompletion {
|
||||
cursorIndex: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the partial string b needed to autocomplete string a
|
||||
* @example matchRemaining('te', 'test') -> 'st'
|
||||
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the last starting template in a string
|
||||
*/
|
||||
export function selectFromLastTemplate(text: string) {
|
||||
const lastBraceIndex = text.lastIndexOf('{{');
|
||||
if (lastBraceIndex !== -1) {
|
||||
return text.slice(lastBraceIndex);
|
||||
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
||||
const textBeforeCursor = text.slice(0, cursorIndex);
|
||||
const start = textBeforeCursor.lastIndexOf('{{');
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
return '';
|
||||
|
||||
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
|
||||
if (closeBeforeCursor > start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeAfterStart = text.indexOf('}}', start);
|
||||
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
|
||||
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
|
||||
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
|
||||
|
||||
return {
|
||||
end,
|
||||
start,
|
||||
template: text.slice(start, cursorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the last unclosed starting template before the cursor.
|
||||
*/
|
||||
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
|
||||
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the active template fragment before the cursor with the selected suggestion.
|
||||
*/
|
||||
export function completeTemplateAtCursor(
|
||||
text: string,
|
||||
suggestion: string,
|
||||
cursorIndex = text.length,
|
||||
): TemplateCompletion {
|
||||
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
|
||||
if (!activeTemplateRange) {
|
||||
const value = text + matchRemaining(text, suggestion);
|
||||
return { value, cursorIndex: value.length };
|
||||
}
|
||||
|
||||
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
|
||||
return {
|
||||
value,
|
||||
cursorIndex: activeTemplateRange.start + suggestion.length,
|
||||
};
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import { useCallback, useMemo, useState, type RefObject } from 'react';
|
||||
|
||||
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
|
||||
|
||||
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function getCursorIndex(element: TemplateElement | null, fallback: string) {
|
||||
return element?.selectionStart ?? fallback.length;
|
||||
}
|
||||
|
||||
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
|
||||
requestAnimationFrame(() => {
|
||||
element?.focus();
|
||||
element?.setSelectionRange(cursorIndex, cursorIndex);
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplateAutocomplete<T extends TemplateElement>(
|
||||
value: string,
|
||||
autocompleteList: string[],
|
||||
elementRef: RefObject<T | null>,
|
||||
onValueChange: (value: string) => void,
|
||||
) {
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
const activeTemplate = selectActiveTemplate(value, cursor);
|
||||
const suggestions = useMemo(() => {
|
||||
if (!activeTemplate) {
|
||||
return [];
|
||||
}
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
|
||||
}, [activeTemplate, autocompleteList]);
|
||||
|
||||
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
|
||||
setCursor(cursorIndex);
|
||||
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
|
||||
}, []);
|
||||
|
||||
const updateCursor = useCallback(() => {
|
||||
const cursorIndex = getCursorIndex(elementRef.current, value);
|
||||
setCursorForValue(value, cursorIndex);
|
||||
}, [elementRef, setCursorForValue, value]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
|
||||
if (eventDetails.reason === 'item-press') {
|
||||
eventDetails.cancel();
|
||||
const completed = completeTemplateAtCursor(value, nextValue, cursor);
|
||||
setCursorForValue(completed.value, completed.cursorIndex);
|
||||
onValueChange(completed.value);
|
||||
focusCursor(elementRef.current, completed.cursorIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
|
||||
setCursorForValue(nextValue, cursorIndex);
|
||||
onValueChange(nextValue);
|
||||
},
|
||||
[cursor, elementRef, onValueChange, setCursorForValue, value],
|
||||
);
|
||||
|
||||
return {
|
||||
handleValueChange,
|
||||
open: showSuggestions && suggestions.length > 0,
|
||||
setCursorForValue,
|
||||
setShowSuggestions,
|
||||
suggestions,
|
||||
updateCursor,
|
||||
};
|
||||
}
|
||||
@@ -73,10 +73,6 @@
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.tagOvertime {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { useTimerProgress } from '../../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
|
||||
}
|
||||
|
||||
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
'use memo';
|
||||
const timer = useTimerProgress();
|
||||
const timer = useTimer();
|
||||
|
||||
const isRolling = timer.playback === Playback.Roll;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
return (
|
||||
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus
|
||||
startedAt={timer.startedAt}
|
||||
expectedFinish={timer.expectedFinish}
|
||||
isStopped={timer.playback === Playback.Stop}
|
||||
isCountToEnd={timer.isCountToEnd}
|
||||
isOvertime={isOvertime}
|
||||
/>
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
isStopped: boolean;
|
||||
isCountToEnd: boolean;
|
||||
isOvertime: boolean;
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
|
||||
if (isStopped) {
|
||||
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finish = millisToString(expectedFinish);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
|
||||
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
|
||||
</span>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,6 @@ $skip-opacity: 0.2;
|
||||
background-color: $block-bg;
|
||||
margin-block: 0.25rem;
|
||||
overflow: initial;
|
||||
position: relative;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
@@ -36,10 +35,6 @@ $skip-opacity: 0.2;
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
&.countToEnd {
|
||||
--status-color-active-override: #{$orange-400};
|
||||
}
|
||||
|
||||
&.play {
|
||||
background-color: $active-green;
|
||||
@include declare-overrides;
|
||||
@@ -160,7 +155,7 @@ $skip-opacity: 0.2;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.warningMeta {
|
||||
.nextTag {
|
||||
font-size: 1rem;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.03px;
|
||||
|
||||
@@ -253,7 +253,6 @@ export default function RundownEvent({
|
||||
|
||||
const blockClasses = cx([
|
||||
style.rundownEvent,
|
||||
countToEnd && style.countToEnd,
|
||||
skip && style.skip,
|
||||
isPast && style.past,
|
||||
loaded && style.loaded,
|
||||
|
||||
@@ -120,8 +120,7 @@ function RundownEventInner({
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
|
||||
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
|
||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||
</div>
|
||||
<EventBlockPlayback
|
||||
eventId={eventId}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface TimeInputFlowProps {
|
||||
export default memo(TimeInputFlow);
|
||||
function TimeInputFlow({
|
||||
eventId,
|
||||
countToEnd,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
@@ -55,6 +56,10 @@ function TimeInputFlow({
|
||||
warnings.push('Over midnight');
|
||||
}
|
||||
|
||||
if (countToEnd) {
|
||||
warnings.push('Count to End');
|
||||
}
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||
|
||||
@@ -12,6 +12,7 @@ $viewer-opacity-disabled: 0.6;
|
||||
$timer-label-size: clamp(12px, 1.25vw, 20px);
|
||||
$base-font-size: clamp(15px, 1.5vw, 28px);
|
||||
$title-font-size: clamp(18px, 2.25vw, 42px);
|
||||
$large-font-size: clamp(40px, 4.5vw, 80px);
|
||||
$timer-value-size: clamp(24px, 2.5vw, 48px);
|
||||
$header-font-size: clamp(24px, 2.5vw, 48px);
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.title-card {
|
||||
// overwrite the title-card bg color so they don't stack as it is transparent
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.project-header {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { enDash } from '../../common/utils/styleUtils';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
@@ -46,9 +45,9 @@ export function getCardData(
|
||||
}
|
||||
|
||||
// if we are loaded, we show the upcoming event as next
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
|
||||
const nowSecondary = getPropertyValue(eventNow, secondarySource);
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
|
||||
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
return {
|
||||
|
||||
@@ -151,8 +151,9 @@ type ScheduleTimeProps = {
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) {
|
||||
const { timeStart, duration, delay, expectedStart, expectedEnd, countToEnd } = event;
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
|
||||
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
@@ -162,14 +163,8 @@ export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) {
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const plannedEnd = (() => {
|
||||
if (countToEnd) {
|
||||
return timeStart + event.dayOffset * dayInMs + duration;
|
||||
}
|
||||
return plannedStart + duration;
|
||||
})();
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { getExpectedStart } from 'ontime-utils';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
@@ -10,7 +11,7 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { CountdownTarget, extendEventData, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
@@ -26,15 +27,19 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const countdownEvent = extendEventData(
|
||||
subscribedEvent,
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
reportData,
|
||||
);
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
@@ -197,11 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||
};
|
||||
|
||||
export type CountdownEvent = CountdownTarget & {
|
||||
expectedStart: number;
|
||||
expectedEnd: number;
|
||||
endedAt: MaybeNumber;
|
||||
};
|
||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
||||
|
||||
/**
|
||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||
@@ -266,7 +262,7 @@ export function extendEventData(
|
||||
reportData: OntimeReport,
|
||||
): CountdownEvent {
|
||||
const { totalGap, isLinkedToLoaded } = event;
|
||||
const expectedStartState = {
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
@@ -274,9 +270,7 @@ export function extendEventData(
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
};
|
||||
const expectedStart = getExpectedStart(event, expectedStartState);
|
||||
const expectedEnd = getExpectedEnd(event, expectedStartState);
|
||||
});
|
||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, expectedEnd, endedAt };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
}
|
||||
|
||||
@@ -65,10 +65,6 @@
|
||||
/* =================== TITLES ===================*/
|
||||
|
||||
.event {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
&.now {
|
||||
grid-area: now;
|
||||
}
|
||||
|
||||
@@ -206,8 +206,24 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
{!hideCards && (
|
||||
<>
|
||||
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
|
||||
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
|
||||
{showNow && (
|
||||
<TitleCard
|
||||
className='event now'
|
||||
label='now'
|
||||
title={nowMain}
|
||||
secondary={nowSecondary}
|
||||
colour={eventNow?.colour}
|
||||
/>
|
||||
)}
|
||||
{showNext && (
|
||||
<TitleCard
|
||||
className='event next'
|
||||
label='next'
|
||||
title={nextMain}
|
||||
secondary={nextSecondary}
|
||||
colour={eventNext?.colour}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
"postinstall": "pnpm addversion",
|
||||
"dev": "cross-env PORT=4001 NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
|
||||
"dev": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
|
||||
"dev:electron": "pnpm dev",
|
||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
|
||||
"lint": "oxlint --quiet --type-aware",
|
||||
|
||||
@@ -163,5 +163,70 @@ describe('parseOutput', () => {
|
||||
secondarySource: 'secondary',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses message-secondary with a text value', () => {
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: 'hello',
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: undefined,
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: 'hello',
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'secondary',
|
||||
text: 'hello',
|
||||
});
|
||||
// an empty text is treated as no change
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: '',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'secondary',
|
||||
text: undefined,
|
||||
});
|
||||
// text can be set while clearing the secondary source
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: null,
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: null,
|
||||
text: 'hello',
|
||||
});
|
||||
expect(() =>
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 123,
|
||||
}),
|
||||
).toThrow('Unexpected payload type:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { runtimeStorePlaceholder } from 'ontime-types';
|
||||
|
||||
import * as messageService from '../../../services/message-service/message.service.js';
|
||||
import { toOntimeAction } from '../clients/ontime.client.js';
|
||||
|
||||
vi.mock('../../../services/message-service/message.service.js', () => ({
|
||||
patch: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('toOntimeAction()', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('parses templates in primary message text', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: 'Current: {{timer.current}}',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
timer: {
|
||||
...runtimeStorePlaceholder.timer,
|
||||
current: 42,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
timer: {
|
||||
text: 'Current: 42',
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses templates in secondary message text', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 'Next: {{eventNext.title}}',
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
eventNext: {
|
||||
id: 'next-event',
|
||||
type: 'event',
|
||||
cue: '2',
|
||||
title: 'Keynote',
|
||||
note: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
timerType: 'count-down',
|
||||
colour: '',
|
||||
delay: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
endAction: 'none',
|
||||
revision: 0,
|
||||
custom: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
timer: {
|
||||
secondarySource: 'secondary',
|
||||
},
|
||||
secondary: 'Next: Keynote',
|
||||
});
|
||||
});
|
||||
|
||||
it('can set secondary message text without changing the secondary source', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text: 'Next: {{eventNext.title}}',
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
eventNext: {
|
||||
id: 'next-event',
|
||||
type: 'event',
|
||||
cue: '2',
|
||||
title: 'Keynote',
|
||||
note: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
timerType: 'count-down',
|
||||
colour: '',
|
||||
delay: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
endAction: 'none',
|
||||
revision: 0,
|
||||
custom: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
secondary: 'Next: Keynote',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
|
||||
} else if (isHTTPOutput(payload)) {
|
||||
emitHTTP(payload, store);
|
||||
} else if (isOntimeAction(payload)) {
|
||||
toOntimeAction(payload);
|
||||
toOntimeAction(payload, store);
|
||||
} else {
|
||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||
}
|
||||
|
||||
@@ -225,13 +225,28 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
}
|
||||
|
||||
if (maybeOntimeAction.action === 'message-secondary') {
|
||||
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
||||
// the secondary text is optional, an empty string is treated as no change
|
||||
let text: string | undefined = undefined;
|
||||
if ('text' in maybeOntimeAction) {
|
||||
assert.isString(maybeOntimeAction.text);
|
||||
text = indeterminateText(maybeOntimeAction.text);
|
||||
}
|
||||
|
||||
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// null is used to clear the secondary source
|
||||
if (maybeOntimeAction.secondarySource === null) {
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: null,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +255,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { LogOrigin, OntimeAction } from 'ontime-types';
|
||||
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
|
||||
import * as messageService from '../../../services/message-service/message.service.js';
|
||||
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
|
||||
import { parseTemplateNested } from '../automation.utils.js';
|
||||
|
||||
export function toOntimeAction(action: OntimeAction) {
|
||||
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) {
|
||||
const actionType = action.action;
|
||||
switch (actionType) {
|
||||
// Aux timer actions
|
||||
@@ -55,18 +57,25 @@ export function toOntimeAction(action: OntimeAction) {
|
||||
case 'message-set': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
text: action.text,
|
||||
text: action.text ? parseTemplateNested(action.text, store) : action.text,
|
||||
visible: action.visible,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'message-secondary': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
});
|
||||
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
|
||||
const patch =
|
||||
action.secondarySource === undefined
|
||||
? { secondary }
|
||||
: {
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
secondary,
|
||||
};
|
||||
|
||||
messageService.patch(patch);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,11 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
import { loadRoll } from '../rollUtils.js';
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
|
||||
expect(calculatedFinish).toBe(10);
|
||||
});
|
||||
describe('on timers of type time-to-end', () => {
|
||||
it('finish time is the fixed end, ignoring added time', () => {
|
||||
it('finish time is as schedule + added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
@@ -242,57 +242,32 @@ describe('getExpectedFinish()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored: added time surfaces as offset, it does not move the finish
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
expect(calculatedFinish).toBe(40);
|
||||
});
|
||||
|
||||
it('finish time is the fixed end, ignoring pauses', () => {
|
||||
it('handles events that finish the day after', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 25,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 20, // paused 5 ago - wall clock keeps approaching the fixed end
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
});
|
||||
|
||||
it('returns the start time for count to end times which started late', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 20 * MILLIS_PER_HOUR, // 20:00:00
|
||||
timeEnd: 21 * MILLIS_PER_MINUTE, // 21:00:00
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
startedAt: 22 * MILLIS_PER_HOUR, // 22:00:00 <--------------
|
||||
startedAt: 79200000, // 22:00:00
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 20 * MILLIS_PER_HOUR, // 20:00:00
|
||||
plannedEnd: 21 * MILLIS_PER_HOUR, // 21:00:00
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
// timeEnd is numerically before startedAt for overnight events, so expectedFinish is clamped to startedAt
|
||||
expect(calculatedFinish).toBe(22 * MILLIS_PER_HOUR);
|
||||
// expected finish is not a duration but a point in time
|
||||
expect(calculatedFinish).toBe(600000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -476,7 +451,7 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('current time is the time to end, ignoring added time', () => {
|
||||
it('current time is the time to end + added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
@@ -497,9 +472,8 @@ describe('getCurrent()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// counts to the fixed end; added time surfaces as offset, not extra countdown
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(70);
|
||||
expect(current).toBe(77);
|
||||
});
|
||||
|
||||
it('handles events that finish the day after', () => {
|
||||
@@ -508,7 +482,6 @@ describe('getCurrent()', () => {
|
||||
timeStart: 79200000, // 22:00:00
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
timer: {
|
||||
@@ -519,7 +492,6 @@ describe('getCurrent()', () => {
|
||||
rundown: {
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
currentDay: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
@@ -531,35 +503,6 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(dayInMs - 79500000 + 600000);
|
||||
});
|
||||
|
||||
it('handles overnight count-to-end after midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity,
|
||||
startedAt: 23 * MILLIS_PER_HOUR,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedEnd: 1 * MILLIS_PER_HOUR,
|
||||
currentDay: 1,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(30 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('handles events that were started late', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
@@ -1130,7 +1073,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(0);
|
||||
});
|
||||
|
||||
it('with time-to-end, offset combines overtime and added time', () => {
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
eventNow: {
|
||||
@@ -1183,45 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
// overtime (400000) plus the operator's added time (-200000)
|
||||
expect(absolute).toBe(200000);
|
||||
});
|
||||
|
||||
it('with time-to-end, added time surfaces as offset', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
},
|
||||
rundown: {
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
currentDay: 0,
|
||||
},
|
||||
offset: {
|
||||
absolute: 0,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 300000, // operator added 5 minutes
|
||||
current: 1000000, // still counting down, no overtime
|
||||
duration: 3600000,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored, so the added 5 minutes shows up purely as offset
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(300000);
|
||||
expect(absolute).toBe(400000); // <--- offset is always the overtime
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Expose errors where we reach invalid states
|
||||
* used mostly in shouldCrashDev patterns
|
||||
*/
|
||||
export class InvalidStateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
import { InvalidStateError } from './generic.errors.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
@@ -25,35 +24,35 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
|
||||
* @returns {number | null} new current time or null if nothing is running
|
||||
*/
|
||||
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
// if there is a loaded event it must have started
|
||||
// either way, we have no expected finish if nothing is playing
|
||||
if (state.eventNow === null || state.timer.startedAt === null) {
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
|
||||
if (state.eventNow === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.eventNow.countToEnd) {
|
||||
// count to end events are anchored to their fixed end: added time and pauses
|
||||
// do not move the end, they surface as offset instead (see getRuntimeOffset)
|
||||
return Math.max(state.eventNow.timeEnd, state.timer.startedAt);
|
||||
const { countToEnd, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
const { clock } = state;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pausedTime = state._timer.pausedAt != null ? state.clock - state._timer.pausedAt : 0;
|
||||
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
||||
|
||||
DEV: {
|
||||
if (state.timer.duration === null) {
|
||||
throw new InvalidStateError('a running timer cannot have null duration');
|
||||
}
|
||||
if (countToEnd) {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
}
|
||||
|
||||
// handle events that finish the day after
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
||||
const expectedFinish = state.timer.startedAt + state.timer.duration + state.timer.addedTime + pausedTime;
|
||||
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
||||
if (expectedFinish > dayInMs) {
|
||||
return expectedFinish - dayInMs;
|
||||
}
|
||||
|
||||
// an event cannot finish before it started (user added too much negative time)
|
||||
return Math.max(expectedFinish, state.timer.startedAt);
|
||||
return Math.max(expectedFinish, startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,12 +74,9 @@ export function getCurrent(state: RuntimeState): number {
|
||||
const { clock } = state;
|
||||
|
||||
if (countToEnd) {
|
||||
// count to end runs to its fixed end, so added time does not stretch the countdown
|
||||
const dayOffset = state.eventNow.dayOffset ?? 0;
|
||||
const currentDay =
|
||||
state.rundown.currentDay ?? (timeStart > timeEnd && clock <= timeEnd ? dayOffset + 1 : dayOffset);
|
||||
const endDay = timeStart > timeEnd ? dayOffset + 1 : dayOffset;
|
||||
return timeEnd + endDay * dayInMs - (clock + currentDay * dayInMs);
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
}
|
||||
|
||||
if (startedAt === null) {
|
||||
@@ -184,14 +180,13 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||
|
||||
// absolute offset is difference between schedule and playback time
|
||||
// count to end is anchored to its fixed end, so it absorbs the late start (eventStartOffset)
|
||||
// and the pause (already reflected in overtime); added time surfaces here as offset
|
||||
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||
const absolute = eventStartOffset + overtime + pausedTime + addedTime;
|
||||
|
||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
|
||||
|
||||
return { absolute, relative };
|
||||
// in case of count to end, the absolute offset is just the overtime
|
||||
return countToEnd ? { absolute: overtime, relative } : { absolute, relative };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,21 +56,18 @@ const mockState = {
|
||||
} as RuntimeState;
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
getAutomation: vi.fn().mockReturnValue({
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 0,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -328,121 +325,6 @@ describe('mutation on runtimeState', () => {
|
||||
expect(newState.offset.expectedRundownEnd).toBeNull();
|
||||
});
|
||||
|
||||
test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 11 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
parent: null,
|
||||
},
|
||||
event2: {
|
||||
...mockEvent,
|
||||
id: 'event2',
|
||||
timeStart: 11 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start event1 five minutes behind schedule
|
||||
vi.setSystemTime('jan 1 10:05');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
const newState = getState();
|
||||
expect(newState.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
// without countToEnd the rundown would end at 12h + 5min, but the countToEnd
|
||||
// event absorbs the overtime so the rundown is still expected to end at 12h
|
||||
expect(newState.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('adding time to a running countToEnd event surfaces as offset and end remains fixed', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start on time
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
let state = getState();
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
|
||||
// operator adds 5 minutes
|
||||
addTime(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
state = getState();
|
||||
// the end cant move, so the added time is added to the offset
|
||||
expect(state.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('subtracting more than the remaining time does not finish a countToEnd event', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
// removing longer than the remaining time cannot finish the event since the end is fixed
|
||||
addTime(-3 * MILLIS_PER_HOUR);
|
||||
update();
|
||||
|
||||
const state = getState();
|
||||
expect(state.timer.playback).toBe(Playback.Play);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('resume restores currentDay from restore point', async () => {
|
||||
clearState();
|
||||
const mockRundown = makeRundown({
|
||||
@@ -1154,32 +1036,4 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
eventNow: rundown.entries[0],
|
||||
});
|
||||
});
|
||||
|
||||
test('a countToEnd event breaks the link chain for the events that follow it', () => {
|
||||
// chain: A (loaded) -> B (countToEnd, flagged) -> C (linked, last event)
|
||||
// the chain stays intact up to and including B, but breaks for C since it follows a countToEnd event
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
A: makeOntimeEvent({ id: 'A', parent: null, linkStart: false, countToEnd: false, gap: 0 }),
|
||||
B: makeOntimeEvent({ id: 'B', parent: null, linkStart: true, countToEnd: true, gap: 0, flag: true }),
|
||||
C: makeOntimeEvent({ id: 'C', parent: null, linkStart: true, countToEnd: false, gap: 0 }),
|
||||
},
|
||||
order: ['A', 'B', 'C'],
|
||||
});
|
||||
|
||||
const state = {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries.A,
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['A', 'B', 'C'], flags: ['B'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
|
||||
// the flag (B) is still part of the chain
|
||||
expect(state._flag).toMatchObject({ event: rundown.entries.B, isLinkedToLoaded: true });
|
||||
// the rundown end (C) follows the countToEnd event, so the chain is broken
|
||||
expect(state._end).toMatchObject({ event: rundown.entries.C, isLinkedToLoaded: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
calculateDuration,
|
||||
checkIsNow,
|
||||
dayInMs,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
getLastEventNormal,
|
||||
isPlaybackActive,
|
||||
@@ -44,7 +43,6 @@ import {
|
||||
hasCrossedMidnight,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
import { shouldCrashDev } from '../utils/development.js';
|
||||
|
||||
type ExpectedMetadata = {
|
||||
event: OntimeEvent;
|
||||
@@ -511,31 +509,23 @@ export function addTime(amount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeState.eventNow?.countToEnd) {
|
||||
// count to end is anchored to its fixed end: added time cannot move the end or finish
|
||||
// the event early, it only surfaces as offset. `current` is derived from the wall clock
|
||||
// by the update loop, so we must not bump it here.
|
||||
runtimeState.timer.addedTime += amount;
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
} else {
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime delays: over - under
|
||||
@@ -841,6 +831,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
state.offset.expectedRundownEnd = null;
|
||||
state.offset.expectedGroupEnd = null;
|
||||
state.offset.expectedFlagStart = null;
|
||||
state.offset.expectedRundownEnd = null;
|
||||
|
||||
const { offset } = state;
|
||||
const { plannedStart, actualStart } = state.rundown;
|
||||
@@ -850,11 +841,9 @@ function getExpectedTimes(state = runtimeState) {
|
||||
|
||||
if (state.groupNow) {
|
||||
const { _group } = state;
|
||||
DEV: shouldCrashDev(_group === null, 'groupNow is set but _group is null');
|
||||
|
||||
if (_group) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(event, {
|
||||
if (_group !== null) {
|
||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
const lastEventExpectedStart = getExpectedStart(lastEvent, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -863,16 +852,15 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedGroupEnd = lastEventExpectedStart + lastEvent.duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.eventFlag) {
|
||||
const { _flag } = state;
|
||||
DEV: shouldCrashDev(_flag === null, 'eventFlag is set but _flag is null');
|
||||
|
||||
if (_flag) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _flag;
|
||||
state.offset.expectedFlagStart = getExpectedStart(event, {
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -881,13 +869,13 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedFlagStart = expectedStart;
|
||||
}
|
||||
}
|
||||
|
||||
if (state._end) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
||||
|
||||
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -896,6 +884,7 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedRundownEnd = expectedStart + event.duration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,8 +927,6 @@ export function loadGroupFlagAndEnd(
|
||||
|
||||
let accumulatedGap = 0;
|
||||
let isLinkedToLoaded = true;
|
||||
// a countToEnd event absorbs overtime and breaks the chain
|
||||
let previousWasCountToEnd = false;
|
||||
|
||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||
const entry = entries[playableEventOrder[idx]];
|
||||
@@ -948,7 +935,7 @@ export function loadGroupFlagAndEnd(
|
||||
if (idx !== currentIndex) {
|
||||
// we only accumulate data after the loaded event
|
||||
accumulatedGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||
|
||||
// and the loaded event is not allowed to be the next flag
|
||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||
@@ -962,8 +949,6 @@ export function loadGroupFlagAndEnd(
|
||||
foundGroupEnd = true;
|
||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||
}
|
||||
|
||||
previousWasCountToEnd = entry.countToEnd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +111,9 @@ export type OntimeAction =
|
||||
text?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
// TODO: when setting a secondary source of type secondary we could specify a value to it
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: OntimeMessageSecondary;
|
||||
secondarySource: SecondarySource;
|
||||
secondarySource?: SecondarySource;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
||||
|
||||
// feature business logic
|
||||
|
||||
export { getExpectedStart, getExpectedEnd } from './src/date-utils/getExpected.js';
|
||||
export { getExpectedStart } from './src/date-utils/getExpectedStart.js';
|
||||
|
||||
// feature business logic - rundown
|
||||
export { checkIsNow } from './src/date-utils/checkIsNow.js';
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* Runtime context shared by the expected start/end calculations
|
||||
*/
|
||||
type ExpectedTimesState = {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number; // accumulated gap from the current event
|
||||
isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
};
|
||||
|
||||
export function getExpectedStart(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
const { timeStart, dayOffset, delay } = event;
|
||||
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
|
||||
|
||||
const absoluteDelayedStart = Math.max(0, dayOffset * dayInMs + timeStart + delay);
|
||||
const normalisedTimeStart = absoluteDelayedStart - currentDay * dayInMs;
|
||||
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
if (mode === OffsetMode.Relative) {
|
||||
relativeStartOffset = (actualStart ?? 0) + currentDay * dayInMs - (plannedStart ?? 0);
|
||||
}
|
||||
|
||||
const scheduledStartTime = normalisedTimeStart + relativeStartOffset;
|
||||
|
||||
const offsetStartTime = scheduledStartTime + offset;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetStartTime;
|
||||
}
|
||||
|
||||
const gapsCanCompensateForOffset = totalGap > offset;
|
||||
if (gapsCanCompensateForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledStartTime;
|
||||
}
|
||||
|
||||
// otherwise consume as much of the offset as possible with the gap
|
||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||
return offsetStartTimeBufferedByGaps;
|
||||
}
|
||||
|
||||
export function getExpectedEnd(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
// expected start includes the delay and any offset compensation
|
||||
const expectedStart = getExpectedStart(event, state);
|
||||
|
||||
/**
|
||||
* Count to end events are a special case
|
||||
* - the end time is always the wall clock
|
||||
*/
|
||||
if (event.countToEnd) {
|
||||
const plannedEnd = event.dayOffset * dayInMs + event.timeStart + event.duration - state.currentDay * dayInMs;
|
||||
|
||||
// count to end should finish on the planned time or on start
|
||||
return Math.max(expectedStart, plannedEnd);
|
||||
}
|
||||
|
||||
// for normal events, the expected end is when we would start + its duration
|
||||
return expectedStart + event.duration;
|
||||
}
|
||||
+1
-165
@@ -1,7 +1,7 @@
|
||||
import { Day, OffsetMode } from 'ontime-types';
|
||||
|
||||
import { MILLIS_PER_HOUR, dayInMs } from './conversionUtils';
|
||||
import { getExpectedEnd, getExpectedStart } from './getExpected';
|
||||
import { getExpectedStart } from './getExpectedStart';
|
||||
|
||||
describe('getExpectedStart()', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
@@ -99,36 +99,6 @@ describe('getExpectedStart()', () => {
|
||||
expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(110); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||
// expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(70); This should not be possible
|
||||
});
|
||||
|
||||
test('negative delay cannot move a day 0 event before the rundown start', () => {
|
||||
const testState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
offset: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 0 as Day }, testState)).toBe(0);
|
||||
expect(getExpectedStart({ timeStart: 10, delay: -5, dayOffset: 0 as Day }, testState)).toBe(5);
|
||||
});
|
||||
|
||||
test('negative delay can move a later-day event back to the previous day', () => {
|
||||
const testState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
offset: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 5);
|
||||
expect(getExpectedStart({ timeStart: 10, delay: -20, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relative offset mode', () => {
|
||||
@@ -345,137 +315,3 @@ describe('getExpectedStart()', () => {
|
||||
expect(getExpectedStart(testEvent, { ...testState, currentDay: 0 })).toBe(23 * MILLIS_PER_HOUR + 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedEnd()', () => {
|
||||
const baseState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
test('a regular event ends at its expected start plus duration', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: false,
|
||||
};
|
||||
|
||||
// on schedule
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150);
|
||||
// running 20 behind pushes the end out
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170);
|
||||
});
|
||||
|
||||
test('a countToEnd event pins to the planned end while in overtime', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150);
|
||||
});
|
||||
|
||||
test('an overnight countToEnd event returns a normalised end', () => {
|
||||
// event starts at 23:00 and counts to 01:00 the next day -> duration spans midnight
|
||||
const timeStart = 23 * MILLIS_PER_HOUR;
|
||||
const duration = 2 * MILLIS_PER_HOUR;
|
||||
const testEvent = {
|
||||
timeStart,
|
||||
duration,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
|
||||
});
|
||||
|
||||
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 20,
|
||||
dayOffset: 0 as Day,
|
||||
};
|
||||
|
||||
// events shift their schedule based on delay...
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170);
|
||||
// ... but count to end events stay pinned to the scheduled end
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 60,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// the delayed start (160) is past the fixed end (150), so the event can no longer
|
||||
// finish on time and the end follows the compromised start
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160);
|
||||
});
|
||||
|
||||
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 1 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
|
||||
// not collapse to the day-shifted start
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
|
||||
// when the running event is already on the same day, no extra day is added
|
||||
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event with negative delay can start on the previous day but keeps its fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 0,
|
||||
duration: 50,
|
||||
delay: -10,
|
||||
dayOffset: 1 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs - 10);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs + 50);
|
||||
});
|
||||
|
||||
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||
const testEvent = {
|
||||
timeStart: 100,
|
||||
duration: 50,
|
||||
delay: 0,
|
||||
dayOffset: 0 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
const relativeState = {
|
||||
...baseState,
|
||||
mode: OffsetMode.Relative,
|
||||
actualStart: 30,
|
||||
plannedStart: 0,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
||||
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* @param event the event that we are counting to
|
||||
* @param currentDay the day offset of the currently running event
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @param clock
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export function getExpectedStart(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||
state: {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
): number {
|
||||
const { timeStart, dayOffset, delay } = event;
|
||||
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
if (mode === OffsetMode.Relative) {
|
||||
relativeStartOffset = (actualStart ?? 0) + currentDay * dayInMs - (plannedStart ?? 0);
|
||||
}
|
||||
|
||||
const scheduledStartTime = normalisedTimeStart + relativeStartOffset;
|
||||
|
||||
const offsetStartTime = scheduledStartTime + offset;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetStartTime;
|
||||
}
|
||||
|
||||
const gapsCanCompensateForOffset = totalGap > offset;
|
||||
if (gapsCanCompensateForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledStartTime;
|
||||
}
|
||||
|
||||
// otherwise consume as much of the offset as possible with the gap
|
||||
const offsetStartTimeBufferedByGaps = offsetStartTime - totalGap;
|
||||
return offsetStartTimeBufferedByGaps;
|
||||
}
|
||||
Reference in New Issue
Block a user