* 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
@@ -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>