Compare commits

..

3 Commits

Author SHA1 Message Date
Claude ce7717f26d refactor(countdown): drop indirection left over from the row sections
renderRow had a single call site and recomputed activeEntryId, which the
caller had already derived to pick the scroll anchor. Inlining it brings
the row back to the shape it had before the sections were introduced.

The section wrapper no longer carries flex-grow. The list is sized by its
content and never fills the column, so there is no free space to
distribute and the growth factor resolved to nothing. Sections stay flex
containers though, otherwise the row margins collapse and the rows of a
section sit closer together than the sections themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-12 05:45:55 +00:00
Claude 0106145a45 docs(countdown): explain why the sticky offset is measured
The group card scales with the viewport, grows while live and wraps on
long titles, measuring between 91px and 171px. A constant would leave
the running event behind the pinned header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-12 05:19:22 +00:00
Claude e4cfa1d17d feat(views): keep group visible as header for the running event
In the countdown view a user can subscribe to a group and to events
inside it. Once an event within the group started, the auto scroll
pushed the group card above the viewport, losing the context of which
group the running event belongs to. The operator view had the same
problem.

Group and its events are now wrapped in a section, and the group card is
pinned with position sticky, so it stays above the running event however
far down the group that event sits. The follow scroll offsets the sticky
header, and the follow button threshold discounts it so it keeps its
meaning.

A group holding the running event is now marked as active. In the
countdown the card keeps its background and gains a green outline, the
green fill stays reserved for the running event itself. In the operator
the card is already filled with the group colour, so the ring uses the
lighter active indicator over a dark frame to stay legible against any
group colour. Neither state was visible before: .sub--group overrode
both .sub--live and .sub--armed at equal specificity.

Also fixes the countdown handing the same selectedRef to both a group
row and its running child. React detaches refs before attaching them,
so the child unsetting the ref left it null while the group never
re-attached, silently disabling auto scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-10 17:36:56 +00:00
19 changed files with 2433 additions and 1203 deletions
+6 -7
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.7.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,7 +13,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.7.0", "react-icons": "5.6.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,8 +60,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.4.0", "@sentry/vite-plugin": "5.1.1",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.2.1", "vite": "8.0.1",
"vite-plugin-compression2": "2.5.3", "vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -22,6 +22,11 @@ interface UseFollowComponentProps {
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; 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; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null followTrigger?: MaybeString; // this would be an entry id or null
} }
@@ -31,6 +36,7 @@ export default function useFollowComponent({
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100, topOffset = 100,
getTopOffset,
setScrollFlag, setScrollFlag,
followTrigger, followTrigger,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
@@ -44,21 +50,23 @@ export default function useFollowComponent({
setScrollFlag?.(true); 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
const offset = getTopOffset?.() ?? topOffset;
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { (componentRef = followRef, containerRef = scrollRef, offset?: number) => {
if (componentRef && containerRef) { if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null // @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset); scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
} }
}, },
[followRef, scrollRef, topOffset], [followRef, scrollRef, topOffset, getTopOffset],
); );
return scrollToRefComponent; return scrollToRefComponent;
@@ -18,6 +18,14 @@
padding-bottom: 95vh; 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 { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+18 -6
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';
@@ -54,11 +54,21 @@ 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 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({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: selectedOffset, getTopOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) { if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top; // 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 hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
} }
@@ -186,13 +197,14 @@ 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}
duration={entry.duration} duration={entry.duration}
isLive={isCurrentParent}
/> />
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
@@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</Fragment> </div>
); );
} }
return null; return null;
@@ -3,17 +3,32 @@
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 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; /* the background comes inline from getAccessibleColour, which resolves to an opaque colour
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); and is what keeps the rows from showing through while the header is pinned */
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; 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; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
letter-spacing: 0; 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 { .title {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -1,6 +1,6 @@
import { CSSProperties, memo } from 'react'; import { CSSProperties, Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss'; import style from './OperatorGroup.module.scss';
@@ -10,15 +10,22 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
/** whether the group contains the currently running event */
isLive?: boolean;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration, isLive, 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={cx([style.group, isLive && style.live])}
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>
+34 -2
View File
@@ -103,6 +103,22 @@ $item-height: 3.5rem;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); 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 ======================*/ /* ====================== LIST-ITEM ======================*/
.sub { .sub {
margin: 2px; margin: 2px;
@@ -196,9 +212,15 @@ $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 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:
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 +252,16 @@ $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 { .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,25 @@ 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]);
// 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({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, getTopOffset: getStickyOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -79,7 +94,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const selectedRect = selectedRef.current.getBoundingClientRect(); const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) { if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top; // 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 hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50; const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold); setLockAutoScroll(hasScrolledOutOfThreshold);
} }
@@ -98,41 +114,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,44 @@ 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( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
+2 -2
View File
@@ -14,8 +14,8 @@
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "38.2.1", "electron": "38.2.1",
"electron-builder": "26.15.3", "electron-builder": "26.9.1",
"wait-on": "^9.0.0" "wait-on": "^7.2.0"
}, },
"scripts": { "scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+3 -3
View File
@@ -10,7 +10,7 @@
"cookie": "1.0.2", "cookie": "1.0.2",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.6", "cors": "2.8.6",
"dotenv": "^17.0.0", "dotenv": "^16.0.1",
"express": "5.2.1", "express": "5.2.1",
"express-static-gzip": "3.0.1", "express-static-gzip": "3.0.1",
"express-validator": "7.3.2", "express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"esbuild": "^0.28.0", "esbuild": "^0.24.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"ts-essentials": "catalog:", "ts-essentials": "catalog:",
"tsx": "^4.23.12", "tsx": "^4.19.2",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */ /** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist { enum RegenerateWhitelist {
id, // adding it for completeness, users cannot change ID 'id', // adding it for completeness, users cannot change ID
type, // adding it for completeness, users cannot change ID 'type', // adding it for completeness, users cannot change ID
cue, 'cue',
title, 'title',
note, 'note',
endAction, 'endAction',
timerType, 'timerType',
countToEnd, 'countToEnd',
colour, 'colour',
timeWarning, 'timeWarning',
timeDanger, 'timeDanger',
custom, 'custom',
triggers, 'triggers',
} }
/** /**
+6 -3
View File
@@ -20,9 +20,12 @@ export function createMcpServer(): Server {
{ capabilities: { tools: {}, prompts: {}, resources: {} } }, { capabilities: { tools: {}, prompts: {}, resources: {} } },
); );
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ server.setRequestHandler(
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'], ListToolsRequestSchema,
})); async (): Promise<ListToolsResult> => ({
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
}),
);
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => { server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
const { name, arguments: args = {} } = request.params; const { name, arguments: args = {} } = request.params;
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check" "format:check": "oxfmt --check"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1", "@playwright/test": "1.60.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"oxfmt": "^0.63.0", "oxfmt": "^0.42.0",
"oxlint": "^1.78.0", "oxlint": "^1.57.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^0.17.4",
"rimraf": "catalog:", "rimraf": "catalog:",
"turbo": "2.10.10", "turbo": "2.8.20",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499", "packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
-1
View File
@@ -14,7 +14,6 @@
"nanoid": "^6.0.0" "nanoid": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
+2092 -1107
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog: catalog:
'@types/node': 22.19.11 '@types/node': 22.19.11
rimraf: 6.1.3 rimraf: 6.0.1
ts-essentials: 10.2.1 ts-essentials: 10.1.1
typescript: 7.0.2 typescript: 7.0.2
vitest: 4.1.10 vitest: 4.0.17
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true