* feat/120: calculate delay to single event
* feat/120 Countdown
* feat/120 prevent parsing NaN
* feat/120: version bump
* feat/120: update README.md
* feat/120: show delayed countdown timer
This commit is contained in:
Carlos Valente
2022-06-30 20:26:01 +02:00
committed by GitHub
parent dcc534f92b
commit f4c5a2bf87
20 changed files with 543 additions and 31 deletions
@@ -2,9 +2,9 @@ import React, { memo } from 'react';
import { formatDisplay } from 'common/utils/dateConfig';
import PropTypes from 'prop-types';
import styles from './Countdown.module.scss';
import styles from './TimerDisplay.module.scss';
const Countdown = ({ time, small, isNegative, hideZeroHours }) => {
const TimerDisplay = ({ time, small, isNegative, hideZeroHours }) => {
// prepare display string
const display =
time != null && !isNaN(time) ? formatDisplay(time, hideZeroHours) : '-- : -- : --';
@@ -19,9 +19,9 @@ const Countdown = ({ time, small, isNegative, hideZeroHours }) => {
);
};
export default memo(Countdown);
export default memo(TimerDisplay);
Countdown.propTypes = {
TimerDisplay.propTypes = {
time: PropTypes.number,
small: PropTypes.bool,
isNegative: PropTypes.bool,
@@ -6,6 +6,7 @@ const navigatorConstants = [
{ url: '/lower', label: 'Lower Thirds' },
{ url: '/pip', label: 'PiP' },
{ url: '/studio', label: 'Studio Clock' },
{ url: '/countdown', label: 'Countdown' },
];
export default navigatorConstants;
+3 -2
View File
@@ -5,9 +5,9 @@ import PropTypes from 'prop-types';
import style from './Empty.module.scss';
export default function Empty(props) {
const { text, ...rest } = props;
const { text, dark, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<div className={`${style.emptyContainer} ${dark ? style.dark : ''}`} {...rest}>
<Emptyimage className={style.empty} />
<span className={style.text}>{text}</span>
</div>
@@ -16,4 +16,5 @@ export default function Empty(props) {
Empty.propTypes = {
text: PropTypes.string,
dark: PropTypes.bool,
}
+20 -10
View File
@@ -2,16 +2,26 @@
.emptyContainer {
width: 100%;
height: 100%;
text-align: center;
}
.empty {
width: 100%;
opacity: 0.3;
}
.text {
color: $bg-black-300;
font-weight: 600;
font-size: 2em;
.empty {
width: 100%;
opacity: 0.3;
}
.text {
font-weight: 600;
font-size: 2em;
}
&.dark {
background: $bg-black;
color: $bg-gray-500;
.empty {
opacity: 1;
}
}
}
@@ -14,6 +14,11 @@ describe('test string from formatDisplay function', () => {
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with not numbers', () => {
const t = { val: 'test', result: '00:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
});
it('test with valid millis', () => {
const t = { val: 3600, result: '01:00:00' };
expect(formatDisplay(t.val, false)).toBe(t.result);
@@ -59,7 +64,7 @@ describe('test formatDisplay handles partial secs', () => {
describe('test string from formatDisplay function with hidezero', () => {
it('test with null values', () => {
const t = { val: null, result: '00:00' };
const t = { val: null, result: '00:00:00' };
expect(formatDisplay(t.val, true)).toBe(t.result);
});
@@ -0,0 +1,56 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
+4
View File
@@ -13,6 +13,10 @@ const mth = 1000 * 60 * 60; // millis to hours
* @returns {string} String representing absolute time 00:12:02
*/
export function formatDisplay(seconds, hideZero = false) {
if (typeof seconds !== 'number') {
return '00:00:00';
}
// add an extra 0 if necessary
const format = (val) => `0${Math.floor(val)}`.slice(-2);
+25
View File
@@ -0,0 +1,25 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { Button } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/react';
import Countdown from 'common/components/countdown/Countdown';
import TimerDisplay from 'common/components/countdown/TimerDisplay';
import PropTypes from 'prop-types';
import { stringFromMillis } from '../../../common/utils/time';
@@ -45,7 +45,7 @@ const PlaybackTimer = (props) => {
<div className={style.indDelay} />
</div>
<div className={style.timer}>
<Countdown
<TimerDisplay
time={isWaiting ? timer.secondary : timer.running}
isNegative={timer.isNegative}
small
@@ -222,6 +222,7 @@ const withSocket = (Component) => {
...timer,
finished: playback === 'start' && timer.isNegative && timer.startedAt,
clock: stringFromMillis(timer.clock),
clockMs: timer.clock,
clockNoSeconds: stringFromMillis(timer.clock, false),
playstate: playback,
};
@@ -1,13 +1,126 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import NavLogo from '../../../common/components/nav/NavLogo';
import Empty from '../../../common/state/Empty';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import getDelayTo from '../../../common/utils/getDelayTo';
import { stringFromMillis } from '../../../common/utils/time';
import { fetchTimerData, sanitiseTitle } from './countdown.helpers';
import style from './Countdown.module.scss';
export default function Countdown(props) {
const [searchParams] = useSearchParams();
const { backstageEvents, time, selectedId } = props;
const [follow, setFollow] = useState(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState('');
const [delay, setDelay] = useState(0);
// Set window title
useEffect(() => {
document.title = 'ontime - Countdown';
}, []);
// eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options
useEffect(() => {
if (!backstageEvents) {
return;
}
const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event');
let followThis = undefined;
const events = [...backstageEvents].filter((event) => event.type === 'event');
if (eventId !== null) {
followThis = events.find((event) => event.id === eventId);
} else if (eventIndex !== null) {
followThis = events?.[eventIndex - 1];
}
if (typeof followThis !== 'undefined') {
setFollow(followThis);
const idx = backstageEvents.findIndex((event) => event.id === followThis.id);
const delay = getDelayTo(backstageEvents, idx);
setDelay(delay);
}
}, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
const standby = time.playstate !== 'start' && selectedId === follow?.id;
return (
<div>
Countdown
<div className={style.container}>
<NavLogo />
{follow === null ? (
<div className={style.eventSelect}>
<span className={style.actionTitle}>Select an event to follow</span>
<ul className={style.events}>
{backstageEvents.length === 0 ? (
<Empty dark text='No events in database' />
) : (
backstageEvents
.filter((e) => e.type === 'event')
.map((event, index) => (
<li key={event.id}>
<Link to={`/countdown?eventid=${event.id}`}>
{`${index + 1}. ${sanitiseTitle(event.title)}`}
</Link>
</li>
))
)}
</ul>
</div>
) : (
<div className={style.countdownContainer}>
<div className={style.timers}>
<div className={style.timer}>
<div className={style.label}>Time Now</div>
<span className={style.value}>{time.clock}</span>
</div>
<div className={style.timer}>
<div className={style.label}>Start Time</div>
<span className={`${style.value} ${delay > 0 ? style.delayed : ''}`}>
{stringFromMillis(follow.timeStart + delay)}
</span>
</div>
<div className={style.timer}>
<div className={style.label}>End Time</div>
<span className={`${style.value} ${delay > 0 ? style.delayed : ''}`}>
{stringFromMillis(follow.timeEnd + delay)}
</span>
</div>
</div>
<div className={style.status}>{runningMessage}</div>
<span className={`${style.countdownClock} ${standby ? style.standby : ''}`}>
{formatDisplay(
time.running ? runningTimer : runningTimer + millisToSeconds(delay),
time.running || time.waiting
)}
</span>
<div className={style.title}>{follow.title || 'Untitled Event'}</div>
</div>
)}
</div>
)
);
}
Countdown.propTypes={
}
Countdown.propTypes = {
backstageEvents: PropTypes.array,
time: PropTypes.object,
selectedId: PropTypes.string,
};
@@ -0,0 +1,110 @@
@use '../../../styles/main' as *;
.container {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
background: $bg-black;
height: 100vh;
color: $title-white;
padding: 1vw;
.eventSelect {
display: flex;
margin-top: 8vh;
align-items: center;
justify-content: center;
font-size: max(1.3vw, 12px);
flex-direction: column;
.actionTitle {
font-size: max(2vw, 14px);
}
.events {
margin-top: 1em;
overflow-y: auto;
height: 70vh;
width: 60vw;
}
}
.countdownContainer {
height: 100%;
width: 100%;
display: grid;
grid-template-rows: 1fr auto auto auto 1fr auto;
grid-template-columns: 100%;
grid-template-areas:
'title'
'status'
'clock'
'y'
'timers';
gap: 1vw;
justify-content: center;
text-align: center;
.timers {
grid-area: timers;
display: flex;
justify-content: space-evenly;
align-items: flex-end;
.timer {
text-align: center;
.label {
font-size: max(1.3vw, 12px);
color: $ontime-pink;
}
.value {
font-family: 'Open Sans', sans-serif;
font-size: max(2vw, 14px);
letter-spacing: 0.3px;
color: #ddd;
&.delayed {
color: $block-delay-color;
}
}
}
}
.title {
grid-area: title;
font-size: max(4vw, 18px);
align-self: center;
color: $ontime-pink;
background-color: $bg-gray-1000;
padding: 1vh 2vw;
border-radius: 1vw;
}
.status {
grid-area: status;
padding-left: 5vw;
font-size: max(2.5vw, 14px);
justify-self: flex-start;
align-self: flex-end;
}
.countdownClock {
grid-area: clock;
line-height: 20vw;
font-size: 21vw;
text-align: center;
letter-spacing: 0.4vw;
align-self: flex-start;
opacity: 1.0;
transition: opacity 0.5s;
&.standby {
opacity: 0.6;
}
}
}
}
@@ -0,0 +1,109 @@
import { DAY_TO_MS } from '../../../../../../server/src/classes/classUtils';
import { millisToSeconds } from '../../../../common/utils/dateConfig';
import { fetchTimerData, sanitiseTitle, timerMessages } from '../countdown.helpers';
describe('sanitiseTitle() function', () => {
test('should return a title when valid', () => {
const validTitles = ['Test', 'test', 'test000', '...', 'test0999', 'test%&'];
for (const title of validTitles) {
expect(sanitiseTitle(title)).toBe(title);
}
});
test('should return {no title} when invalid', () => {
const invalidTitles = ['', undefined, null];
for (const title of invalidTitles) {
expect(sanitiseTitle(title)).toBe('{no title}');
}
});
});
describe('fetchTimerData() function', () => {
it('shows running timer if current is the one we follow', () => {
const followId = 'testId';
const runningMockValue = 13;
const follow = { id: followId };
const time = { running: runningMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(timerMessages.running);
expect(timer).toBe(runningMockValue);
});
it('shows the countdown to an upcoming event', () => {
const startMockValue = 10000;
const timeNow = 1000;
const follow = { id: 'anotherevent', timeStart: startMockValue };
const time = { clockMs: timeNow };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(timerMessages.toStart);
expect(timer).toBe(millisToSeconds(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 };
const time = { clockMs: timeNow, running: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(timerMessages.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 };
const time = { clockMs: timeNow, running: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(timerMessages.ended);
expect(timer).toBe(millisToSeconds(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 };
const time = { clockMs: timeNow, running: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(timerMessages.waiting);
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue);
});
it('handle an running 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 };
const time = { clockMs: timeNow, running: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(timerMessages.running);
expect(timer).toBe(DAY_TO_MS + 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 };
const time = { clockMs: timeNow, running: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(timerMessages.toStart);
expect(timer).toBe(millisToSeconds(startMockValue - timeNow));
});
});
@@ -0,0 +1,68 @@
import { millisToSeconds } from '../../../common/utils/dateConfig';
/**
* @description parses string as a title
* @param {string|null} title
* @return {string}
*/
export const sanitiseTitle = (title) =>
title === null || title === '' || typeof title === 'undefined' ? '{no title}' : title;
/**
* @description object with possible timer messages
* @type {{running: string, toStart: string, waiting: string, ended: string}}
*/
export const timerMessages = {
toStart: 'Time to start',
waiting: 'Waiting for event start',
running: 'Event running',
ended: 'Event ended at',
};
/**
* @description Returns a parsed timer and relevant status message
* @param {object} time
* @param {object} follow
* @param {string} selectedId
* @return {{timer: number, message: string}}
*/
export const fetchTimerData = (time, follow, selectedId) => {
let message = "";
let timer = 0;
if (selectedId === follow.id) {
// check that is not running
message = time.playstate === 'pause' ? timerMessages.waiting : timerMessages.running;
timer = time.running;
} else if (time.clockMs < follow.timeStart) {
// if it hasnt started, we count to start
message = timerMessages.toStart;
timer = millisToSeconds(follow.timeStart - time.clockMs);
} else if (follow.timeStart <= time.clockMs && time.clockMs <= follow.timeEnd) {
// if it has started, we show running timer
message = timerMessages.waiting;
timer = time.running;
} else {
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clockMs ) {
// if it hasnt started, we count to start
message = timerMessages.toStart;
timer = millisToSeconds(follow.timeStart - time.clockMs);
} else if (follow.timeStart <= time.clockMs) {
// if it has started, we show running timer
message = timerMessages.waiting;
timer = time.running;
} else {
// if it has ended, we show how long ago
message = timerMessages.ended;
timer = millisToSeconds(follow.timeEnd);
}
} else {
// if it has ended, we show how long ago
message = timerMessages.ended;
timer = millisToSeconds(follow.timeEnd);
}
}
return { message, timer };
};
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Countdown from 'common/components/countdown/Countdown';
import TimerDisplay from 'common/components/countdown/TimerDisplay';
import MyProgressBar from 'common/components/myProgressBar/MyProgressBar';
import NavLogo from 'common/components/nav/NavLogo';
import TitleCard from 'common/components/views/TitleCard';
@@ -39,7 +39,7 @@ export default function Timer(props) {
// show timer if end message is empty
const endMessage =
general.endMessage == null || general.endMessage === '' ? (
<Countdown
<TimerDisplay
time={time.running}
isNegative={time.isNegative}
hideZeroHours
@@ -90,7 +90,7 @@ export default function Timer(props) {
<div className={style.finished}>{endMessage}</div>
) : (
<div className={isPlaying ? style.countdown : style.countdownPaused}>
<Countdown time={normalisedTime} hideZeroHours />
<TimerDisplay time={normalisedTime} hideZeroHours />
</div>
)}
</div>