feat(countdown): allow counting down to a group

This commit is contained in:
Claude
2026-06-24 14:52:18 +00:00
committed by Carlos Valente
parent 161ab3fe13
commit 4addb50ca4
15 changed files with 427 additions and 48 deletions
@@ -4,7 +4,9 @@
color: $white-10;
.empty {
width: 100%;
display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8;
}
@@ -13,6 +15,6 @@
margin-inline: auto;
font-weight: 600;
font-size: 2em;
max-width: 600px;
max-width: min(100%, 600px);
}
}
@@ -18,6 +18,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft',
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start',
@@ -18,6 +18,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso',
'countdown.group_running': 'Evento en grupo en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar',
@@ -18,6 +18,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.group_running': 'Évènement du groupe en cours',
'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans',
@@ -18,6 +18,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.group_running': 'Evento nel gruppo in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza',
@@ -18,6 +18,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento',
'countdown.group_running': 'Evento em grupo em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar',
+106 -3
View File
@@ -59,6 +59,7 @@ $item-height: 3.5rem;
right: $view-inline-padding;
display: flex;
align-items: center;
justify-content: end;
gap: 2rem;
@@ -72,14 +73,27 @@ $item-height: 3.5rem;
}
/* ========================= LIST ========================*/
.empty-container {
.empty-state {
min-height: min(56vh, 36rem);
padding-top: clamp(4rem, 14vh, 9rem);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
button {
margin-top: 2rem;
margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
}
@@ -88,7 +102,7 @@ $item-height: 3.5rem;
flex-direction: column;
overflow-y: auto;
padding-bottom: 60vh;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* ====================== LIST-ITEM ======================*/
@@ -104,6 +118,7 @@ $item-height: 3.5rem;
column-gap: 1rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem;
border: 1px solid transparent;
border-radius: $element-border-radius;
&:hover {
@@ -115,18 +130,41 @@ $item-height: 3.5rem;
.sub--selected {
background: $blue-700;
box-shadow: inset 0 0 0 1px $blue-500;
}
.sub--live {
background-color: $active-green;
box-shadow: inset 0 0 0 1px color-mix(in srgb, $active-green 75%, $ui-white);
}
.sub--armed {
background-color: $gray-1325;
}
.sub--in-group {
position: relative;
margin-left: 1rem;
&::before {
content: '';
position: absolute;
left: -0.5rem;
top: -0.25rem;
bottom: -0.25rem;
width: 2px;
border-radius: 1px;
background: var(--group-color, transparent);
}
.sub__binder {
box-shadow: inset 3px 0 0 var(--group-color, transparent);
}
}
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
border-radius: $element-border-radius 0 0 $element-border-radius;
grid-area: binder;
}
@@ -138,6 +176,8 @@ $item-height: 3.5rem;
gap: 0.25em;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.sub__schedule--delayed {
@@ -156,22 +196,73 @@ $item-height: 3.5rem;
color: $playback-under;
}
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
box-shadow: none;
}
.sub__title {
font-weight: 700;
}
.sub__schedule,
.sub__status,
.sub__label {
color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.sub--group.sub--selected {
box-shadow: inset 0 0 0 1px $blue-500;
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 22%, transparent), transparent 42%),
$blue-700;
.sub__schedule,
.sub__status,
.sub__label,
.sub__eyebrow {
color: $ui-white;
}
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
min-width: 0;
font-size: $title-font-size;
line-height: 1.1em;
overflow-wrap: anywhere;
}
.sub__eyebrow {
display: block;
margin-bottom: 0.15rem;
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: $timer-label-size;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
}
.sub__secondary {
grid-area: secondary;
padding-bottom: 0.5rem;
min-width: 0;
font-size: $base-font-size;
line-height: 1.1em;
color: var(--label-color-override, $viewer-label-color);
// allow multi-line text but trim before
white-space: pre-line;
overflow-wrap: anywhere;
}
.sub__status {
@@ -181,6 +272,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size;
text-align: right;
text-transform: uppercase;
white-space: nowrap;
}
.sub__label {
@@ -190,6 +282,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-align: right;
white-space: nowrap;
}
.sub__timer {
@@ -199,6 +292,16 @@ $item-height: 3.5rem;
line-height: 1.1em;
font-weight: 600;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.selection-count {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
/* ====================== MODIFIERS ======================*/
+42 -18
View File
@@ -1,4 +1,14 @@
import { EntryId, OntimeEvent, OntimeView, PlayableEvent, isOntimeEvent, isPlayableEvent } from 'ontime-types';
import {
EntryId,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeView,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
@@ -15,7 +25,7 @@ import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils';
import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils';
import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown';
@@ -45,13 +55,19 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false);
// gather rundown data
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
return isOntimeEvent(entry) && isPlayableEvent(entry);
// gather subscribable items: playable events and groups that contain at least one playable child
const candidates = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent | OntimeGroup> => {
if (isOntimeEvent(entry)) {
return isPlayableEvent(entry);
}
if (isOntimeGroup(entry)) {
return rundownData.some((item) => isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id);
}
return false;
});
// gather presentation data
const hasEvents = playableEvents.length > 0;
const hasEvents = candidates.length > 0;
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -69,15 +85,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
<CountdownClock />
</div>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div>
)}
{hasEvents && editMode && (
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
<CountdownSelect events={candidates} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)}
{hasEvents && !editMode && (
<CountdownContents
playableEvents={playableEvents}
candidates={candidates}
rundownData={rundownData}
subscriptions={subscriptions}
goToEditMode={() => setEditMode(true)}
/>
@@ -87,19 +108,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
}
interface CountdownContentsProps {
playableEvents: ExtendedEntry<OntimeEvent>[];
candidates: ExtendedEntry<OntimeEvent | OntimeGroup>[];
rundownData: ExtendedEntry<OntimeEntry>[];
subscriptions: EntryId[];
goToEditMode: () => void;
}
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
function CountdownContents({ candidates, rundownData, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -107,13 +129,15 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
);
}
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
const subscribedEvents = getOrderedSubscriptions(subscriptions, candidates)
.map((entry) => resolveSubscriptionTarget(entry, rundownData))
.filter((target): target is NonNullable<typeof target> => target !== null);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -129,8 +153,8 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
if (eventsToShow.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div>
);
}
@@ -1,4 +1,4 @@
import { EntryId, PlayableEvent } from 'ontime-types';
import { EntryId, OntimeEvent, OntimeGroup, isOntimeGroup } from 'ontime-types';
import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss';
interface CountdownSelectProps {
events: ExtendedEntry<PlayableEvent>[];
events: ExtendedEntry<OntimeEvent | OntimeGroup>[];
subscriptions: EntryId[];
disableEdit: () => void;
}
@@ -52,13 +52,15 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
return (
<div className='list-container'>
{events.map((event, index) => {
{events.map((event) => {
const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id);
const isGroup = isOntimeGroup(event);
const isGroupedEvent = !isGroup && Boolean(event.parent);
return (
<div
key={index}
key={event.id}
role='button'
tabIndex={0}
onClick={() => toggleSelect(event.id)}
@@ -68,21 +70,33 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
e.stopPropagation();
}
}}
className={cx(['sub', isSelected && 'sub--selected'])}
className={cx([
'sub',
isSelected && 'sub--selected',
isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
>
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
<div
className='sub__binder'
style={{ '--user-color': event?.colour ?? '', '--group-color': event.groupColour ?? 'transparent' }}
/>
<div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div>
<div className='sub__title'>
{isGroup && <span className='sub__eyebrow'>Group</span>}
{title}
</div>
</div>
);
})}
<div className='fab-container'>
<div className='selection-count'>{selected.length} selected</div>
<Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back
</Button>
@@ -19,6 +19,7 @@ import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import {
CountdownEvent,
CountdownTarget,
extendEventData,
getIsLive,
isOutsideRange,
@@ -30,7 +31,7 @@ import {
import './Countdown.scss';
interface CountdownSubscriptionsProps {
subscribedEvents: ExtendedEntry<OntimeEvent>[];
subscribedEvents: CountdownTarget[];
goToEditMode: () => void;
}
@@ -98,22 +99,39 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource);
const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
// 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={event.id}
ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
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 }} />
<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'])}>{displayTitle}</div>
<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>
);
@@ -11,13 +11,13 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { useSubscriptionDisplayData } from './countdown.utils';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
interface SingleEventCountdownProps {
subscribedEvent: ExtendedEntry<OntimeEvent>;
subscribedEvent: CountdownTarget;
goToEditMode: () => void;
}
@@ -38,11 +38,13 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
// while a group is live, surface the running event's title as the secondary line
const liveTitle = subscribedEvent.isGroup && subscribedEvent.liveEntry ? subscribedEvent.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(subscribedEvent, secondarySource);
return (
<div className='single-container' data-testid='countdown-event'>
@@ -80,7 +80,7 @@ export const getCountdownOptions = (
{
id: 'sub',
title: 'Event subscription',
description: 'The events to follow',
description: 'The events and groups to follow',
values: persistedSubscriptions,
type: 'persist',
},
@@ -0,0 +1,128 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
* Only the fields exercised by resolveSubscriptionTarget are provided; the rest are cast away.
*/
function makeEvent(patch: Partial<ExtendedEntry<OntimeEvent>>): ExtendedEntry<OntimeEvent> {
return {
id: 'event',
type: SupportedEntry.Event,
title: 'event title',
cue: '1',
colour: '',
skip: false,
parent: null,
timeStart: 0,
timeEnd: 0,
duration: 0,
delay: 0,
dayOffset: 0,
gap: 0,
countToEnd: false,
custom: {},
note: '',
// metadata
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
isPast: false,
...patch,
} as ExtendedEntry<OntimeEvent>;
}
function makeGroup(patch: Partial<ExtendedEntry<OntimeGroup>>): ExtendedEntry<OntimeGroup> {
return {
id: 'group',
type: SupportedEntry.Group,
title: 'group title',
colour: '#abcdef',
note: 'group note',
entries: [],
duration: 0,
custom: {},
timeStart: 0,
timeEnd: 0,
isPast: false,
isLoaded: false,
...patch,
} as ExtendedEntry<OntimeGroup>;
}
describe('resolveSubscriptionTarget()', () => {
it('returns events unchanged', () => {
const event = makeEvent({ id: 'e1' });
const result = resolveSubscriptionTarget(event, [event]);
expect(result).toBe(event);
});
it('derives group timing from the first playable child while keeping the group identity', () => {
const group = makeGroup({ id: 'g1', title: 'Session A', colour: '#123456', duration: 5000 });
const child1 = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1000, delay: 100, dayOffset: 0, title: 'Pres 1' });
const child2 = makeEvent({ id: 'c2', parent: 'g1', timeStart: 3000, title: 'Pres 2' });
const flat: ExtendedEntry<OntimeEntry>[] = [group, child1, child2];
const result = resolveSubscriptionTarget(group, flat);
expect(result).not.toBeNull();
// group identity and display
expect(result?.id).toBe('g1');
expect(result?.title).toBe('Session A');
expect(result?.colour).toBe('#123456');
expect(result?.isGroup).toBe(true);
// timing comes from the first child, duration from the group
expect(result?.timeStart).toBe(1000);
expect(result?.delay).toBe(100);
expect(result?.duration).toBe(5000);
expect(result?.countToEnd).toBe(false);
// state checks target the first child before any child is loaded
expect(result?.targetId).toBe('c1');
// report lookup targets the last child (session end)
expect(result?.reportId).toBe('c2');
});
it('returns null for a group with no playable children', () => {
const group = makeGroup({ id: 'g1' });
const result = resolveSubscriptionTarget(group, [group]);
expect(result).toBeNull();
});
it('skips skipped children when picking the first child', () => {
const group = makeGroup({ id: 'g1' });
const skipped = {
...makeEvent({ id: 'c0', parent: 'g1', timeStart: 500 }),
skip: true,
} as ExtendedEntry<OntimeEntry>;
const playable = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1500 });
const result = resolveSubscriptionTarget(group, [group, skipped, playable]);
expect(result?.timeStart).toBe(1500);
expect(result?.reportId).toBe('c1');
});
it('is live while any child is loaded and not past', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isLoaded: true });
const child3 = makeEvent({ id: 'c3', parent: 'g1' });
const result = resolveSubscriptionTarget(group, [group, child1, child2, child3]);
expect(result?.isLoaded).toBe(true);
expect(result?.isPast).toBe(false);
expect(result?.liveEntry?.id).toBe('c2');
});
it('is past only once the last child is past and nothing is loaded', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isPast: true });
const result = resolveSubscriptionTarget(group, [group, child1, child2]);
expect(result?.isLoaded).toBe(false);
expect(result?.isPast).toBe(true);
expect(result?.targetId).toBe('c1');
expect(result?.liveEntry).toBeNull();
});
});
@@ -1,4 +1,17 @@
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
import {
EntryId,
MaybeNumber,
OffsetMode,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeReport,
Playback,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
@@ -40,7 +53,7 @@ export const timerProgress: TimerMessage = {
* Handles events in different days but disregards whether an event has actually played
*/
export function useSubscriptionDisplayData(
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation();
@@ -67,7 +80,11 @@ export function useSubscriptionDisplayData(
return {
status: 'live',
statusDisplay: getLocalizedString(timerProgress['live']),
statusDisplay: getLocalizedString(
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
? 'countdown.group_running'
: timerProgress['live'],
),
timeDisplay: bigDuration(current ?? 0),
};
}
@@ -168,10 +185,75 @@ export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE;
}
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
/**
* A subscription target normalised to the event-shaped object the countdown display consumes.
* For events this is a pass-through; for groups it carries the group identity and display data
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
*/
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
isGroup?: boolean;
targetId?: EntryId; // child entry used for countdown/live/armed state checks
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
* - Events are returned unchanged.
* - Groups count down to their first playable child while displaying the group identity.
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
* Returns null when a group has no playable children (nothing to count down to).
*/
export function resolveSubscriptionTarget(
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
flatRundown: ExtendedEntry<OntimeEntry>[],
): CountdownTarget | null {
if (!isOntimeGroup(entry)) {
return entry;
}
const children = flatRundown.filter(
(item): item is ExtendedEntry<PlayableEvent> =>
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
);
if (children.length === 0) {
return null;
}
const firstChild = children[0];
const lastChild = children[children.length - 1];
const liveEntry = children.find((child) => child.isLoaded) ?? null;
const isLoaded = liveEntry !== null;
const isPast = !isLoaded && lastChild.isPast;
return {
// timing derives from the first playable child
...firstChild,
// group identity and display data
id: entry.id,
parent: null,
title: entry.title,
colour: entry.colour,
note: entry.note,
custom: entry.custom,
duration: entry.duration,
countToEnd: false,
// group-aware status
isLoaded,
isPast,
// group markers
isGroup: true,
targetId: firstChild.id,
reportId: lastChild.id,
liveEntry,
};
}
export function extendEventData(
event: ExtendedEntry<OntimeEvent>,
event: CountdownTarget,
currentDay: number,
actualStart: MaybeNumber,
plannedStart: MaybeNumber,
@@ -189,6 +271,6 @@ export function extendEventData(
offset,
mode,
});
const { endedAt } = reportData[event.id] ?? { endedAt: null };
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt };
}
+1
View File
@@ -16,6 +16,7 @@ export const langEn = {
'common.no_data': 'No data',
'countdown.ended': 'Event ended at',
'countdown.running': 'Event running',
'countdown.group_running': 'Event in group running',
'countdown.loaded': 'Event loaded',
'countdown.select_event': 'Select an event to follow',
'countdown.to_start': 'Time to start',