Compare commits

...

2 Commits

Author SHA1 Message Date
Claude ac927a97cf refactor(hooks): drop unused parameters from follow callback
Both consumers of useFollowComponent call the returned callback with no
arguments, so the ref and offset overrides were dead. Removing them also
drops the guard on refs that can never be falsy.
2026-08-30 10:52:33 +00:00
Carlos Valente 417773e7a9 fix: keep group visible as header for the running event 2026-08-28 12:22:11 +02:00
9 changed files with 302 additions and 80 deletions
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef>, scrollRef: RefObject<ScrollRef | null>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -17,11 +17,17 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
scrollRef.current.scrollTo({ top, behavior: 'smooth' }); scrollRef.current.scrollTo({ top, behavior: 'smooth' });
} }
type TopOffset = number | (() => number);
function resolveTopOffset(topOffset: TopOffset) {
return typeof topOffset === 'function' ? topOffset() : topOffset;
}
interface UseFollowComponentProps { interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; topOffset?: TopOffset;
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
} }
@@ -44,22 +50,16 @@ 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
scrollToComponent(followRef, scrollRef, resolveTopOffset(topOffset));
setScrollFlag?.(false); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(() => {
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { scrollToComponent(followRef, scrollRef, resolveTopOffset(topOffset));
if (componentRef && containerRef) { }, [followRef, scrollRef, topOffset]);
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
@@ -18,6 +18,13 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
/* Bounds the sticky header to its group and restores the surrounding list gap. */
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+24 -13
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,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the list gap below the sticky header.
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, topOffset: getTopOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -82,15 +91,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -186,13 +196,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 +250,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</Fragment> </div>
); );
} }
return null; return null;
@@ -3,17 +3,28 @@
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 inline opaque group colour prevents rows showing through the sticky header. */
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
letter-spacing: 0; letter-spacing: 0;
} }
/* Keep the live indicator legible over any group colour without competing with the live event fill. */
.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 { type CSSProperties, type 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,21 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
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>
+30 -2
View File
@@ -103,6 +103,19 @@ $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)));
} }
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
}
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
.sub { .sub {
margin: 2px; margin: 2px;
@@ -196,9 +209,14 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background: /* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color); linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +248,16 @@ $item-height: 3.5rem;
} }
} }
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, topOffset: getStickyOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -75,15 +87,16 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -98,41 +111,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {sections.map((section) => {
// while a group is live, surface the running event's title as the secondary line const rows = section.group ? [section.group, ...section.events] : section.events;
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; // the running event anchors the scroll, the group header stays pinned above it
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div key={section.group?.id ?? rows[0].id} className='sub-section'>
key={event.id} {rows.map((event) => {
ref={isLive ? selectedRef : undefined} // while a group is live, surface the running event's title as the secondary line
className={cx([ const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
'sub', const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
isLive && 'sub--live', const isGroupedEvent = !event.isGroup && Boolean(event.parent);
isArmed && 'sub--armed', const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
event.isGroup && 'sub--group', // a subscribed group is live when any of its children is the selected/running event
isGroupedEvent && 'sub--in-group', const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
])} const isArmed = !isLive && activeEntryId === selectedEventId;
data-testid={event.cue} // only ever hand the ref to a single row, sharing it would null it out on the next commit
> const isAnchor = isLive && (anchorId === null || event.id === anchorId);
<div const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
className='sub__binder' const countdownEvent = extendEventData(
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }} event,
/> currentDay,
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> actualStart,
<SubscriptionStatus event={countdownEvent} /> plannedStart,
<div className={cx(['sub__title', !displayTitle && 'subdued'])}> offset,
{event.isGroup && <span className='sub__eyebrow'>Group</span>} mode,
{displayTitle} reportData,
</div> );
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils'; import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,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,