From 52cae1e22fd01d7e7496ec18974c9f9006aaca85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 14:34:18 +0000 Subject: [PATCH] feat(timeline): add group reference Co-authored-by: Carlos Valente --- apps/client/src/translation/languages/de.ts | 1 + apps/client/src/translation/languages/es.ts | 1 + apps/client/src/translation/languages/fr.ts | 1 + apps/client/src/translation/languages/it.ts | 1 + apps/client/src/translation/languages/pt.ts | 1 + .../src/views/timeline/Timeline.module.scss | 20 ++- apps/client/src/views/timeline/Timeline.tsx | 5 +- .../src/views/timeline/TimelineEntry.tsx | 6 + .../src/views/timeline/TimelinePage.tsx | 20 ++- .../src/views/timeline/TimelineSections.tsx | 2 +- .../src/views/timeline/timeline.utils.ts | 120 ++++++++++-------- packages/types/src/translations/index.ts | 1 + 12 files changed, 113 insertions(+), 66 deletions(-) diff --git a/apps/client/src/translation/languages/de.ts b/apps/client/src/translation/languages/de.ts index 069c784d5..7548a394d 100644 --- a/apps/client/src/translation/languages/de.ts +++ b/apps/client/src/translation/languages/de.ts @@ -28,6 +28,7 @@ export const langDe: TranslationObject = { 'timeline.done': 'Beendet', 'timeline.due': 'fällig', 'timeline.followedby': 'Gefolgt von', + 'timeline.standby': 'Bereitschaft', 'project.title': 'Titel', 'project.description': 'Beschreibung', 'project.info': 'Projektinfo', diff --git a/apps/client/src/translation/languages/es.ts b/apps/client/src/translation/languages/es.ts index 16ab222a2..385d3a343 100644 --- a/apps/client/src/translation/languages/es.ts +++ b/apps/client/src/translation/languages/es.ts @@ -28,6 +28,7 @@ export const langEs: TranslationObject = { 'timeline.done': 'Terminado', 'timeline.due': 'pendiente', 'timeline.followedby': 'Seguido por', + 'timeline.standby': 'En espera', 'project.title': 'Título', 'project.description': 'Descripción', 'project.info': 'Información del proyecto', diff --git a/apps/client/src/translation/languages/fr.ts b/apps/client/src/translation/languages/fr.ts index 0f8f7d612..6d38ea0fb 100644 --- a/apps/client/src/translation/languages/fr.ts +++ b/apps/client/src/translation/languages/fr.ts @@ -28,6 +28,7 @@ export const langFr: TranslationObject = { 'timeline.done': 'Terminé', 'timeline.due': 'dû', 'timeline.followedby': 'Suivi de', + 'timeline.standby': 'En attente', 'project.title': 'Titre', 'project.description': 'Description', 'project.info': 'Informations du projet', diff --git a/apps/client/src/translation/languages/it.ts b/apps/client/src/translation/languages/it.ts index efc5036a7..16c9ddace 100644 --- a/apps/client/src/translation/languages/it.ts +++ b/apps/client/src/translation/languages/it.ts @@ -28,6 +28,7 @@ export const langIt: TranslationObject = { 'timeline.done': 'Terminato', 'timeline.due': 'previsto', 'timeline.followedby': 'Seguito da', + 'timeline.standby': 'In attesa', 'project.title': 'Titolo', 'project.description': 'Descrizione', 'project.info': 'Informazioni sul progetto', diff --git a/apps/client/src/translation/languages/pt.ts b/apps/client/src/translation/languages/pt.ts index 646137973..67bb532db 100644 --- a/apps/client/src/translation/languages/pt.ts +++ b/apps/client/src/translation/languages/pt.ts @@ -28,6 +28,7 @@ export const langPt: TranslationObject = { 'timeline.done': 'Concluído', 'timeline.due': 'Pendente', 'timeline.followedby': 'Seguido por', + 'timeline.standby': 'Em espera', 'project.title': 'Título', 'project.description': 'Descrição', 'project.info': 'Informações do projeto', diff --git a/apps/client/src/views/timeline/Timeline.module.scss b/apps/client/src/views/timeline/Timeline.module.scss index 0d8ecc7a2..e62fa7948 100644 --- a/apps/client/src/views/timeline/Timeline.module.scss +++ b/apps/client/src/views/timeline/Timeline.module.scss @@ -1,6 +1,8 @@ @use '@/theme/viewerDefs' as *; $timeline-height: 1rem; +$group-band-height: 0.5rem; +$group-band-gap: 0.25rem; $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color)); .timelineContainer { @@ -17,7 +19,21 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over position: relative; font-weight: 600; height: 100%; - box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color); + // reserve the top region (progress bar + gap + group band) with the card background + box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0 + var(--card-background-color-override, $viewer-card-bg-color); +} + +.groupBand { + height: $group-band-height; + margin-top: $group-band-gap; + width: 100%; + background: var(--group-colour, transparent); + display: flex; + align-items: center; + position: relative; + z-index: 2; + overflow: visible; } .column { @@ -92,7 +108,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over border-top: 2px solid var(--background-color-override, $viewer-background-color); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); - text-transform: capitalize; white-space: normal; &[data-status='done'] { @@ -119,6 +134,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over .status { width: fit-content; + text-transform: uppercase; } .status.due { diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx index 8a07ac377..c112a5d18 100644 --- a/apps/client/src/views/timeline/Timeline.tsx +++ b/apps/client/src/views/timeline/Timeline.tsx @@ -55,10 +55,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize); }, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]); - if (totalDuration === 0) { - return null; - } - // Pre-calculate event statuses let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future'; const statusMap: Record = {}; @@ -105,6 +101,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel title={displayTitle} cue={event.cue} width={position.width} + groupColour={event.groupColour} /> ); })} diff --git a/apps/client/src/views/timeline/TimelineEntry.tsx b/apps/client/src/views/timeline/TimelineEntry.tsx index eef9da58f..723c2d0ba 100644 --- a/apps/client/src/views/timeline/TimelineEntry.tsx +++ b/apps/client/src/views/timeline/TimelineEntry.tsx @@ -26,6 +26,7 @@ interface TimelineEntryProps { title: string; width: number; cue: string; + groupColour?: string; ref?: RefObject; } @@ -48,6 +49,7 @@ export function TimelineEntry({ title, width, cue, + groupColour, ref, }: TimelineEntryProps) { const formattedStartTime = formatTime(start, formatOptions); @@ -67,6 +69,7 @@ export function TimelineEntry({ { '--color': colour, '--lighter': lighterColour ?? '', + '--group-colour': groupColour ?? 'transparent', left: `${left}px`, width: `${width}px`, } as CSSProperties @@ -74,6 +77,7 @@ export function TimelineEntry({ data-testid={cue} > {status === 'live' ? :
} +
getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]); + const hasContent = totalDuration > 0; + return (
@@ -61,12 +65,16 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD - + {hasContent ? ( + + ) : ( + + )}
); } diff --git a/apps/client/src/views/timeline/TimelineSections.tsx b/apps/client/src/views/timeline/TimelineSections.tsx index e94219f42..e9d51d25c 100644 --- a/apps/client/src/views/timeline/TimelineSections.tsx +++ b/apps/client/src/views/timeline/TimelineSections.tsx @@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }: const state = useExpectedStartData(); // gather card data - const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-'; + const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby'); const dueText = getLocalizedString('timeline.due').toUpperCase(); const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-'; const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-'; diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index f578f5d0b..a3d9d859a 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -96,63 +96,77 @@ export function useScopedRundown( ): ScopedRundownData { const { hidePast } = useTimelineOptions(); - const data = useMemo(() => { - if (rundown.length === 0) { - return { scopedRundown: [], firstStart: 0, totalDuration: 0 }; - } - - const scopedRundown: ExtendedEntry[] = []; - let selectedIndex = selectedEventId ? Infinity : -1; - let firstStart = null; - let totalDuration = 0; - let lastEntry: ExtendedEntry | null = null; - - for (let i = 0; i < rundown.length; i++) { - const currentEntry = rundown[i]; - // we only deal with playableEvents - if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) { - if (currentEntry.id === selectedEventId) { - selectedIndex = i; - } - - // maybe filter past - if (hidePast && i < selectedIndex) { - continue; - } - - // add to scopedRundown - scopedRundown.push(currentEntry); - - /** - * Derive timers - * This logic is partially from rundownCache.generate - * With the addition of deriving the current day offset - */ - if (firstStart === null) { - firstStart = currentEntry.timeStart; - } - - const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry); - - if (timeFromPrevious === 0) { - totalDuration += currentEntry.duration; - } else if (timeFromPrevious > 0) { - totalDuration += timeFromPrevious + currentEntry.duration; - } else if (timeFromPrevious < 0) { - totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0); - } - if (isNewLatest(currentEntry, lastEntry)) { - lastEntry = currentEntry; - } - } - } - - return { scopedRundown, firstStart: firstStart ?? 0, totalDuration }; - }, [hidePast, rundown, selectedEventId]); + const data = useMemo( + () => computeScopedRundown(rundown, selectedEventId, hidePast), + [hidePast, rundown, selectedEventId], + ); return data; } +/** + * Pure computation behind useScopedRundown: filters to playable events and derives timers + */ +export function computeScopedRundown( + rundown: ExtendedEntry[], + selectedEventId: MaybeString, + hidePast: boolean, +): ScopedRundownData { + if (rundown.length === 0) { + return { scopedRundown: [], firstStart: 0, totalDuration: 0 }; + } + + const scopedRundown: ExtendedEntry[] = []; + let selectedIndex = selectedEventId ? Infinity : -1; + let firstStart: number | null = null; + let totalDuration = 0; + let lastEntry: ExtendedEntry | null = null; + + for (let i = 0; i < rundown.length; i++) { + // we only deal with playableEvents + const currentEntry = rundown[i]; + if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) { + continue; + } + + if (currentEntry.id === selectedEventId) { + selectedIndex = i; + } + + // maybe filter past + if (hidePast && i < selectedIndex) { + continue; + } + + // add to scopedRundown + scopedRundown.push(currentEntry); + + /** + * Derive timers + * This logic is partially from rundownCache.generate + * With the addition of deriving the current day offset + */ + if (firstStart === null) { + firstStart = currentEntry.timeStart; + } + + const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry); + + if (timeFromPrevious === 0) { + totalDuration += currentEntry.duration; + } else if (timeFromPrevious > 0) { + totalDuration += timeFromPrevious + currentEntry.duration; + } else if (timeFromPrevious < 0) { + totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0); + } + if (isNewLatest(currentEntry, lastEntry)) { + lastEntry = currentEntry; + } + } + + return { scopedRundown, firstStart: firstStart ?? 0, totalDuration }; +} + type UpcomingEvents = { now: ExtendedEntry | null; next: ExtendedEntry | null; diff --git a/packages/types/src/translations/index.ts b/packages/types/src/translations/index.ts index 683a32c2c..bc1223990 100644 --- a/packages/types/src/translations/index.ts +++ b/packages/types/src/translations/index.ts @@ -26,6 +26,7 @@ export const langEn = { 'timeline.done': 'done', 'timeline.due': 'due', 'timeline.followedby': 'Followed by', + 'timeline.standby': 'Standby', 'project.title': 'Title', 'project.description': 'Description', 'project.info': 'Project Info',