diff --git a/apps/client/src/common/components/state/Empty.module.scss b/apps/client/src/common/components/state/Empty.module.scss index ea224ae95..345b3057f 100644 --- a/apps/client/src/common/components/state/Empty.module.scss +++ b/apps/client/src/common/components/state/Empty.module.scss @@ -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); } } diff --git a/apps/client/src/translation/languages/de.ts b/apps/client/src/translation/languages/de.ts index 7548a394d..aee4ed763 100644 --- a/apps/client/src/translation/languages/de.ts +++ b/apps/client/src/translation/languages/de.ts @@ -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', diff --git a/apps/client/src/translation/languages/es.ts b/apps/client/src/translation/languages/es.ts index 385d3a343..79f5a7460 100644 --- a/apps/client/src/translation/languages/es.ts +++ b/apps/client/src/translation/languages/es.ts @@ -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', diff --git a/apps/client/src/translation/languages/fr.ts b/apps/client/src/translation/languages/fr.ts index 6d38ea0fb..989951976 100644 --- a/apps/client/src/translation/languages/fr.ts +++ b/apps/client/src/translation/languages/fr.ts @@ -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', diff --git a/apps/client/src/translation/languages/it.ts b/apps/client/src/translation/languages/it.ts index 16c9ddace..7b1e80a0c 100644 --- a/apps/client/src/translation/languages/it.ts +++ b/apps/client/src/translation/languages/it.ts @@ -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', diff --git a/apps/client/src/translation/languages/pt.ts b/apps/client/src/translation/languages/pt.ts index 67bb532db..68ec27336 100644 --- a/apps/client/src/translation/languages/pt.ts +++ b/apps/client/src/translation/languages/pt.ts @@ -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', diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss index 6c32000d9..a5a175c8f 100644 --- a/apps/client/src/views/countdown/Countdown.scss +++ b/apps/client/src/views/countdown/Countdown.scss @@ -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 ======================*/ diff --git a/apps/client/src/views/countdown/Countdown.tsx b/apps/client/src/views/countdown/Countdown.tsx index 6c096b7d2..40db224db 100644 --- a/apps/client/src/views/countdown/Countdown.tsx +++ b/apps/client/src/views/countdown/Countdown.tsx @@ -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 => { - 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 => { + 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 - {!hasEvents && } + {!hasEvents && ( +
+ +
+ )} {hasEvents && editMode && ( - setEditMode(false)} /> + setEditMode(false)} /> )} {hasEvents && !editMode && ( setEditMode(true)} /> @@ -87,19 +108,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting } interface CountdownContentsProps { - playableEvents: ExtendedEntry[]; + candidates: ExtendedEntry[]; + rundownData: ExtendedEntry[]; 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 ( -
- +
+ @@ -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 => target !== null); const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast); if (subscribedEvents.length === 0) { return ( -
- +
+ @@ -129,8 +153,8 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun if (eventsToShow.length === 0) { return ( -
- +
+
); } diff --git a/apps/client/src/views/countdown/CountdownSelect.tsx b/apps/client/src/views/countdown/CountdownSelect.tsx index c36ce0fd8..2550715db 100644 --- a/apps/client/src/views/countdown/CountdownSelect.tsx +++ b/apps/client/src/views/countdown/CountdownSelect.tsx @@ -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[]; + events: ExtendedEntry[]; subscriptions: EntryId[]; disableEdit: () => void; } @@ -52,13 +52,15 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }: return (
- {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 (
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', + ])} > -
+
{isSelected ? 'Click to remove' : 'Click to add'}
-
{title}
+
+ {isGroup && Group} + {title} +
); })}
+
{selected.length} selected
diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index 408dc3341..571af12d3 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -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[]; + subscribedEvents: CountdownTarget[]; goToEditMode: () => void; } @@ -98,22 +99,39 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode return (
{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 (
-
+
-
{displayTitle}
+
+ {event.isGroup && Group} + {displayTitle} +
{secondaryData &&
{secondaryData}
}
); diff --git a/apps/client/src/views/countdown/SingleEventCountdown.tsx b/apps/client/src/views/countdown/SingleEventCountdown.tsx index d567d3263..82003e03f 100644 --- a/apps/client/src/views/countdown/SingleEventCountdown.tsx +++ b/apps/client/src/views/countdown/SingleEventCountdown.tsx @@ -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; + 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 (
diff --git a/apps/client/src/views/countdown/countdown.options.ts b/apps/client/src/views/countdown/countdown.options.ts index 788a3bd48..6f55f522f 100644 --- a/apps/client/src/views/countdown/countdown.options.ts +++ b/apps/client/src/views/countdown/countdown.options.ts @@ -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', }, diff --git a/apps/client/src/views/countdown/countdown.utils.test.ts b/apps/client/src/views/countdown/countdown.utils.test.ts new file mode 100644 index 000000000..13ba3a75c --- /dev/null +++ b/apps/client/src/views/countdown/countdown.utils.test.ts @@ -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 { + 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; +} + +function makeGroup(patch: Partial>): ExtendedEntry { + 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; +} + +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[] = [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; + 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(); + }); +}); diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 0ddc68bfe..3a82529f9 100644 --- a/apps/client/src/views/countdown/countdown.utils.ts +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -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 & { 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 & { 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 & { + 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 | 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, + flatRundown: ExtendedEntry[], +): CountdownTarget | null { + if (!isOntimeGroup(entry)) { + return entry; + } + + const children = flatRundown.filter( + (item): item is ExtendedEntry => + 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, + 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 }; } diff --git a/packages/types/src/translations/index.ts b/packages/types/src/translations/index.ts index bc1223990..26e17381c 100644 --- a/packages/types/src/translations/index.ts +++ b/packages/types/src/translations/index.ts @@ -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',