From e4cfa1d17d8060934ab83b660ff68f7dbff131d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 17:36:56 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL --- .../src/common/hooks/useFollowComponent.ts | 18 ++- .../features/operator/Operator.module.scss | 8 ++ .../client/src/features/operator/Operator.tsx | 24 +++- .../operator-group/OperatorGroup.module.scss | 19 ++- .../operator/operator-group/OperatorGroup.tsx | 15 ++- .../client/src/views/countdown/Countdown.scss | 36 +++++- .../countdown/CountdownSubscriptions.tsx | 108 ++++++++++++------ .../views/countdown/countdown.utils.test.ts | 88 +++++++++++++- .../src/views/countdown/countdown.utils.ts | 38 ++++++ 9 files changed, 299 insertions(+), 55 deletions(-) diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts index a757b2e9c..7b03d70b1 100644 --- a/apps/client/src/common/hooks/useFollowComponent.ts +++ b/apps/client/src/common/hooks/useFollowComponent.ts @@ -22,6 +22,11 @@ interface UseFollowComponentProps { scrollRef: RefObject; 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 } @@ -31,6 +36,7 @@ export default function useFollowComponent({ scrollRef, doFollow, topOffset = 100, + getTopOffset, setScrollFlag, followTrigger, }: UseFollowComponentProps) { @@ -44,21 +50,23 @@ export default function useFollowComponent({ setScrollFlag?.(true); // Use requestAnimationFrame to ensure the component is fully loaded window.requestAnimationFrame(() => { - scrollToComponent(followRef as RefObject, scrollRef as RefObject, topOffset); + // resolve the offset after layout, so that measured values are up to date + const offset = getTopOffset?.() ?? topOffset; + scrollToComponent(followRef as RefObject, scrollRef as RefObject, offset); setScrollFlag?.(false); }); } - }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); + }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]); const scrollToRefComponent = useCallback( - (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { + (componentRef = followRef, containerRef = scrollRef, offset?: number) => { 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); + scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset); } }, - [followRef, scrollRef, topOffset], + [followRef, scrollRef, topOffset, getTopOffset], ); return scrollToRefComponent; diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index a8bcfa5a5..6b410b5b2 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -18,6 +18,14 @@ padding-bottom: 95vh; } +/* a group and its events, bounds the sticky group header to its own section */ +.groupSection { + display: flex; + flex-direction: column; + /* the rows used to be direct children of the list, keep the gap they had */ + gap: 2px; +} + .editPrompt { position: fixed; z-index: $zindex-dialog; diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index f1b6ffc41..ea5511ebb 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,5 +1,5 @@ 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 EmptyPage from '../../common/components/state/EmptyPage'; @@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(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, - topOffset: selectedOffset, + getTopOffset, followTrigger: selectedEventId, }); @@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator const selectedRect = selectedRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect(); 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; setLockAutoScroll(hasScrolledOutOfThreshold); } @@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator } return ( - +
{entry.entries.map((nestedEntryId) => { const nestedEntry = rundown.entries[nestedEntryId]; @@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator /> ); })} - +
); } return null; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss index b545380a2..5873bad92 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss @@ -3,17 +3,32 @@ min-height: 2.5rem; padding: 0.4rem 0.75rem; border-left: 0.35rem solid var(--group-colour, $gray-500); - background-color: $gray-1350; - background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); + /* 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 */ 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; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx index eb8036c2f..b421ab37d 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx @@ -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 style from './OperatorGroup.module.scss'; @@ -10,15 +10,22 @@ interface OperatorGroup { colour: string; count: number; duration: number; + /** whether the group contains the currently running event */ + isLive?: boolean; + ref?: Ref; } 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 groupColours = getAccessibleColour(groupColour); return ( -
+
{title} {`${count} ${count === 1 ? 'event' : 'events'}`} diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss index e1bd75235..f72aaab84 100644 --- a/apps/client/src/views/countdown/Countdown.scss +++ b/apps/client/src/views/countdown/Countdown.scss @@ -103,6 +103,22 @@ $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 */ + .sub-section { + display: flex; + flex-direction: column; + /* flex-grow is set inline from the row count, so that the rows keep the share they had in the list */ + flex: 1 1 0; + } + + /* 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; @@ -196,9 +212,15 @@ $item-height: 3.5rem; .sub--group { 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%), - 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 { 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 { grid-area: title; padding-bottom: 0.5rem; diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index ced544457..eb5ef00ed 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -1,6 +1,6 @@ import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; -import { useEffect, useRef, useState } from 'react'; +import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IoPencil } from 'react-icons/io5'; import Button from '../../common/components/buttons/Button'; @@ -22,6 +22,7 @@ import { CountdownTarget, extendEventData, getIsLive, + groupSubscriptionTargets, isOutsideRange, preferredFormat12, preferredFormat24, @@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(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 + 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, - topOffset: 0, + getTopOffset: getStickyOffset, followTrigger: selectedEventId, }); @@ -79,7 +91,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode const selectedRect = selectedRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect(); 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; setLockAutoScroll(hasScrolledOutOfThreshold); } @@ -96,43 +109,68 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode throttledHandleScroll(); }; + const renderRow = (event: CountdownTarget, rowRef: RefObject | undefined, isLive: boolean) => { + // 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; + const isArmed = !isLive && activeEntryId === selectedEventId; + const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); + const displayTitle = getPropertyValue(event, mainSource ?? 'title'); + + return ( +
+
+ + +
+ {event.isGroup && Group} + {displayTitle} +
+ {secondaryData &&
{secondaryData}
} +
+ ); + }; + return (
- {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'); + {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; + return (
-
- - -
- {event.isGroup && Group} - {displayTitle} -
- {secondaryData &&
{secondaryData}
} + {rows.map((event) => { + 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; + // 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; + + return renderRow(event, rowRef, isLive); + })}
); })} diff --git a/apps/client/src/views/countdown/countdown.utils.test.ts b/apps/client/src/views/countdown/countdown.utils.test.ts index 13ba3a75c..524d71997 100644 --- a/apps/client/src/views/countdown/countdown.utils.test.ts +++ b/apps/client/src/views/countdown/countdown.utils.test.ts @@ -1,7 +1,7 @@ import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; 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. @@ -126,3 +126,89 @@ 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, flat: ExtendedEntry[]): 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] }, + ]); + }); +}); diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 746b38bbe..4c04b34d3 100644 --- a/apps/client/src/views/countdown/countdown.utils.ts +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -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( event: CountdownTarget, currentDay: number,