Compare commits

..

4 Commits

Author SHA1 Message Date
Claude 650c5eb66a fix(cuesheet): keep original "Hide seconds in table" option label
Revert the label rename from the previous commit; only the behavior
scope changes (time tags only), not the option's display name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoUSFVFdmMQN5Ro86pRvBQ
2026-09-05 16:22:01 +00:00
Claude 36b05f499e fix(cuesheet): hide seconds option should not affect duration column
The "hide seconds" toggle in the Cuesheet table settings was also
stripping seconds from the Duration column. It should only apply to
the Start/End time tags, so the Duration column now always displays
its full precision (including seconds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoUSFVFdmMQN5Ro86pRvBQ
2026-09-05 16:19:56 +00:00
Carlos Valente 977b01a072 fix: keep group visible as header for the running event 2026-09-05 09:24:42 +02:00
SMPTY dd718230ad fix(client): fix PWA manifest so "Install as App" works properly (#2192) 2026-09-05 09:15:08 +02:00
61 changed files with 396 additions and 839 deletions
+4 -2
View File
@@ -7,13 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>ontime</title> <title>Ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+9 -7
View File
@@ -1,19 +1,21 @@
{ {
"name": "ontime", "name": "Ontime",
"short_name": "ontime", "short_name": "Ontime",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "ontime-logo-192.png",
"type": "image/x-icon" "sizes": "192x192",
"type": "image/png"
}, },
{ {
"src": "ontime-logo.png", "src": "ontime-logo-512.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "", "display": "standalone",
"theme_color": "#121212", "theme_color": "#101010",
"background_color": "#101010" "background_color": "#101010"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

-8
View File
@@ -1,8 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
@@ -1,81 +0,0 @@
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
import { resolveTimerDisplay } from '../useSocket.utils';
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
const groupTimer = {
...runtimeStorePlaceholder.timer,
current: 25_000,
phase: TimerPhase.Default,
playback: Playback.Play,
};
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
return {
...runtimeStorePlaceholder,
timer: eventTimer,
eventNow: {
id: 'event-1',
timerType: TimerType.CountUp,
countToEnd: true,
} as RuntimeStore['eventNow'],
...patch,
};
}
describe('resolveTimerDisplay()', () => {
it('uses the event timer by default', () => {
expect(resolveTimerDisplay(makeState())).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
it('uses the group timer and display type when enabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display).toMatchObject({
time: groupTimer,
timerType: TimerType.CountDown,
countToEnd: false,
usesGroupTimer: true,
eventTimer,
eventTimerType: TimerType.CountUp,
});
});
it('ignores a group timer when the group setting is disabled', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer,
}),
);
expect(display.time).toBe(eventTimer);
expect(display.usesGroupTimer).toBe(false);
});
it('falls back entirely to the event display while group timer data is unavailable', () => {
const display = resolveTimerDisplay(
makeState({
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
groupTimer: null,
}),
);
expect(display).toMatchObject({
time: eventTimer,
timerType: TimerType.CountUp,
countToEnd: true,
usesGroupTimer: false,
});
});
});
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef>, scrollRef: RefObject<ScrollRef | null>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; followTrigger: MaybeString; // this would be an entry id or null
setScrollFlag?: (newValue: boolean) => void; getTopOffset: () => number;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset); // resolve the offset after layout, so that measured values are up to date
setScrollFlag?.(false); scrollToComponent(followRef, scrollRef, getTopOffset());
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(() => {
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { scrollToComponent(followRef, scrollRef, getTopOffset());
if (componentRef && containerRef) { }, [followRef, scrollRef, getTopOffset]);
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
+24 -35
View File
@@ -1,8 +1,7 @@
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
import { useRuntimeStore } from '../stores/runtime'; import { useRuntimeStore } from '../stores/runtime';
import { sendSocket } from '../utils/socket'; import { sendSocket } from '../utils/socket';
import { resolveTimerDisplay } from './useSocket.utils';
const createSelector = const createSelector =
<T>(selector: (state: RuntimeStore) => T) => <T>(selector: (state: RuntimeStore) => T) =>
@@ -39,19 +38,15 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'secondary', visible: state.message.timer.secondarySource === 'secondary',
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => { export const useMessagePreview = createSelector((state: RuntimeStore) => ({
const timerDisplay = resolveTimerDisplay(state); blink: state.message.timer.blink,
return { blackout: state.message.timer.blackout,
blink: state.message.timer.blink, phase: state.timer.phase,
blackout: state.message.timer.blackout, secondarySource: state.message.timer.secondarySource,
phase: timerDisplay.time.phase, showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
secondarySource: state.message.timer.secondarySource, timerType: state.eventNow?.timerType ?? null,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), countToEnd: state.eventNow?.countToEnd ?? false,
timerType: timerDisplay.timerType, }));
countToEnd: timerDisplay.countToEnd,
usesGroupTimer: timerDisplay.usesGroupTimer,
};
});
export const setMessage = { export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
@@ -235,26 +230,20 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
/* ======================= View specific subscriptions ======================= */ /* ======================= View specific subscriptions ======================= */
export const useTimerSocket = createSelector((state: RuntimeStore) => { export const useTimerSocket = createSelector((state: RuntimeStore) => ({
const timerDisplay = resolveTimerDisplay(state); eventNext: state.eventNext,
return { eventNow: state.eventNow,
eventNext: state.eventNext, message: state.message,
eventNow: state.eventNow, time: state.timer,
message: state.message, clock: state.clock,
time: timerDisplay.time, timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
eventTimer: timerDisplay.eventTimer, countToEndNow: state.eventNow?.countToEnd ?? false,
clock: state.clock, auxTimer: {
timerTypeNow: timerDisplay.timerType, aux1: state.auxtimer1.current,
eventTimerType: timerDisplay.eventTimerType, aux2: state.auxtimer2.current,
countToEndNow: timerDisplay.countToEnd, aux3: state.auxtimer3.current,
usesGroupTimer: timerDisplay.usesGroupTimer, },
auxTimer: { }));
aux1: state.auxtimer1.current,
aux2: state.auxtimer2.current,
aux3: state.auxtimer3.current,
},
};
});
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({ export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback, playback: state.timer.playback,
@@ -1,27 +0,0 @@
import { RuntimeStore, TimerType } from 'ontime-types';
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
export function resolveTimerDisplay(state: TimerDisplaySource) {
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
return {
time: state.groupTimer,
timerType: state.groupNow.timerType,
countToEnd: false,
usesGroupTimer: true,
eventTimer: state.timer,
eventTimerType,
};
}
return {
time: state.timer,
timerType: eventTimerType,
countToEnd: state.eventNow?.countToEnd ?? false,
usesGroupTimer: false,
eventTimer: state.timer,
eventTimerType,
};
}
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `ontime - ${title}`; document.title = `Ontime - ${title}`;
}, []); }, []);
} }
@@ -1,6 +1,6 @@
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types'; import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata'; import { initRundownMetadata } from '../rundownMetadata';
describe('initRundownMetadata()', () => { describe('initRundownMetadata()', () => {
it('processes nested rundown data', () => { it('processes nested rundown data', () => {
@@ -300,36 +300,3 @@ describe('initRundownMetadata()', () => {
}); });
}); });
}); });
describe('getFlatRundownMetadata()', () => {
it('exposes group timer settings on a group and its events', () => {
const group = {
id: 'group',
type: SupportedEntry.Group,
entries: ['event'],
colour: 'red',
useGroupTimer: true,
timerType: TimerType.CountUp,
} as OntimeGroup;
const event = {
id: 'event',
type: SupportedEntry.Event,
parent: group.id,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent;
const flat = getFlatRundownMetadata(
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
null,
);
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
});
});
@@ -3,11 +3,9 @@ import {
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup,
OntimeMilestone, OntimeMilestone,
PlayableEvent, PlayableEvent,
Rundown, Rundown,
TimerType,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isPlayableEvent, isPlayableEvent,
@@ -31,11 +29,7 @@ export type RundownMetadata = {
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
RundownMetadata & {
groupUsesTimer?: boolean;
groupTimerType?: TimerType;
};
export const lastMetadataKey = 'LAST'; export const lastMetadataKey = 'LAST';
@@ -71,23 +65,10 @@ export function getFlatRundownMetadata(
): ExtendedEntry[] { ): ExtendedEntry[] {
const { process } = initRundownMetadata(selectedEventId); const { process } = initRundownMetadata(selectedEventId);
const flatRundown: ExtendedEntry[] = []; const flatRundown: ExtendedEntry[] = [];
let activeGroup: OntimeGroup | null = null;
for (const id of data.flatOrder) { for (const id of data.flatOrder) {
const entry = data.entries[id]; const entry = data.entries[id];
if (isOntimeGroup(entry)) { const extendedEntry = { ...entry, ...process(entry) };
activeGroup = entry;
} else if (entry.parent !== activeGroup?.id) {
activeGroup = null;
}
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
const extendedEntry = {
...entry,
...process(entry),
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
groupTimerType: timerGroup?.timerType,
};
flatRundown.push(extendedEntry); flatRundown.push(extendedEntry);
} }
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
{!isOntimeCloud && ( {!isOntimeCloud && (
<Panel.Section> <Panel.Section>
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}> <Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
Shutdown ontime Shutdown Ontime
</Button> </Button>
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>} {!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
</Panel.Section> </Panel.Section>
@@ -27,13 +27,6 @@
border-top: 1px solid $white-7; border-top: 1px solid $white-7;
} }
.timerSource {
color: $active-indicator;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.blackout { .blackout {
display: none; display: none;
} }
@@ -1,5 +1,5 @@
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
@@ -20,8 +20,7 @@ const secondarySourceLabels: Record<string, string> = {
}; };
export default function TimerPreview() { export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } = const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
useMessagePreview();
const { data } = useViewSettings(); const { data } = useViewSettings();
const main = (() => { const main = (() => {
@@ -36,9 +35,7 @@ export default function TimerPreview() {
const secondary = (() => { const secondary = (() => {
// message is a fullscreen overlay or secondary is not active // message is a fullscreen overlay or secondary is not active
if (showTimerMessage) return null; if (showTimerMessage || !secondarySource) return null;
if (usesGroupTimer) return 'Event timer';
if (!secondarySource) return null;
// we need to check aux first since it takes priority // we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource]; return secondarySourceLabels[secondarySource];
@@ -58,7 +55,6 @@ export default function TimerPreview() {
<div className={style.preview}> <div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} /> <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}> <div className={contentClasses}>
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
<div <div
className={style.mainContent} className={style.mainContent}
data-phase={showColourOverride && phase} data-phase={showColourOverride && phase}
@@ -69,14 +65,6 @@ export default function TimerPreview() {
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>} {secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div> </div>
<div className={style.eventStatus}> <div className={style.eventStatus}>
<Tooltip
text='Timer display controlled by group'
render={<span />}
className={style.statusIcon}
data-active={usesGroupTimer}
>
<IoTimerOutline />
</Tooltip>
<Tooltip <Tooltip
text='Time type: Count down' text='Time type: Count down'
render={<span />} render={<span />}
@@ -18,6 +18,12 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+27 -14
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,7 +25,10 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; /** Keeps the running event clear of the list edge when no group header is pinned above it */
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() { export default function OperatorLoader() {
const { data, status } = useOperatorData(); const { data, status } = useOperatorData();
@@ -54,11 +57,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// The header height varies with the viewport, so measure it at scroll time.
const getTopOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Sit right under the pinned header, so it covers the previous event instead of half of it.
return header ? header.offsetHeight + 2 : edgeOffset;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: selectedOffset, getTopOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -82,15 +94,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Measure the drift from where an automated scroll would place the event.
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -186,9 +199,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<Fragment key={entry.id}> <div className={style.groupSection} key={entry.id}>
<OperatorGroup <OperatorGroup
key={entry.id} ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
@@ -239,7 +252,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</Fragment> </div>
); );
} }
return null; return null;
@@ -1,13 +1,20 @@
.group { .group {
width: 100%; width: 100%;
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
while a taller title still grows the row. */
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.4rem 0.75rem; padding: 0.25rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1,4 +1,4 @@
import { CSSProperties, memo } from 'react'; import { type CSSProperties, type Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -10,15 +10,16 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}> <div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<span className={style.title}>{title}</span> <span className={style.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -32,12 +32,6 @@
gap: 1rem; gap: 1rem;
} }
.timerDisplaySettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column { .column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,12 +1,10 @@
import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types'; import { MaybeNumber, OntimeGroup } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import Select from '../../../common/components/select/Select';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
@@ -109,38 +107,6 @@ export default function GroupEditor({ group }: GroupEditorProps) {
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} /> <EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
</div> </div>
<div className={style.column}>
<Editor.Title>Timer display</Editor.Title>
<div className={style.timerDisplaySettings}>
<div>
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='useGroupTimer'
checked={group.useGroupTimer}
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
/>
{group.useGroupTimer ? 'On' : 'Off'}
</Editor.Label>
</div>
<div>
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
<Select
id='groupTimerType'
disabled={!group.useGroupTimer}
value={group.timerType}
onValueChange={(timerType: TimerType | null) => {
if (timerType !== null) updateEntry({ id: group.id, timerType });
}}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
]}
/>
</div>
</div>
</div>
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
@@ -335,7 +335,6 @@ export default function RundownEvent({
eventIndex={eventIndex} eventIndex={eventIndex}
endAction={endAction} endAction={endAction}
timerType={timerType} timerType={timerType}
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
title={title} title={title}
note={note} note={note}
delay={delay} delay={delay}
@@ -9,7 +9,6 @@ import {
IoPlayForward, IoPlayForward,
IoPlaySkipForward, IoPlaySkipForward,
IoTime, IoTime,
IoTimerOutline,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
@@ -36,7 +35,6 @@ interface RundownEventInnerProps {
eventIndex: number; eventIndex: number;
endAction: EndAction; endAction: EndAction;
timerType: TimerType; timerType: TimerType;
groupTimerType?: TimerType;
title: string; title: string;
note: string; note: string;
delay: number; delay: number;
@@ -64,7 +62,6 @@ function RundownEventInner({
countToEnd, countToEnd,
endAction, endAction,
timerType, timerType,
groupTimerType,
title, title,
note, note,
delay, delay,
@@ -153,14 +150,6 @@ function RundownEventInner({
{loaded && <EventBlockProgressBar />} {loaded && <EventBlockProgressBar />}
</div> </div>
<div className={style.eventStatus} tabIndex={-1}> <div className={style.eventStatus} tabIndex={-1}>
{groupTimerType && (
<Tooltip
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span />}
>
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
</Tooltip>
)}
<Tooltip text={`Time type: ${timerType}`} render={<span />}> <Tooltip text={`Time type: ${timerType}`} render={<span />}>
<TimerIcon type={timerType} className={style.statusIcon} /> <TimerIcon type={timerType} className={style.statusIcon} />
</Tooltip> </Tooltip>
@@ -55,14 +55,6 @@
gap: 0.5rem; gap: 0.5rem;
} }
.timerIndicator {
display: grid;
flex: 0 0 1.5rem;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
.metaRow { .metaRow {
display: flex; display: flex;
gap: $block-clearance; // same as RundownEvent.eventTimers gap: $block-clearance; // same as RundownEvent.eventTimers
@@ -1,6 +1,6 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeGroup, TimerType } from 'ontime-types'; import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { MouseEvent, useCallback, useRef } from 'react'; import { MouseEvent, useCallback, useRef } from 'react';
import { import {
@@ -9,7 +9,6 @@ import {
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoReorderTwo, IoReorderTwo,
IoTimerOutline,
IoTrash, IoTrash,
IoLockClosed, IoLockClosed,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -175,14 +174,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
<div className={style.header}> <div className={style.header}>
<div className={style.titleRow}> <div className={style.titleRow}>
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' /> <TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
{data.useGroupTimer && (
<Tooltip
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
render={<span className={style.timerIndicator} />}
>
<IoTimerOutline />
</Tooltip>
)}
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}> <IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />} {collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton> </IconButton>
+31 -3
View File
@@ -100,7 +100,20 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); padding-bottom: 95vh;
}
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -196,9 +209,14 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background: /* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color); linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +248,16 @@ $item-height: 3.5rem;
} }
} }
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, getTopOffset: getStickyOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -75,15 +87,16 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -98,41 +111,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {sections.map((section) => {
// while a group is live, surface the running event's title as the secondary line const rows = section.group ? [section.group, ...section.events] : section.events;
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; // the running event anchors the scroll, the group header stays pinned above it
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div key={section.group?.id ?? rows[0].id} className='sub-section'>
key={event.id} {rows.map((event) => {
ref={isLive ? selectedRef : undefined} // while a group is live, surface the running event's title as the secondary line
className={cx([ const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
'sub', const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
isLive && 'sub--live', const isGroupedEvent = !event.isGroup && Boolean(event.parent);
isArmed && 'sub--armed', const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
event.isGroup && 'sub--group', // a subscribed group is live when any of its children is the selected/running event
isGroupedEvent && 'sub--in-group', const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
])} const isArmed = !isLive && activeEntryId === selectedEventId;
data-testid={event.cue} // only ever hand the ref to a single row, sharing it would null it out on the next commit
> const isAnchor = isLive && (anchorId === null || event.id === anchorId);
<div const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
className='sub__binder' const countdownEvent = extendEventData(
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }} event,
/> currentDay,
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> actualStart,
<SubscriptionStatus event={countdownEvent} /> plannedStart,
<div className={cx(['sub__title', !displayTitle && 'subdued'])}> offset,
{event.isGroup && <span className='sub__eyebrow'>Group</span>} mode,
{displayTitle} reportData,
</div> );
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils'; import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,42 @@ export function resolveSubscriptionTarget(
}; };
} }
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
sections.push({ group: target, events: [] });
continue;
}
const previousSection = sections.at(-1);
if (previousSection?.group?.id === target.parent) {
previousSection.events.push(target);
continue;
}
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
@@ -1,13 +0,0 @@
.timerOverrideCell {
display: grid;
grid-template-columns: minmax(0, 1fr) 1.5rem;
align-items: center;
gap: 0.25rem;
}
.timerOverrideIndicator {
display: grid;
place-items: center;
color: $active-indicator;
font-size: 1rem;
}
@@ -1,18 +1,8 @@
import { import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
CustomFields,
TimeStrategy,
TimerType,
URLPreset,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
} from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { IoTimerOutline } from 'react-icons/io5';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
@@ -27,8 +17,6 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
import style from './cuesheetColsFactory.module.scss';
function getColumnLabel(column: CuesheetCellContext['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
@@ -117,10 +105,9 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
return null; return null;
} }
const { hideTableSeconds } = table.options.meta.options;
const event = row.original; const event = row.original;
if (!isOntimeEvent(event)) { if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>; return <MutedText numeric>{formatDuration(getValue() as number, false)}</MutedText>;
} }
const { handleUpdateTimer } = table.options.meta; const { handleUpdateTimer } = table.options.meta;
@@ -129,7 +116,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
const duration = getValue() as number; const duration = getValue() as number;
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration; const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, hideTableSeconds); const formattedDuration = formatDuration(duration, false);
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { if (!canWrite) {
@@ -205,38 +192,17 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
} }
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
const content = canWrite ? ( if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell <SingleLineCell
initialValue={initialValue as string} initialValue={initialValue as string}
fieldId={column.id} fieldId={column.id}
fieldLabel={getColumnLabel(column)} fieldLabel={getColumnLabel(column)}
handleUpdate={update} handleUpdate={update}
/> />
) : (
<GhostedText>{initialValue}</GhostedText>
);
if (column.id !== 'title') {
return content;
}
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
if (!isGroupOverride && !isEventOverride) {
return content;
}
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
return (
<div className={style.timerOverrideCell}>
{content}
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
<IoTimerOutline />
</Tooltip>
</div>
); );
} }
@@ -35,14 +35,6 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.timer-source {
color: $viewer-label-color;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.timer { .timer {
opacity: 1; opacity: 1;
font-family: $viewer-font-family; font-family: $viewer-font-family;
@@ -7,7 +7,6 @@ import { cx } from '../../../common/utils/styleUtils';
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils'; import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
import { import {
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowMessage, getShowMessage,
@@ -24,18 +23,7 @@ interface PipTimerProps {
} }
export function PipTimer({ viewSettings }: PipTimerProps) { export function PipTimer({ viewSettings }: PipTimerProps) {
const { const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
// gather modifiers // gather modifiers
const showOverlay = getShowMessage(message.timer); const showOverlay = getShowMessage(message.timer);
@@ -71,9 +59,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return null; return null;
})(); })();
const secondaryContent = usesGroupTimer const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
@@ -91,7 +77,6 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
</div> </div>
<div className='timer-container'> <div className='timer-container'>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: `${timerFontSize}vw` }}
@@ -111,11 +96,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={usesGroupTimer ? undefined : eventNow?.id} eventId={eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning} warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger} danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
-8
View File
@@ -89,14 +89,6 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
.timer-source {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-align: center;
text-transform: uppercase;
}
.end-message { .end-message {
text-align: center; text-align: center;
font-size: 11.5vw; font-size: 11.5vw;
+13 -31
View File
@@ -22,7 +22,6 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
import { import {
getCardData, getCardData,
getEstimatedFontSize, getEstimatedFontSize,
getEventTimerSecondary,
getIsPlaying, getIsPlaying,
getSecondaryDisplay, getSecondaryDisplay,
getShowClock, getShowClock,
@@ -53,19 +52,7 @@ export default function TimerLoader() {
} }
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) { function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const { const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
eventNext,
eventNow,
message,
time,
eventTimer,
clock,
timerTypeNow,
eventTimerType,
countToEndNow,
usesGroupTimer,
auxTimer,
} = useTimerSocket();
const { const {
hideClock, hideClock,
hideCards, hideCards,
@@ -91,7 +78,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes'); const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound); const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers // gather modifiers
const viewTimerType = timerType ?? timerTypeNow; const viewTimerType = timerType ?? timerTypeNow;
@@ -141,18 +128,14 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
return null; return null;
})(); })();
const secondaryContent = const secondaryContent = getSecondaryDisplay(
usesGroupTimer && !hideSecondary message,
? getEventTimerSecondary( currentAux,
eventTimer, localisedMinutes,
eventTimerType, hideTimerSeconds,
clock, removeLeadingZeros,
localisedMinutes, hideSecondary,
hideTimerSeconds, );
removeLeadingZeros,
timeformat,
)
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
@@ -193,7 +176,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />} {showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}> <div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
{showEndMessage ? ( {showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'> <FitText mode='multi' min={64} max={256} className='end-message'>
{freezeMessage} {freezeMessage}
@@ -220,11 +202,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])} className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current} now={time.current}
complete={totalTime} complete={totalTime}
eventId={usesGroupTimer ? undefined : eventNow?.id} eventId={eventNow?.id}
normalColor={viewSettings.normalColor} normalColor={viewSettings.normalColor}
warning={usesGroupTimer ? undefined : eventNow?.timeWarning} warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor} warningColor={viewSettings.warningColor}
danger={usesGroupTimer ? undefined : eventNow?.timeDanger} danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished} hideOvertime={!showFinished}
/> />
@@ -1,32 +1,6 @@
import { TimerPhase, TimerType } from 'ontime-types'; import { TimerPhase } from 'ontime-types';
import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils'; import { shouldPlayEndSound } from '../timer.utils';
describe('getEventTimerSecondary()', () => {
it('formats the event countdown as a labelled secondary value', () => {
expect(
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
).toBe('Event timer 00:01:05');
});
it('preserves the event count-up display', () => {
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('falls back to remaining time when the event timer is hidden', () => {
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
'Event timer 00:00:05',
);
});
it('shows event progress instead of wall-clock time for clock events', () => {
expect(
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
).toBe('Event timer 00:00:05');
});
});
describe('shouldPlayEndSound()', () => { describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])( test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
+1 -21
View File
@@ -6,12 +6,11 @@ import {
RundownEntries, RundownEntries,
TimerMessage, TimerMessage,
TimerPhase, TimerPhase,
TimerState,
TimerType, TimerType,
} from 'ontime-types'; } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils'; import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
/** /**
* Whether a message should be shown * Whether a message should be shown
@@ -145,25 +144,6 @@ export function getSecondaryDisplay(
return; return;
} }
export function getEventTimerSecondary(
timer: Pick<TimerState, 'current' | 'elapsed'>,
timerType: TimerType,
clock: number,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
clockFormat?: string | null,
): string {
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
const value = getTimerByType(false, effectiveType, clock, timer);
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
removeSeconds,
removeLeadingZero,
clockFormat,
});
return `Event timer ${display}`;
}
/** /**
* What should we be showing in the cards? * What should we be showing in the cards?
*/ */
+2 -2
View File
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
/> />
<title>ontime</title> <title>Ontime</title>
<style> <style>
body { body {
-webkit-user-select: none; -webkit-user-select: none;
@@ -92,7 +92,7 @@
<body> <body>
<div class="container"> <div class="container">
<img src="../assets/logo.png" /> <img src="../assets/logo.png" />
<h1>ontime · event timers</h1> <h1>Ontime · event timers</h1>
<div class="lds-ellipsis"> <div class="lds-ellipsis">
<div></div> <div></div>
<div></div> <div></div>
@@ -417,7 +417,7 @@ export function migrateRundown(
timeEnd: null, timeEnd: null,
duration: 0, duration: 0,
isFirstLinked: false, isFirstLinked: false,
} as unknown as OntimeEntry); });
} else if (entry.type === 'delay') { } else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent }); append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
} }
@@ -1,4 +1,4 @@
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types'; import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js'; import { makeNewRundown } from '../../../models/dataModel.js';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
@@ -276,13 +276,7 @@ describe('parseRundown()', () => {
expect(parsedRundown.order).toStrictEqual(['group']); expect(parsedRundown.order).toStrictEqual(['group']);
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']); expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
expect(parsedRundown.entries).toMatchObject({ expect(parsedRundown.entries).toMatchObject({
group: { group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
id: 'group',
type: SupportedEntry.Group,
entries: ['1', '2'],
useGroupTimer: false,
timerType: TimerType.CountDown,
},
'1': { id: '1', type: SupportedEntry.Event }, '1': { id: '1', type: SupportedEntry.Event },
'2': { id: '2', type: SupportedEntry.Milestone }, '2': { id: '2', type: SupportedEntry.Milestone },
}); });
@@ -25,7 +25,6 @@ import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData, cloneEntryData,
createGroupPatch,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -37,25 +36,6 @@ import {
} from '../rundown.utils.js'; } from '../rundown.utils.js';
describe('test event validator', () => { describe('test event validator', () => {
it('creates groups with the shared timer disabled by default', () => {
expect(createGroup({ id: 'group' })).toMatchObject({
useGroupTimer: false,
timerType: TimerType.CountDown,
});
});
it('limits group timers to count down and count up', () => {
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
});
it('rejects non-boolean group timer updates', () => {
const group = createGroup({ useGroupTimer: false });
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
expect(updated.useGroupTimer).toBe(false);
});
it('validates a good object', () => { it('validates a good object', () => {
const event = { const event = {
title: 'test', title: 'test',
@@ -33,7 +33,6 @@ import {
makeString, makeString,
maxDuration, maxDuration,
validateEndAction, validateEndAction,
validateGroupTimerType,
validateTimerType, validateTimerType,
validateTimes, validateTimes,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -181,9 +180,6 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
note: makeString(patchGroup.note, originalGroup.note), note: makeString(patchGroup.note, originalGroup.note),
entries: patchGroup.entries ?? originalGroup.entries, entries: patchGroup.entries ?? originalGroup.entries,
targetDuration: maybeTargetDuration(), targetDuration: maybeTargetDuration(),
useGroupTimer:
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
colour: makeString(patchGroup.colour, originalGroup.colour), colour: makeString(patchGroup.colour, originalGroup.colour),
revision: originalGroup.revision, revision: originalGroup.revision,
timeStart: originalGroup.timeStart, timeStart: originalGroup.timeStart,
-1
View File
@@ -206,7 +206,6 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
eventStore.init({ eventStore.init({
clock: state.clock, clock: state.clock,
timer: state.timer, timer: state.timer,
groupTimer: state.groupTimer,
message: { ...runtimeStorePlaceholder.message }, message: { ...runtimeStorePlaceholder.message },
offset: state.offset, offset: state.offset,
rundown: state.rundown, rundown: state.rundown,
+1 -1
View File
@@ -16,7 +16,7 @@
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #121212; background: #101010;
color: #ffffff; color: #ffffff;
font-family: sans-serif; font-family: sans-serif;
overflow: hidden; overflow: hidden;
@@ -29,15 +29,19 @@ function makeHeadersWithFailingAuthorization(cookie?: string) {
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true); expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
}); });
it('allows prefixed public assets in cloud deployments', () => { it('allows prefixed public assets in cloud deployments', () => {
expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/manifest.json', '/stage-hash')).toBe(true);
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true); expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
}); });
it('allows the PWA install icons', () => {
expect(isPublicAssetRequest('/ontime-logo-192.png', '')).toBe(true);
expect(isPublicAssetRequest('/ontime-logo-512.png', '')).toBe(true);
});
it('keeps non-public paths protected', () => { it('keeps non-public paths protected', () => {
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false); expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
+2 -1
View File
@@ -17,8 +17,9 @@ const publicAssets = new Set([
'/favicon.ico', '/favicon.ico',
'/manifest.json', '/manifest.json',
'/ontime-logo.png', '/ontime-logo.png',
'/ontime-logo-192.png',
'/ontime-logo-512.png',
'/robots.txt', '/robots.txt',
'/site.webmanifest',
]); ]);
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean { export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
-22
View File
@@ -39,8 +39,6 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'], entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -174,8 +172,6 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['0aaa7d'], entries: ['0aaa7d'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -223,8 +219,6 @@ export const stageRundown: Rundown = {
note: '', note: '',
entries: ['02afca', '75ce86', 'e10ed9', '07df89'], entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -366,8 +360,6 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'], entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#A790F5', colour: '#A790F5',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -484,8 +476,6 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'], entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -598,8 +588,6 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0301'], entries: ['bs0301'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -646,8 +634,6 @@ export const backstageRundown: Rundown = {
note: '', note: '',
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'], entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -782,8 +768,6 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0101', 'br0102', 'br0103'], entries: ['br0101', 'br0102', 'br0103'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#ED3333', colour: '#ED3333',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -867,8 +851,6 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0201', 'br0202', 'br0203', 'br0204'], entries: ['br0201', 'br0202', 'br0203', 'br0204'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -977,8 +959,6 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0301', 'br0302'], entries: ['br0301', 'br0302'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#3E75E8', colour: '#3E75E8',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -1049,8 +1029,6 @@ export const broadcastRundown: Rundown = {
note: '', note: '',
entries: ['br0401', 'br0402', 'br0403'], entries: ['br0401', 'br0402', 'br0403'],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '#339E4E', colour: '#339E4E',
custom: {}, custom: {},
revision: 0, revision: 0,
@@ -4,7 +4,6 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
import type { RuntimeState } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getGroupTimer,
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
@@ -17,91 +16,6 @@ import {
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock']; const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
describe('getGroupTimer()', () => {
const makeState = (patch: Partial<RuntimeState> = {}) =>
({
clock: asTimeOfDay(10_000),
groupNow: {
duration: 20_000,
},
rundown: {
actualGroupStart: 5_000,
},
timer: {
addedTime: 4_000,
current: 1_000,
elapsed: 99_000,
phase: TimerPhase.Danger,
playback: Playback.Play,
},
...patch,
}) as RuntimeState;
it('returns null outside a group', () => {
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
});
it('derives elapsed and remaining time from the group start and duration', () => {
expect(getGroupTimer(makeState())).toMatchObject({
addedTime: 0,
current: 15_000,
duration: 20_000,
elapsed: 5_000,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 5_000,
});
});
it('does not inherit event timer progress or added time', () => {
const first = getGroupTimer(makeState());
const second = getGroupTimer(
makeState({
timer: {
...makeState().timer,
addedTime: -8_000,
current: -50_000,
elapsed: 500_000,
},
}),
);
expect(second).toEqual(first);
});
it('keeps running while the loaded event is paused', () => {
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
expect(timer?.playback).toBe(Playback.Play);
});
it('handles a group running across midnight', () => {
const timer = getGroupTimer(
makeState({
clock: asTimeOfDay(1_000),
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
}),
);
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
});
it('uses the event phase before the group starts and overtime afterwards', () => {
const pending = getGroupTimer(
makeState({
rundown: { ...makeState().rundown, actualGroupStart: null },
timer: { ...makeState().timer, phase: TimerPhase.Pending },
}),
);
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
});
});
describe('getElapsed()', () => { describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => { it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = { const state = {
@@ -7,7 +7,6 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -96,34 +95,6 @@ describe('getShouldTimerUpdate()', () => {
}); });
}); });
describe('getShouldGroupTimerUpdate()', () => {
const timer: TimerState = {
addedTime: 0,
current: 10_000,
duration: 10_000,
elapsed: 0,
expectedFinish: null,
phase: TimerPhase.Default,
playback: Playback.Play,
secondaryTimer: null,
startedAt: 0,
};
it('updates when a group timer appears or disappears', () => {
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
});
it('does not repeatedly publish an absent group timer', () => {
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
});
it('uses normal timer tick semantics while a group timer exists', () => {
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
});
});
describe('getShouldOffsetUpdate()', () => { describe('getShouldOffsetUpdate()', () => {
const baseOffset: Offset = { const baseOffset: Offset = {
absolute: 0, absolute: 0,
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
import { timerConfig } from '../../setup/config.js'; import { timerConfig } from '../../setup/config.js';
import { eventStore } from '../../stores/EventStore.js'; import { eventStore } from '../../stores/EventStore.js';
import * as runtimeState from '../../stores/runtimeState.js'; import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js';
import { EventTimer } from '../EventTimer.js'; import { EventTimer } from '../EventTimer.js';
import { restoreService } from '../restore-service/restore.service.js'; import { restoreService } from '../restore-service/restore.service.js';
import type { RestorePoint } from '../restore-service/restore.type.js'; import type { RestorePoint } from '../restore-service/restore.type.js';
@@ -36,7 +36,6 @@ import {
findPreviousPlayableId, findPreviousPlayableId,
getEventAtIndex, getEventAtIndex,
getShouldClockUpdate, getShouldClockUpdate,
getShouldGroupTimerUpdate,
getShouldOffsetUpdate, getShouldOffsetUpdate,
getShouldTimerUpdate, getShouldTimerUpdate,
isNewSecond, isNewSecond,
@@ -52,11 +51,11 @@ class RuntimeService {
private lastIntegrationTimerValue = -1; private lastIntegrationTimerValue = -1;
/** last known state */ /** last known state */
static previousState: RuntimeStateSnapshot; static previousState: RuntimeState;
constructor(eventTimer: EventTimer) { constructor(eventTimer: EventTimer) {
this.eventTimer = eventTimer; this.eventTimer = eventTimer;
RuntimeService.previousState = {} as RuntimeStateSnapshot; RuntimeService.previousState = {} as RuntimeState;
} }
@broadcastResult @broadcastResult
@@ -711,12 +710,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
RuntimeService.previousState.timer = { ...state.timer }; RuntimeService.previousState.timer = { ...state.timer };
} }
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
if (updateGroupTimer) {
batch.add('groupTimer', state.groupTimer);
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
}
/** /**
* clock has changed by a second or more. * clock has changed by a second or more.
* or the timer updated so we ensure that the timer and clock ticks are in sync * or the timer updated so we ensure that the timer and clock ticks are in sync
@@ -54,15 +54,6 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
); );
} }
export function getShouldGroupTimerUpdate(
previousValue: TimerState | null | undefined,
currentValue: TimerState | null,
): boolean {
if (previousValue === undefined) return true;
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
return getShouldTimerUpdate(previousValue, currentValue);
}
/** /**
* Checks whether we should update the offset values * Checks whether we should update the offset values
* - `mode` triggers update * - `mode` triggers update
+1 -32
View File
@@ -1,4 +1,4 @@
import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types'; import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import type { RuntimeState } from '../stores/runtimeState.js'; import type { RuntimeState } from '../stores/runtimeState.js';
@@ -111,37 +111,6 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
return Math.max(0, activeElapsed); return Math.max(0, activeElapsed);
} }
/**
* Derives a timer for the active group from wall-clock time.
* Event timer controls such as pause and add time intentionally do not affect it.
*/
export function getGroupTimer(state: RuntimeState): TimerState | null {
if (state.groupNow === null) {
return null;
}
const { actualGroupStart } = state.rundown;
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
let phase = state.timer.phase;
if (actualGroupStart !== null) {
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
}
return {
addedTime: 0,
current,
duration: state.groupNow.duration,
elapsed,
expectedFinish: null,
phase,
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
secondaryTimer: null,
startedAt: actualGroupStart,
};
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
if (clock < startedAt) { if (clock < startedAt) {
return clock + dayInMs - startedAt; return clock + dayInMs - startedAt;
+1 -6
View File
@@ -13,7 +13,6 @@ import {
Playback, Playback,
Rundown, Rundown,
RundownState, RundownState,
RuntimeStore,
TimeOfDay, TimeOfDay,
TimerPhase, TimerPhase,
TimerState, TimerState,
@@ -40,7 +39,6 @@ import {
getCurrent, getCurrent,
getElapsed, getElapsed,
getExpectedFinish, getExpectedFinish,
getGroupTimer,
getRuntimeOffset, getRuntimeOffset,
getTimerPhase, getTimerPhase,
hasCrossedMidnight, hasCrossedMidnight,
@@ -106,9 +104,7 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>; export function getState(): Readonly<RuntimeState> {
export function getState(): Readonly<RuntimeStateSnapshot> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
...runtimeState, ...runtimeState,
@@ -119,7 +115,6 @@ export function getState(): Readonly<RuntimeStateSnapshot> {
offset: { ...runtimeState.offset }, offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown }, rundown: { ...runtimeState.rundown },
timer: { ...runtimeState.timer }, timer: { ...runtimeState.timer },
groupTimer: getGroupTimer(runtimeState),
_timer: { ...runtimeState._timer }, _timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown }, _rundown: { ...runtimeState._rundown },
}; };
+7 -7
View File
@@ -6,7 +6,7 @@ test.describe('pages routes are available', () => {
test('editor', async ({ page }) => { test('editor', async ({ page }) => {
await page.goto('/editor'); await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByTestId('editor-container')).toBeVisible(); await expect(page.getByTestId('editor-container')).toBeVisible();
await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible();
@@ -16,38 +16,38 @@ test.describe('pages routes are available', () => {
test('cuesheet', async ({ page }) => { test('cuesheet', async ({ page }) => {
await page.goto('/cuesheet'); await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByTestId('cuesheet')).toBeVisible(); await expect(page.getByTestId('cuesheet')).toBeVisible();
}); });
test('operator', async ({ page }) => { test('operator', async ({ page }) => {
await page.goto('/op'); await page.goto('/op');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
}); });
test('timer', async ({ page }) => { test('timer', async ({ page }) => {
await page.goto('/timer'); await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
}); });
test('backstage', async ({ page }) => { test('backstage', async ({ page }) => {
await page.goto('/backstage'); await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
}); });
test('studio', async ({ page }) => { test('studio', async ({ page }) => {
await page.goto('/studio'); await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
}); });
test('countdown', async ({ page }) => { test('countdown', async ({ page }) => {
await page.goto('/countdown?sub=32d31'); await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByText('Albania')).toBeVisible(); await expect(page.getByText('Albania')).toBeVisible();
await expect(page.getByText('Latvia')).toBeHidden(); await expect(page.getByText('Latvia')).toBeHidden();
+2 -2
View File
@@ -92,12 +92,12 @@ test.describe('test view navigation feature', () => {
test('not-found', async ({ page }) => { test('not-found', async ({ page }) => {
await page.goto('/not-found'); await page.goto('/not-found');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
await page.goto('/preset/not-found'); await page.goto('/preset/not-found');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/i);
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
}); });
}); });
@@ -44,8 +44,6 @@ export type OntimeGroup = OntimeBaseEvent & {
note: string; note: string;
entries: EntryId[]; entries: EntryId[];
targetDuration: MaybeNumber; targetDuration: MaybeNumber;
useGroupTimer: boolean;
timerType: TimerType;
colour: string; colour: string;
custom: EntryCustomFields; custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
@@ -17,7 +17,6 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
secondaryTimer: null, // change on every update secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user startedAt: null, // change can only be initiated by user
}, },
groupTimer: null,
message: { message: {
timer: { timer: {
text: '', text: '',
@@ -9,7 +9,6 @@ export type RuntimeStore = {
// timer data // timer data
clock: number; clock: number;
timer: TimerState; timer: TimerState;
groupTimer: TimerState | null;
// messages service // messages service
message: MessageState; message: MessageState;
+1 -8
View File
@@ -34,14 +34,7 @@ export {
group as groupDef, group as groupDef,
milestone as milestoneDef, milestone as milestoneDef,
} from './src/rundown-utils/entryDefinitions.js'; } from './src/rundown-utils/entryDefinitions.js';
export { export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
createDelay,
createEvent,
createGroup,
createMilestone,
makeString,
validateGroupTimerType,
} from './src/rundown-utils/entryUtils.js';
// time format utils // time format utils
export { export {
@@ -52,8 +52,6 @@ export const group: Omit<OntimeGroup, 'id'> = {
note: '', note: '',
entries: [], entries: [],
targetDuration: null, targetDuration: null,
useGroupTimer: false,
timerType: TimerType.CountDown,
colour: '', colour: '',
custom: {}, custom: {},
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
+1 -13
View File
@@ -1,5 +1,5 @@
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types'; import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types'; import { SupportedEntry, TimeStrategy } from 'ontime-types';
import { generateId } from '../generate-id/generateId.js'; import { generateId } from '../generate-id/generateId.js';
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js'; import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
@@ -67,8 +67,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
note: patch.note ?? '', note: patch.note ?? '',
entries: patch.entries ?? [], entries: patch.entries ?? [],
targetDuration: patch.targetDuration ?? null, targetDuration: patch.targetDuration ?? null,
useGroupTimer: patch.useGroupTimer === true,
timerType: validateGroupTimerType(patch.timerType),
colour: makeString(patch.colour, ''), colour: makeString(patch.colour, ''),
custom: patch.custom ?? {}, custom: patch.custom ?? {},
revision: 0, revision: 0,
@@ -79,16 +77,6 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
}; };
} }
export function validateGroupTimerType(
value: unknown,
fallback: unknown = TimerType.CountDown,
): TimerType.CountDown | TimerType.CountUp {
if (value === TimerType.CountDown || value === TimerType.CountUp) {
return value;
}
return fallback === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
}
/** /**
* Creates a new milestone from an optional patch * Creates a new milestone from an optional patch
*/ */