mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 17:03:53 +00:00
feat(timeline): add group reference
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user