feat(timeline): add group reference

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Claude
2026-06-29 14:34:18 +00:00
committed by Carlos Valente
parent 48f9fb2609
commit 52cae1e22f
12 changed files with 113 additions and 66 deletions
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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 {
+1 -4
View File
@@ -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<string, ProgressStatus> = {};
@@ -105,6 +101,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
title={displayTitle}
cue={event.cue}
width={position.width}
groupColour={event.groupColour}
/>
);
})}
@@ -26,6 +26,7 @@ interface TimelineEntryProps {
title: string;
width: number;
cue: string;
groupColour?: string;
ref?: RefObject<HTMLDivElement | null>;
}
@@ -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' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div className={style.groupBand} />
<div
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status}
@@ -153,6 +157,8 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
}
const isDue = status === 'future' && timeToStart <= 0;
@@ -38,6 +38,8 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId();
const { mainSource, timeformat } = useTimelineOptions();
const { getLocalizedString } = useTranslation();
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -50,6 +52,8 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
const hasContent = totalDuration > 0;
return (
<div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
@@ -61,12 +65,16 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedEventId}
totalDuration={totalDuration}
/>
{hasContent ? (
<Timeline
firstStart={firstStart}
rundown={scopedRundown}
selectedEventId={selectedEventId}
totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div>
);
}
@@ -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') ?? '-';
@@ -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<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | 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<OntimeEntry>[],
selectedEventId: MaybeString,
hidePast: boolean,
): ScopedRundownData {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart: number | null = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | 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<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
+1
View File
@@ -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',