V2 monorepo (#285)

* refactor(project structure): UI

* refactor(project structure): extract utilities

* refactor(project structure): remove unused

* refactor(project structure): electron

* refactor(project structure): server

refactor: migrate to vitest

refactor: monorepo config

* refactor: extract application menu

* refactor: exit process

* refactor: extract tray menu

* chore: electron build

* Added Seconds in studio clock #282
---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

---------

Co-authored-by: Fabian Posenau <fabian.p99@gmx.de>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
This commit is contained in:
Carlos Valente
2023-02-14 22:02:15 +01:00
committed by GitHub
parent 3918758d32
commit de9a7a87fd
439 changed files with 11381 additions and 14294 deletions
@@ -0,0 +1,146 @@
@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(24px, 2vw, 32px);
}
&__events {
font-size: clamp(16px, 1.5vw, 24px);
margin-top: 1em;
overflow-y: auto;
height: 70vh;
width: 60vw;
}
}
/* =================== MAIN - EVENT CONTAINER ===================*/
.countdown-container {
height: 100%;
width: 100%;
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
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: clamp(16px, 1.5vw, 24px);
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
}
}
.status {
grid-area: status;
color: var(--label-color-override, $viewer-label-color);
font-size: clamp(32px, 3.5vw, 50px);
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: 16px 24px;
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: flex;
justify-content: space-evenly;
align-items: flex-end;
.aux-timers {
text-align: center;
font-size: clamp(24px, 1.75vw, 32px);
&__label {
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
&--delayed {
color: $delay-color;
}
}
}
}
}
}
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAtom } from 'jotai';
import PropTypes from 'prop-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { mirrorViewersAtom } from '../../../common/atoms/ViewerSettings';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig';
import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time';
import { fetchTimerData, TimerMessage } from './countdown.helpers';
import CountdownSelect from './CountdownSelect';
import './Countdown.scss';
const formatOptions = {
showSeconds: true,
format: 'hh:mm:ss a',
};
const formatOptionsFinished = {
showSeconds: false,
format: 'hh:mm a',
};
Countdown.propTypes = {
backstageEvents: PropTypes.array,
time: PropTypes.object,
selectedId: PropTypes.string,
viewSettings: PropTypes.object,
};
// @ts-expect-error we are unable to type this just yet
export default function Countdown(props) {
const { backstageEvents, time, selectedId, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [isMirrored] = useAtom(mirrorViewersAtom);
const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0);
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: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
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 = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
const delayToEvent = getDelayTo(backstageEvents, idx);
setDelay(delayToEvent);
}
}, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const standby = time.playback !== 'play' && selectedId === follow?.id;
const isRunningFinished = time.finished && runningMessage === TimerMessage.running;
const isSelected = runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock, formatOptions);
const startTime =
follow === null
? '...'
: formatTime(follow.timeStart + delay, formatOptions);
const endTime =
follow === null
? '...'
: formatTime(follow.timeEnd + delay, formatOptions);
const formattedTimer = runningMessage === TimerMessage.ended
? formatTime(runningTimer, formatOptionsFinished)
: formatDisplay(
isSelected ? millisToSeconds(runningTimer) : millisToSeconds(runningTimer + delay),
isSelected || time.waiting,
);
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
<div className='countdown-container' data-testid='countdown-event'>
<div className='clock-container'>
<div className='label'>Time Now</div>
<div className='time'>{clock}</div>
</div>
<div className='status'>{runningMessage}</div>
<span
className={`timer ${standby ? 'timer--paused' : ''} ${
isRunningFinished ? 'timer--finished' : ''
}`}
>
{formattedTimer}
</span>
<div className='title'>{follow?.title || 'Untitled Event'}</div>
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>Start Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{startTime}
</span>
</div>
<div className='aux-timers'>
<div className='aux-timers__label'>End Time</div>
<span className={`aux-timers__value ${delayedTimerStyles}`}>
{endTime}
</span>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,46 @@
import { Link } from 'react-router-dom';
import Empty from '../../../common/components/state/Empty';
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from '../../../common/models/EventTypes';
import { formatTime } from '../../../common/utils/time';
import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
interface CountdownSelectProps {
events: OntimeRundownEntry[];
}
export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const filteredEvents = events.filter((event: OntimeRundownEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
return (
<div className='event-select' data-testid='countdown-select'>
<span className='event-select__title'>Select an event to follow</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, { format: 'hh:mm' });
const end = formatTime(event.timeEnd, { format: 'hh:mm' });
return (
<li key={event.id}>
<Link to={`/countdown?eventid=${event.id}`}>
{`${index}. ${start}${end} | ${title}`}
</Link>
</li>
);
},
)
)}
</ul>
</div>
);
}
@@ -0,0 +1,108 @@
import { DAY_TO_MS } from '../../../../common/utils/timeConstants';
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)).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 };
const time = { current: currentMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
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 };
const time = { clock: timeNow };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
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 };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(DAY_TO_MS + 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 };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.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 = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart);
expect(timer).toBe(startMockValue - timeNow);
});
});
@@ -0,0 +1,69 @@
import { OntimeEvent } from '../../../common/models/EventTypes';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
export enum TimerMessage {
toStart = 'Time to start',
waiting = 'Waiting for event start',
running = 'Event running',
ended = 'Event ended at',
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: TimeManagerType, follow: OntimeEvent, selectedId: string): { message: TimerMessage, timer: number } => {
let message;
let timer;
if (selectedId === follow.id) {
// check that is not running
message = time.playback === 'pause' ? TimerMessage.waiting : TimerMessage.running;
timer = time.current ?? 0;
} else if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// running timer timer is not the one we are following
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
}
return { message, timer };
};