- Countdown
+
+
+ {follow === null ? (
+
+
Select an event to follow
+
+ {backstageEvents.length === 0 ? (
+
+ ) : (
+ backstageEvents
+ .filter((e) => e.type === 'event')
+ .map((event, index) => (
+ -
+
+ {`${index + 1}. ${sanitiseTitle(event.title)}`}
+
+
+ ))
+ )}
+
+
+ ) : (
+
+
+
+
Time Now
+
{time.clock}
+
+
+
Start Time
+
0 ? style.delayed : ''}`}>
+ {stringFromMillis(follow.timeStart + delay)}
+
+
+
+
End Time
+
0 ? style.delayed : ''}`}>
+ {stringFromMillis(follow.timeEnd + delay)}
+
+
+
+
{runningMessage}
+
+ {formatDisplay(
+ time.running ? runningTimer : runningTimer + millisToSeconds(delay),
+ time.running || time.waiting
+ )}
+
+
{follow.title || 'Untitled Event'}
+
+ )}
- )
+ );
}
-Countdown.propTypes={
-
-}
\ No newline at end of file
+Countdown.propTypes = {
+ backstageEvents: PropTypes.array,
+ time: PropTypes.object,
+ selectedId: PropTypes.string,
+};
diff --git a/client/src/features/viewers/countdown/Countdown.module.scss b/client/src/features/viewers/countdown/Countdown.module.scss
new file mode 100644
index 000000000..21ef47223
--- /dev/null
+++ b/client/src/features/viewers/countdown/Countdown.module.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/client/src/features/viewers/countdown/__tests__/Countdown.test.js b/client/src/features/viewers/countdown/__tests__/Countdown.test.js
new file mode 100644
index 000000000..90110fab5
--- /dev/null
+++ b/client/src/features/viewers/countdown/__tests__/Countdown.test.js
@@ -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));
+ });
+});
diff --git a/client/src/features/viewers/countdown/countdown.helpers.js b/client/src/features/viewers/countdown/countdown.helpers.js
new file mode 100644
index 000000000..885636c34
--- /dev/null
+++ b/client/src/features/viewers/countdown/countdown.helpers.js
@@ -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 };
+};
diff --git a/client/src/features/viewers/timer/Timer.jsx b/client/src/features/viewers/timer/Timer.jsx
index e185bf64d..da631e60c 100644
--- a/client/src/features/viewers/timer/Timer.jsx
+++ b/client/src/features/viewers/timer/Timer.jsx
@@ -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 === '' ? (
-
{endMessage}
) : (