mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
Feat/120 (#164)
* 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:
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user