mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c29756abe |
@@ -22,11 +22,6 @@ interface UseFollowComponentProps {
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
doFollow: boolean;
|
||||
topOffset?: number;
|
||||
/**
|
||||
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
|
||||
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
|
||||
*/
|
||||
getTopOffset?: () => number;
|
||||
setScrollFlag?: (newValue: boolean) => void;
|
||||
followTrigger?: MaybeString; // this would be an entry id or null
|
||||
}
|
||||
@@ -36,7 +31,6 @@ export default function useFollowComponent({
|
||||
scrollRef,
|
||||
doFollow,
|
||||
topOffset = 100,
|
||||
getTopOffset,
|
||||
setScrollFlag,
|
||||
followTrigger,
|
||||
}: UseFollowComponentProps) {
|
||||
@@ -50,23 +44,21 @@ export default function useFollowComponent({
|
||||
setScrollFlag?.(true);
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
// resolve the offset after layout, so that measured values are up to date
|
||||
const offset = getTopOffset?.() ?? topOffset;
|
||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
|
||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
||||
setScrollFlag?.(false);
|
||||
});
|
||||
}
|
||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
|
||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||
|
||||
const scrollToRefComponent = useCallback(
|
||||
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
|
||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||
if (componentRef && containerRef) {
|
||||
// @ts-expect-error -- we know this are not null
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
|
||||
scrollToComponent(componentRef!, containerRef!, offset);
|
||||
}
|
||||
},
|
||||
[followRef, scrollRef, topOffset, getTopOffset],
|
||||
[followRef, scrollRef, topOffset],
|
||||
);
|
||||
|
||||
return scrollToRefComponent;
|
||||
|
||||
@@ -18,14 +18,6 @@
|
||||
padding-bottom: 95vh;
|
||||
}
|
||||
|
||||
/* a group and its events, bounds the sticky group header to its own section.
|
||||
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
|
||||
.groupSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.editPrompt {
|
||||
position: fixed;
|
||||
z-index: $zindex-dialog;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
@@ -54,21 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// the group of the running event is pinned to the top of the list, the running event needs to clear it
|
||||
const getStickyOffset = useCallback(() => {
|
||||
const header = stickyHeaderRef.current;
|
||||
// account for the list gap, so that the rows do not touch
|
||||
return header ? header.offsetHeight + 2 : 0;
|
||||
}, []);
|
||||
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
|
||||
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
getTopOffset,
|
||||
topOffset: selectedOffset,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
@@ -96,8 +86,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
if (selectedRect && scrollerRect) {
|
||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||
}
|
||||
@@ -197,14 +186,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.groupSection} key={entry.id}>
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup
|
||||
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
||||
key={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
isLive={isCurrentParent}
|
||||
/>
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
@@ -251,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,32 +3,17 @@
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
|
||||
and is what keeps the rows from showing through while the header is pinned */
|
||||
background-color: $gray-1350;
|
||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
/* stays visible as a header for the events of the group, bound by the group section */
|
||||
position: sticky;
|
||||
/* covers the list padding, otherwise the rows scroll through it */
|
||||
top: -0.25rem;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
|
||||
the card is already filled with the group colour, so we use the lighter active indicator over a dark
|
||||
frame, which stays legible against any of them, green groups included */
|
||||
.live {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px $ui-black,
|
||||
inset 0 0 0 4px $active-indicator;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CSSProperties, Ref, memo } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorGroup.module.scss';
|
||||
@@ -10,22 +10,15 @@ interface OperatorGroup {
|
||||
colour: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
/** whether the group contains the currently running event */
|
||||
isLive?: boolean;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export default memo(OperatorGroup);
|
||||
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
|
||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||
const groupColour = colour || '#929292';
|
||||
const groupColours = getAccessibleColour(groupColour);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx([style.group, isLive && style.live])}
|
||||
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||
<span className={style.title}>{title}</span>
|
||||
<span className={style.meta}>
|
||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||
|
||||
@@ -103,22 +103,6 @@ $item-height: 3.5rem;
|
||||
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
||||
}
|
||||
|
||||
/* a subscribed group and its subscribed events, bounds the sticky header to its own section.
|
||||
flex rather than block so that the row margins do not collapse, keeping the spacing between
|
||||
rows of a section equal to the spacing between sections */
|
||||
.sub-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* scoped to the section, the select view shows the same cards in a flat list */
|
||||
.sub-section .sub--group {
|
||||
position: sticky;
|
||||
/* sticky insets constrain the margin box, negate the row margin so the header sits flush */
|
||||
top: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ====================== LIST-ITEM ======================*/
|
||||
.sub {
|
||||
margin: 2px;
|
||||
@@ -212,15 +196,9 @@ $item-height: 3.5rem;
|
||||
|
||||
.sub--group {
|
||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
|
||||
split into longhands since the shorthand only takes a colour in its last layer */
|
||||
background-color: var(--background-color-override, $viewer-background-color);
|
||||
background-image:
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||
linear-gradient(
|
||||
var(--card-background-color-override, $viewer-card-bg-color),
|
||||
var(--card-background-color-override, $viewer-card-bg-color)
|
||||
);
|
||||
var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
.sub__binder {
|
||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
@@ -252,16 +230,6 @@ $item-height: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
|
||||
.sub--group.sub--live {
|
||||
box-shadow: inset 0 0 0 2px $active-green;
|
||||
}
|
||||
|
||||
/* loaded but not yet running, subtle enough to not compete with the live ring */
|
||||
.sub--group.sub--armed {
|
||||
box-shadow: inset 0 0 0 2px $gray-1000;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
grid-area: title;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
CountdownTarget,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
groupSubscriptionTargets,
|
||||
isOutsideRange,
|
||||
preferredFormat12,
|
||||
preferredFormat24,
|
||||
@@ -49,25 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
|
||||
|
||||
// The group of the running event is pinned to the top of the list, the running event needs to clear it.
|
||||
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
|
||||
// against vw), it grows while live since the running event's title is added, and long titles wrap.
|
||||
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
|
||||
const getStickyOffset = useCallback(() => {
|
||||
const header = stickyHeaderRef.current;
|
||||
// account for the row margin, so that the rows do not touch
|
||||
return header ? header.offsetHeight + 2 : 0;
|
||||
}, []);
|
||||
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
getTopOffset: getStickyOffset,
|
||||
topOffset: 0,
|
||||
followTrigger: selectedEventId,
|
||||
});
|
||||
|
||||
@@ -94,8 +79,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||
if (selectedRect && scrollerRect) {
|
||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||
}
|
||||
@@ -114,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
|
||||
return (
|
||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{sections.map((section) => {
|
||||
const rows = section.group ? [section.group, ...section.events] : section.events;
|
||||
// the running event anchors the scroll, the group header stays pinned above it
|
||||
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
|
||||
|
||||
{subscribedEvents.map((event) => {
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||
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 (
|
||||
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
|
||||
{rows.map((event) => {
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||
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;
|
||||
// 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);
|
||||
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
|
||||
const countdownEvent = extendEventData(
|
||||
event,
|
||||
currentDay,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
offset,
|
||||
mode,
|
||||
reportData,
|
||||
);
|
||||
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
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
|
||||
import { resolveSubscriptionTarget } from './countdown.utils';
|
||||
|
||||
/**
|
||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
|
||||
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,44 +252,6 @@ 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[] = [];
|
||||
let openSection: CountdownSection | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
|
||||
if (target.isGroup) {
|
||||
openSection = { group: target, events: [] };
|
||||
sections.push(openSection);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (openSection?.group && target.parent === openSection.group.id) {
|
||||
openSection.events.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
openSection = null;
|
||||
sections.push({ group: null, events: [target] });
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
export function extendEventData(
|
||||
event: CountdownTarget,
|
||||
currentDay: number,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
findNextPlayableId,
|
||||
findNextPlayableWithCue,
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from '../runtime.utils.js';
|
||||
|
||||
describe('isNewSecond()', () => {
|
||||
it('is false while the value moves within the same second', () => {
|
||||
// count down rounds up, so both resolve to second 2
|
||||
expect(isNewSecond(1500, 1200)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once the value crosses a second boundary', () => {
|
||||
expect(isNewSecond(1001, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('rounds according to the given direction', () => {
|
||||
// 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1
|
||||
expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false);
|
||||
expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false);
|
||||
// 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2
|
||||
expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true);
|
||||
expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats null and undefined as second zero', () => {
|
||||
expect(isNewSecond(undefined, null)).toBe(false);
|
||||
expect(isNewSecond(null, 0)).toBe(false);
|
||||
expect(isNewSecond(undefined, 500)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldClockUpdate()', () => {
|
||||
it('is false within the same second and true across the boundary', () => {
|
||||
expect(getShouldClockUpdate(1000, 1999)).toBe(false);
|
||||
expect(getShouldClockUpdate(1000, 2000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldTimerUpdate()', () => {
|
||||
const baseTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
current: 10000,
|
||||
duration: 10000,
|
||||
elapsed: 0,
|
||||
expectedFinish: 10000,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 0,
|
||||
};
|
||||
|
||||
it('always updates when there is no previous state', () => {
|
||||
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update while the timer ticks within the same second', () => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false);
|
||||
});
|
||||
|
||||
it('updates when the timer crosses a second', () => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when the secondary timer crosses a second', () => {
|
||||
const previous = { ...baseTimer, secondaryTimer: 2000 };
|
||||
// counting down rounds up, so 1999 is still second 2
|
||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false);
|
||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['addedTime', { addedTime: 1 }],
|
||||
['duration', { duration: 1 }],
|
||||
['phase', { phase: TimerPhase.Warning }],
|
||||
['playback', { playback: Playback.Pause }],
|
||||
['startedAt', { startedAt: 1 }],
|
||||
])('updates immediately when %s changes', (_label, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['elapsed', { elapsed: 1 }],
|
||||
['expectedFinish', { expectedFinish: 1 }],
|
||||
])('does not update on %s alone, since it is derived', (_label, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldOffsetUpdate()', () => {
|
||||
const baseOffset: Offset = {
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
expectedGroupEnd: null,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
};
|
||||
|
||||
it('always updates when there is no previous state', () => {
|
||||
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates on a mode change even when no dependency ticked', () => {
|
||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('holds back value changes until a dependency ticks', () => {
|
||||
const next = { ...baseOffset, absolute: 1000 };
|
||||
expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false);
|
||||
expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update when a dependency ticked but nothing changed', () => {
|
||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousPlayableId()', () => {
|
||||
const order = ['1', '2', '3'];
|
||||
|
||||
it('returns undefined when there is nothing to play', () => {
|
||||
expect(findPreviousPlayableId([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the first event when nothing is loaded', () => {
|
||||
expect(findPreviousPlayableId(order)).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the preceding event', () => {
|
||||
expect(findPreviousPlayableId(order, '3')).toBe('2');
|
||||
});
|
||||
|
||||
it('stays on the first event when already at the top', () => {
|
||||
expect(findPreviousPlayableId(order, '1')).toBe('1');
|
||||
});
|
||||
|
||||
it('falls back to the first event when the loaded id is unknown', () => {
|
||||
expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextPlayableId()', () => {
|
||||
const order = ['1', '2', '3'];
|
||||
|
||||
it('returns undefined when there is nothing to play', () => {
|
||||
expect(findNextPlayableId([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the first event when nothing is loaded', () => {
|
||||
expect(findNextPlayableId(order)).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the following event', () => {
|
||||
expect(findNextPlayableId(order, '1')).toBe('2');
|
||||
});
|
||||
|
||||
it('wraps to the first event from the last', () => {
|
||||
expect(findNextPlayableId(order, '3')).toBe('1');
|
||||
});
|
||||
|
||||
it('falls back to the first event when the loaded id is unknown', () => {
|
||||
expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextPlayableWithCue()', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3', '4'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', cue: 'a' }),
|
||||
'2': makeOntimeEvent({ id: '2', cue: 'b' }),
|
||||
'3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }),
|
||||
'4': makeOntimeEvent({ id: '4', cue: 'b' }),
|
||||
},
|
||||
});
|
||||
const order = ['1', '2', '3', '4'];
|
||||
|
||||
it('finds the next event with the given cue', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('skips events which are not playable', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4');
|
||||
});
|
||||
|
||||
it('wraps around to the start of the rundown', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1');
|
||||
});
|
||||
|
||||
it('excludes the current event unless allowCurrent is set', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4');
|
||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('returns undefined when no event carries the cue', () => {
|
||||
expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventAtIndex()', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1' }),
|
||||
'2': makeOntimeEvent({ id: '2' }),
|
||||
},
|
||||
});
|
||||
|
||||
it('returns the event at the given index', () => {
|
||||
expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('returns undefined when the index is out of range', () => {
|
||||
expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined();
|
||||
expect(getEventAtIndex(rundown, [], 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { colourToHex, cssOrHexToColour, hexToColour, isLightColour, mixColours } from './colour.utils';
|
||||
|
||||
describe('hexToColour()', () => {
|
||||
it('parses a full length hex', () => {
|
||||
expect(hexToColour('#ff8800')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('parses a compressed hex by duplicating each digit', () => {
|
||||
expect(hexToColour('#f80')).toStrictEqual(hexToColour('#ff8800'));
|
||||
});
|
||||
|
||||
it('parses the alpha channel of a full length hex', () => {
|
||||
expect(hexToColour('#ff880000')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 0 });
|
||||
expect(hexToColour('#ff8800ff')).toStrictEqual({ red: 255, green: 136, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('parses the alpha channel of a compressed hex', () => {
|
||||
expect(hexToColour('#f800')).toStrictEqual(hexToColour('#ff880000'));
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(hexToColour('#FF8800')).toStrictEqual(hexToColour('#ff8800'));
|
||||
});
|
||||
|
||||
it('returns null for values which are not a hex colour', () => {
|
||||
// these are the values which reach us from user input
|
||||
for (const invalid of ['', 'red', '#', '#ff', '#fffff', '#ffg', 'ff8800']) {
|
||||
expect(hexToColour(invalid)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('colourToHex()', () => {
|
||||
it('pads single digit channels', () => {
|
||||
expect(colourToHex({ red: 0, green: 1, blue: 2, alpha: 1 })).toBe('#000102ff');
|
||||
});
|
||||
|
||||
it('round trips with hexToColour', () => {
|
||||
for (const hex of ['#000000ff', '#ff8800ff', '#ffffffff', '#12345600']) {
|
||||
expect(colourToHex(hexToColour(hex)!)).toBe(hex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cssOrHexToColour()', () => {
|
||||
it('resolves named css colours', () => {
|
||||
expect(cssOrHexToColour('red')).toStrictEqual({ red: 255, green: 0, blue: 0, alpha: 1 });
|
||||
});
|
||||
|
||||
it('resolves named css colours regardless of casing', () => {
|
||||
expect(cssOrHexToColour('CornflowerBlue')).toStrictEqual(cssOrHexToColour('cornflowerblue'));
|
||||
});
|
||||
|
||||
it('delegates hex values to the hex parser', () => {
|
||||
expect(cssOrHexToColour('#f80')).toStrictEqual(hexToColour('#f80'));
|
||||
});
|
||||
|
||||
it('returns null for an unknown colour name', () => {
|
||||
expect(cssOrHexToColour('not-a-colour')).toBeNull();
|
||||
expect(cssOrHexToColour('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixColours()', () => {
|
||||
const black = { red: 0, green: 0, blue: 0, alpha: 1 };
|
||||
const white = { red: 255, green: 255, blue: 255, alpha: 1 };
|
||||
|
||||
it('defaults to an even mix', () => {
|
||||
expect(mixColours(black, white)).toStrictEqual({ red: 128, green: 128, blue: 128, alpha: 1 });
|
||||
});
|
||||
|
||||
it('weights the first colour by the given proportion', () => {
|
||||
expect(mixColours(black, white, 1)).toStrictEqual({ ...black, alpha: 1 });
|
||||
expect(mixColours(black, white, 0)).toStrictEqual({ ...white, alpha: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLightColour()', () => {
|
||||
it('detects light and dark colours', () => {
|
||||
expect(isLightColour({ red: 255, green: 255, blue: 255, alpha: 1 })).toBe(true);
|
||||
expect(isLightColour({ red: 0, green: 0, blue: 0, alpha: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('weights green most heavily, as per the YIQ calculation', () => {
|
||||
// pure green is considered light, pure blue is not
|
||||
expect(isLightColour({ red: 0, green: 255, blue: 0, alpha: 1 })).toBe(true);
|
||||
expect(isLightColour({ red: 0, green: 0, blue: 255, alpha: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user