refactor: countdown view redesign

- allow selecting multiple events
- follow selected
- remove past events
This commit is contained in:
Carlos Valente
2025-06-03 21:45:17 +02:00
committed by Carlos Valente
parent 93977ebc48
commit b2b115c329
22 changed files with 955 additions and 672 deletions
@@ -57,8 +57,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const { data: customFields } = useCustomFields();
// websocket data
const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } =
useStore(runtimeStore);
const { clock, timer, message, onAir, eventNext, eventNow, runtime, auxtimer1 } = useStore(runtimeStore);
const selectedId = eventNow?.id ?? null;
const nextId = eventNext?.id ?? null;
@@ -1,186 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.countdown {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
/* =================== MAIN - SELECT ===================*/
.event-select {
display: flex;
margin-top: 8vh;
align-items: center;
justify-content: center;
flex-direction: column;
&__title {
font-size: clamp(1.5rem, 2vw, 2rem);
}
&__events {
font-size: clamp(1rem, 1.5vw, 1.5rem);
margin-top: 1em;
overflow-y: auto;
height: 70vh;
width: 60vw;
}
}
.logo {
position: absolute;
top: 2vw;
left: 2vw;
max-width: min(200px, 20vw);
}
/* =================== MAIN - EVENT CONTAINER ===================*/
.countdown-container {
height: 100%;
width: 100%;
gap: min(2vh, 1rem);
padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
display: grid;
grid-template-rows: auto auto auto auto 1fr;
grid-template-columns: 100%;
grid-template-areas:
'header'
'status'
'clock'
'title'
'timers';
/* =================== HEADER + EXTRAS ===================*/
.clock-container {
grid-area: header;
margin-left: auto;
font-weight: 600;
.label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
}
.status {
grid-area: status;
color: var(--label-color-override, $viewer-label-color);
font-size: clamp(2rem, 3.5vw, 3.5rem);
font-weight: 600;
}
/* =================== TIMER + TITLE ===================*/
.timer {
grid-area: clock;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
font-size: 15vw;
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
font-weight: 600;
opacity: 1;
&--paused {
opacity: $viewer-opacity-disabled;
}
&--finished {
color: $timer-finished-color;
}
}
.title {
grid-area: title;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: clamp(40px, 4.5vw, 80px);
color: var(--accent-color-override, $accent-color);
line-height: 1.1em;
text-align: center;
}
/* =================== FOOTER TIMERS ===================*/
.timer-group {
grid-area: timers;
display: grid;
grid-template-areas:
'projected-start projected-end'
'scheduled-start scheduled-end';
grid-template-columns: 1fr 1fr;
justify-items: center;
text-align: center;
row-gap: clamp(1rem, 5vh, 4rem);
align-self: flex-end;
height: fit-content;
&__projected-start {
grid-area: projected-start;
}
&__projected-end {
grid-area: projected-end;
}
&__scheduled-start {
grid-area: scheduled-start;
}
&__scheduled-end {
grid-area: scheduled-end;
}
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
margin-top: 0.5rem;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed {
color: $delay-color;
}
}
}
}
}
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.countdown {
.logo img {
height: min(50px, 10vh);
}
}
}
@@ -1,164 +0,0 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
OntimeEntry,
OntimeEvent,
Playback,
ProjectData,
Runtime,
Settings,
TimerPhase,
} from 'ontime-types';
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect';
import './Countdown.scss';
interface CountdownProps {
events: OntimeEvent[];
general: ProjectData;
isMirrored: boolean;
runtime: Runtime;
selectedId: string | null;
settings: Settings | undefined;
time: ViewExtendedTimer;
}
export default function Countdown(props: CountdownProps) {
const { events, general, isMirrored, runtime, selectedId, settings, time } = props;
const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation();
const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useWindowTitle('Countdown');
// eg. http://localhost:4001/countdown?eventId=ei0us
// update data to the event we are following
useEffect(() => {
if (!events) {
return;
}
const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event');
// if there is no event selected, we reset the data
if (!eventId && !eventIndex) {
setFollow(null);
return;
}
let followThis: OntimeEvent | null = null;
if (eventId !== null) {
followThis = events.find((event) => event.id === eventId) || null;
} else if (eventIndex !== null) {
followThis = events?.[Number(eventIndex) - 1];
}
if (followThis !== null) {
setFollow(followThis);
const idx: number = events.findIndex((event: OntimeEntry) => event.id === followThis?.id);
const delayToEvent = events[idx]?.delay ?? 0;
setDelay(delayToEvent);
}
}, [events, searchParams]);
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.phase === TimerPhase.Overtime;
const isRunningFinished = finished && runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock);
const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
follow?.timeStart,
follow?.timeEnd,
delay,
runtime.offset,
);
const hideSeconds = searchParams.get('hideTimerSeconds');
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: isStringBoolean(hideSeconds),
removeLeadingZero: false,
});
const persistParam = () => {
const eventId = searchParams.get('eventid');
if (eventId !== null) {
return { id: 'eventid', value: eventId };
}
const eventIndex = searchParams.get('event');
if (eventIndex !== null) {
return { id: 'eventindex', value: eventIndex };
}
return undefined;
};
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const viewOptions = getCountdownOptions(defaultFormat, persistParam());
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<ViewParamsEditor viewOptions={viewOptions} />
{follow === null ? (
<CountdownSelect events={events} />
) : (
<div className='countdown-container' data-testid='countdown-event'>
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
</div>
{runningMessage !== TimerMessage.unhandled && (
<div className='status'>{getLocalizedString(`countdown.${runningMessage}`)}</div>
)}
<SuperscriptTime
time={formattedTimer}
className={`timer ${standby ? 'timer--paused' : ''} ${isRunningFinished ? 'timer--finished' : ''}`}
/>
{follow?.title && <div className='title'>{follow.title}</div>}
<div className='timer-group'>
{projectedStart && projectedEnd && (
<div className='timer-group__projected-start'>
<div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
{projectedStart && projectedEnd && (
<div className='timer-group__projected-end'>
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
<div className='timer-group__scheduled-start'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
<div className='timer-group__scheduled-end'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,47 +0,0 @@
import { Link } from 'react-router-dom';
import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeEntry[];
}
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const { getLocalizedString } = useTranslation();
const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEntry.Event) as OntimeEvent[];
return (
<div className='event-select' data-testid='countdown__select'>
<span className='event-select__title'>{getLocalizedString('countdown.select_event')}</span>
<ul className='event-select__events'>
{!events.length ? (
<Empty text='No events in database' />
) : (
filteredEvents.map((event: OntimeEvent, counter: number) => {
const index = counter + 1;
const title = sanitiseTitle(event.title);
const start = formatTime(event.timeStart, scheduleFormat);
const end = formatTime(event.timeEnd, scheduleFormat);
return (
<li key={event.id}>
<Link to={`/countdown?eventid=${event.id}`}>{`${index}. ${start}${end} | ${title}`}</Link>
</li>
);
})
)}
</ul>
</div>
);
}
@@ -1,111 +0,0 @@
import { ViewExtendedTimer } from 'common/models/TimeManager.type';
import { OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
describe('sanitiseTitle() function', () => {
it('should return a title when valid', () => {
const validTitles = ['Test', 'test', 'test000', '...', 'test0999', 'test%&'];
for (const title of validTitles) {
expect(sanitiseTitle(title)).toBe(title);
}
});
it('should return {no title} when invalid', () => {
const invalidTitles = ['', undefined, null];
for (const title of invalidTitles) {
expect(sanitiseTitle(title as unknown as string)).toBe('{no title}');
}
});
});
describe('fetchTimerData() function', () => {
it('shows current timer if current is the one we follow', () => {
const followId = 'testId';
const currentMockValue = 13;
const follow = { id: followId } as OntimeEvent;
const time = { current: currentMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, followId, 0);
expect(message).toBe(TimerMessage.running);
expect(timer).toBe(currentMockValue);
});
it('shows the countdown to an upcoming event', () => {
const startMockValue = 10000;
const timeNow = 1000;
const follow = { id: 'anotherevent', timeStart: startMockValue } as OntimeEvent;
const time = { clock: timeNow } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
it('shows the timer of a scheduled event that hasnt started', () => {
const startMockValue = 10000;
const endMockValue = 20000;
const timeNow = 15000;
const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
const time = { clock: timeNow, current: endMockValue - startMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(endMockValue - startMockValue);
});
it('shows the end time of a finished event', () => {
const startMockValue = 10000;
const endMockValue = 20000;
const timeNow = 30000;
const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
const time = { clock: timeNow, current: endMockValue - startMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
expect(message).toBe(TimerMessage.ended);
expect(timer).toBe(endMockValue);
});
it('handle an idle event that finishes after midnight', () => {
const startMockValue = 10000;
const endMockValue = 1000;
const timeNow = 15000;
const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
});
it('handle an current event that finishes after midnight', () => {
const startMockValue = 10000;
const endMockValue = 1000;
const timeNow = 15000;
const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, followId, 0);
expect(message).toBe(TimerMessage.running);
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
});
it('handle an event that finishes after midnight but hasnt started', () => {
const startMockValue = 10000;
const endMockValue = 1000;
const timeNow = 2000;
const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue } as OntimeEvent;
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue } as ViewExtendedTimer;
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent', 0);
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
});
@@ -1,106 +0,0 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage {
toStart = 'to_start',
waiting = 'waiting',
running = 'running',
ended = 'ended',
unhandled = '',
}
/**
* Parses string as a title
*/
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
/**
* Returns a parsed timer and relevant status message
*/
export const fetchTimerData = (
time: ViewExtendedTimer,
follow: OntimeEvent | null,
selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => {
if (follow === null) {
return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) {
// if it is selected, it may not be running
return {
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
timer: time.current ?? 0,
};
}
const showProjected = getShouldShowProjected();
const addedTime = showProjected ? offset : 0;
if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// running timer timer is not the one we are following
// ends day after
if (follow.timeStart > follow.timeEnd) {
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
};
/**
* Gets values for the timer items
*/
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
if (start == null || end == null) {
return {
scheduledStart: '',
scheduledEnd: '',
projectedStart: '',
projectedEnd: '',
};
}
const showProjected = getShouldShowProjected();
const scheduledStart = formatTime(start + delay);
const scheduledEnd = formatTime(end + delay);
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
return {
scheduledStart,
scheduledEnd,
projectedStart,
projectedEnd,
};
}
/**
* Gets from the URL whether the showProjected option is active
*/
function getShouldShowProjected() {
const params = new URL(document.location.href).searchParams;
return isStringBoolean(params.get('showProjected'));
}
@@ -1,32 +0,0 @@
import { getTimeOption, hideTimerSeconds, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ParamField, ViewOption } from '../../../common/components/view-params-editor/types';
const makePersistedField = (id: string, value: string): ParamField => {
return {
id,
title: 'Used to keep the selection on submit',
description: 'Used to keep the selection on submit',
type: 'persist',
value,
};
};
type Persisted = { id: string; value: string };
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds] },
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
],
},
];