From 0e450cb6cbbaeaf5197a1362a2caef1f6e1c9f42 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sun, 4 Sep 2022 22:44:03 +0200 Subject: [PATCH] Feat/table part1 (#197) * feat(csv): export data as csv file * feat(table): toggle fullscreen * ux: coordinate tooltip open delay * feat(excelDates): update tests * refactor: folder structure --- .../components/buttons/ActionButtons.jsx | 4 +- .../components/buttons/PauseIconBtn.jsx | 4 +- .../common/components/buttons/RollIconBtn.jsx | 4 +- .../components/buttons/StartIconBtn.jsx | 4 +- .../components/buttons/TooltipActionBtn.jsx | 7 +- .../components/buttons/TransportIconBtn.jsx | 4 +- .../components/buttons/UnloadIconBtn.jsx | 4 +- client/src/common/components/nav/NavLogo.jsx | 23 +--- client/src/common/hooks/useFullscreen.js | 31 +++++ client/src/common/utils/dateConfig.js | 6 +- client/src/common/utils/time.js | 5 +- client/src/common/utils/timeConstants.js | 24 ++++ .../src/features/control/message/InputRow.jsx | 8 +- .../control/message/MessageControl.jsx | 4 +- .../message/MessageControl.module.scss | 5 + .../control/playback/PlaybackTimer.jsx | 9 +- .../editors/EntryBlock/EntryBlock.jsx | 7 +- client/src/features/modals/AliasesModal.jsx | 7 +- client/src/features/table/OntimeTable.jsx | 3 +- client/src/features/table/Table.module.scss | 25 +++- client/src/features/table/TableHeader.jsx | 35 ++++- client/src/features/table/TableWrapper.jsx | 94 ++++++++----- .../__snapshots__/utils.test.js.snap | 49 +++++++ .../features/table/__tests__/utils.test.js | 93 +++++++++++++ .../table/tableElements/PlaybackIcon.jsx | 10 +- .../table/tableElements/SortableCell.jsx | 4 +- client/src/features/table/utils.js | 107 +++++++++++++++ .../countdown/__tests__/Countdown.test.js | 2 +- client/src/ontimeConfig.js | 3 + server/package.json | 2 +- server/src/app.js | 2 +- server/src/classes/{ => timer}/EventTimer.js | 8 +- server/src/classes/{ => timer}/Timer.js | 2 +- .../{ => timer}/__tests__/classUtils.test.js | 0 .../{ => timer}/__tests__/eventtimer.test.js | 0 .../{ => timer}/__tests__/timer.test.js | 0 server/src/classes/{ => timer}/classUtils.js | 0 .../classes/{ => timer}/integrations/Http.js | 0 .../classes/{ => timer}/integrations/Osc.js | 0 .../integrations/__tests__/Osc.test.js | 0 server/src/utils/__tests__/time.test.js | 28 ++++ server/src/utils/parser.js | 6 +- server/src/utils/time.js | 127 ++++++++++++++---- 43 files changed, 631 insertions(+), 129 deletions(-) create mode 100644 client/src/common/hooks/useFullscreen.js create mode 100644 client/src/common/utils/timeConstants.js create mode 100644 client/src/features/table/__tests__/__snapshots__/utils.test.js.snap create mode 100644 client/src/features/table/__tests__/utils.test.js create mode 100644 client/src/features/table/utils.js create mode 100644 client/src/ontimeConfig.js rename server/src/classes/{ => timer}/EventTimer.js (99%) rename server/src/classes/{ => timer}/Timer.js (99%) rename server/src/classes/{ => timer}/__tests__/classUtils.test.js (100%) rename server/src/classes/{ => timer}/__tests__/eventtimer.test.js (100%) rename server/src/classes/{ => timer}/__tests__/timer.test.js (100%) rename server/src/classes/{ => timer}/classUtils.js (100%) rename server/src/classes/{ => timer}/integrations/Http.js (100%) rename server/src/classes/{ => timer}/integrations/Osc.js (100%) rename server/src/classes/{ => timer}/integrations/__tests__/Osc.test.js (100%) create mode 100644 server/src/utils/__tests__/time.test.js diff --git a/client/src/common/components/buttons/ActionButtons.jsx b/client/src/common/components/buttons/ActionButtons.jsx index f2253d37be..b6c66e17e6 100644 --- a/client/src/common/components/buttons/ActionButtons.jsx +++ b/client/src/common/components/buttons/ActionButtons.jsx @@ -7,6 +7,8 @@ import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle'; import { FiPlus } from '@react-icons/all-files/fi/FiPlus'; import PropTypes from 'prop-types'; +import { tooltipDelayMid } from '../../../ontimeConfig'; + export default function ActionButtons(props) { const { showAdd, showDelay, showBlock, actionHandler } = props; @@ -17,7 +19,7 @@ export default function ActionButtons(props) { return ( - + + } colorScheme='orange' diff --git a/client/src/common/components/buttons/RollIconBtn.jsx b/client/src/common/components/buttons/RollIconBtn.jsx index a0a051b8e5..3713cd5b7d 100644 --- a/client/src/common/components/buttons/RollIconBtn.jsx +++ b/client/src/common/components/buttons/RollIconBtn.jsx @@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip'; import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline'; import PropTypes from 'prop-types'; +import { tooltipDelayMid } from '../../../ontimeConfig'; + export default function RollIconBtn(props) { const { clickhandler, active, disabled, ...rest } = props; return ( - + } colorScheme='blue' diff --git a/client/src/common/components/buttons/StartIconBtn.jsx b/client/src/common/components/buttons/StartIconBtn.jsx index 161489e6b9..8aa64cc190 100644 --- a/client/src/common/components/buttons/StartIconBtn.jsx +++ b/client/src/common/components/buttons/StartIconBtn.jsx @@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import PropTypes from 'prop-types'; +import { tooltipDelayMid } from '../../../ontimeConfig'; + export default function StartIconBtn(props) { const { clickhandler, active, disabled, ...rest } = props; return ( - + } colorScheme='green' diff --git a/client/src/common/components/buttons/TooltipActionBtn.jsx b/client/src/common/components/buttons/TooltipActionBtn.jsx index 216a4fbbe0..d6706ad3a8 100644 --- a/client/src/common/components/buttons/TooltipActionBtn.jsx +++ b/client/src/common/components/buttons/TooltipActionBtn.jsx @@ -4,9 +4,9 @@ import { Tooltip } from '@chakra-ui/tooltip'; import PropTypes from 'prop-types'; export default function TooltipActionBtn(props) { - const { clickHandler, icon, color, size='xs', tooltip, ...rest } = props; + const { clickHandler, icon, color, size='xs', tooltip, openDelay = 0, ...rest } = props; return ( - + + + } colorScheme='red' diff --git a/client/src/common/components/nav/NavLogo.jsx b/client/src/common/components/nav/NavLogo.jsx index 7d253c63f5..c7eb57ab94 100644 --- a/client/src/common/components/nav/NavLogo.jsx +++ b/client/src/common/components/nav/NavLogo.jsx @@ -2,11 +2,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { IconButton } from '@chakra-ui/button'; import { Image } from '@chakra-ui/react'; +import { IoContract } from '@react-icons/all-files/io5/IoContract'; import { IoExpand } from '@react-icons/all-files/io5/IoExpand'; import navlogo from 'assets/images/logos/LOGO-72.png'; import { AnimatePresence, motion } from 'framer-motion'; import PropTypes from 'prop-types'; +import useFullscreen from '../../hooks/useFullscreen'; + import navigatorConstants from './navigatorConstants'; import style from './NavLogo.module.scss'; @@ -22,6 +25,7 @@ const navButtonStyle = { export default function NavLogo(props) { const { isHidden } = props; const [showNav, setShowNav] = useState(false); + const { isFullScreen, toggleFullScreen } = useFullscreen(); const handleClick = useCallback(() => { setShowNav((prev) => !prev); @@ -37,16 +41,6 @@ export default function NavLogo(props) { } }, []); - const toggleFullscreen = useCallback(() => { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen(); - } else { - if (document.exitFullscreen) { - document.exitFullscreen(); - } - } - }, []); - useEffect(() => { // attach the event listener document.addEventListener('keydown', handleKeyPress); @@ -83,8 +77,8 @@ export default function NavLogo(props) { > } - onClick={toggleFullscreen} + icon={isFullScreen ? : } + onClick={toggleFullScreen} {...navButtonStyle} /> @@ -96,10 +90,7 @@ export default function NavLogo(props) { className={style.nav} > {navigatorConstants.map((route) => ( - + {route.label} ))} diff --git a/client/src/common/hooks/useFullscreen.js b/client/src/common/hooks/useFullscreen.js new file mode 100644 index 0000000000..94dda7e875 --- /dev/null +++ b/client/src/common/hooks/useFullscreen.js @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from 'react'; + + +export default function useFullscreen() { + const [isFullScreen, setFullScreen] = useState(document.fullscreenElement); + + useEffect(() => { + const handleChange = () => { + setFullScreen(document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleChange, { passive: true }); + document.addEventListener('resize', handleChange, { passive: true }); + + return () => { + document.removeEventListener('fullscreenchange', handleChange, { passive: true }); + document.removeEventListener('resize', handleChange, { passive: true }); + }; }, []); + + const toggleFullScreen = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + setFullScreen(document.fullscreenElement); + }, []); + + return { isFullScreen, toggleFullScreen }; +} diff --git a/client/src/common/utils/dateConfig.js b/client/src/common/utils/dateConfig.js index cb793812c8..7da9e9fda5 100644 --- a/client/src/common/utils/dateConfig.js +++ b/client/src/common/utils/dateConfig.js @@ -1,10 +1,8 @@ +import { mth, mtm, mts } from './timeConstants'; + export const timeFormat = 'HH:mm'; export const timeFormatSeconds = 'HH:mm:ss'; -const mts = 1000; // millis to seconds -const mtm = 1000 * 60; // millis to minutes -const mth = 1000 * 60 * 60; // millis to hours - /** * another go at simpler string formatting (counters) * @description Converts seconds to string representing time diff --git a/client/src/common/utils/time.js b/client/src/common/utils/time.js index e7383203a3..78745eafea 100644 --- a/client/src/common/utils/time.js +++ b/client/src/common/utils/time.js @@ -3,9 +3,8 @@ import { DateTime } from 'luxon'; import { ontimeQueryClient } from '../../App'; import { APP_SETTINGS } from '../api/apiConstants'; -const mts = 1000; // millis to seconds -const mtm = 1000 * 60; // millis to minutes -const mth = 1000 * 60 * 60; // millis to hours +import { mth, mtm, mts } from './timeConstants'; + /** * Returns current time in milliseconds diff --git a/client/src/common/utils/timeConstants.js b/client/src/common/utils/timeConstants.js new file mode 100644 index 0000000000..39a5c34aa0 --- /dev/null +++ b/client/src/common/utils/timeConstants.js @@ -0,0 +1,24 @@ +/** + * millis to seconds + * @type {number} + */ +export const mts = 1000; + +/** + * millis to minutes + * @type {number} + */ +export const mtm = 1000 * 60; + + +/** + * millis to hours + * @type {number} + */ +export const mth = 1000 * 60 * 60; + +/** + * milliseconds in a day + * @type {number} + */ +export const DAY_TO_MS = 86400000; diff --git a/client/src/features/control/message/InputRow.jsx b/client/src/features/control/message/InputRow.jsx index a615281ad0..fd012739ce 100644 --- a/client/src/features/control/message/InputRow.jsx +++ b/client/src/features/control/message/InputRow.jsx @@ -5,13 +5,15 @@ import { Tooltip } from '@chakra-ui/tooltip'; import { IoSunny } from '@react-icons/all-files/io5/IoSunny'; import PropTypes from 'prop-types'; +import { tooltipDelayMid } from '../../../ontimeConfig'; + import style from './MessageControl.module.scss'; export default function InputRow(props) { const { label, placeholder, text, visible, actionHandler, changeHandler } = props; return ( -
+
{label}
- + - +
- + { )}
- + - + - + - +
); } + +TableHeader.propTypes = { + handleCSVExport: PropTypes.func.isRequired, +}; diff --git a/client/src/features/table/TableWrapper.jsx b/client/src/features/table/TableWrapper.jsx index 06a1902a02..71e2c5f475 100644 --- a/client/src/features/table/TableWrapper.jsx +++ b/client/src/features/table/TableWrapper.jsx @@ -10,6 +10,7 @@ import useMutateEvents from '../../common/hooks/useMutateEvents'; import OntimeTable from './OntimeTable'; import TableHeader from './TableHeader'; +import { makeCSV, makeTable } from './utils'; import style from './Table.module.scss'; @@ -22,9 +23,7 @@ export default function TableWrapper() { const { theme } = useContext(TableSettingsContext); // Set window title - useEffect(() => { - document.title = 'ontime - Cuesheet'; - }, []); + document.title = 'ontime - Cuesheet'; /** * Handle incoming data from socket @@ -46,47 +45,68 @@ export default function TableWrapper() { }; }, [socket]); - const handleUpdate = useCallback(async (rowIndex, accessor, payload) => { - if (rowIndex == null || accessor == null || payload == null) { - return; - } - - // check if value is the same - const event = tableData[rowIndex]; - if (event == null) { - return; - } - - if (event[accessor] === payload) { - return; - } - // check if value is valid - // as of now, the fields do not have any validation - if (typeof payload !== 'string') { - return; - } - - // cleanup - const cleanVal = payload.trim(); - const mutationObject = { - id: event.id, - [accessor]: cleanVal, - }; + const handleUpdate = useCallback( + async (rowIndex, accessor, payload) => { + if (rowIndex == null || accessor == null || payload == null) { + return; + } + + // check if value is the same + const event = tableData[rowIndex]; + if (event == null) { + return; + } + + if (event[accessor] === payload) { + return; + } + // check if value is valid + // as of now, the fields do not have any validation + if (typeof payload !== 'string') { + return; + } + + // cleanup + const cleanVal = payload.trim(); + const mutationObject = { + id: event.id, + [accessor]: cleanVal, + }; - // submit - try { - await mutation.mutateAsync(mutationObject); - } catch (error) { - console.error(error); - } - }, [mutation, tableData]); + // submit + try { + await mutation.mutateAsync(mutationObject); + } catch (error) { + console.error(error); + } + }, + [mutation, tableData] + ); + + const exportHandler = useCallback( + (headerData) => { + if (!headerData || !tableData || !userFields) { + return; + } + + const sheetData = makeTable(headerData, tableData, userFields); + const csvContent = makeCSV(sheetData); + const encodedUri = encodeURI(csvContent); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + link.setAttribute('download', 'ontime export.csv'); + document.body.appendChild(link); + link.click(); + }, + [tableData, userFields] + ); if (typeof tableData === 'undefined' || typeof userFields === 'undefined') { return loading...; } return (
- + { + it('returns a string from given millis on timeStart and TimeEnd', () => { + const testData1 = 1000; + const testData2 = 60000; + expect(parseField('timeStart', testData1)).toBe('00:00:01'); + expect(parseField('timeEnd', testData2)).toBe('00:01:00'); + expect(parseField('timeEnd', testData2)).toBe('00:01:00'); + }); + + describe('returns an x when isPublic is truthy, empty string otherwise', () => { + const testTruthy = [1, true, 'x', 'test']; + const testFalsy = ['', null, undefined, false, 0]; + + testTruthy.forEach((value) => { + test(`${value}`, () => { + expect(parseField('isPublic', value)).toBe('x'); + }); + }); + testFalsy.forEach((value) => { + test(`${value}`, () => { + expect(parseField('isPublic', value)).toBe(''); + }); + }); + }); + + it('returns an empty string on undefined fields', () => { + expect(parseField('presenter', undefined)).toBe(''); + }); + + describe('simply returns any other value in any other field', () => { + const testFields = [ + { field: 'nothing', value: 123 }, + { field: 'title', value: 'test' }, + { field: 'presenter', value: 'test' }, + { field: 'subtitle', value: 'test' }, + { field: 'notes', value: 'test' }, + { field: 'colour', value: 'test' }, + { field: 'user0', value: 'test' }, + { field: 'user1', value: 'test' }, + { field: 'user2', value: 'test' }, + { field: 'user3', value: 'test' }, + { field: 'user4', value: 'test' }, + { field: 'user5', value: 'test' }, + { field: 'user6', value: 'test' }, + { field: 'user7', value: 'test' }, + { field: 'user8', value: 'test' }, + { field: 'user9', value: 'test' }, + ]; + + testFields.forEach((testCase) => { + test(`${testCase.field}:${testCase.value}`, () => { + expect(parseField(testCase.field, testCase.value)).toBe(testCase.value); + }); + }); + }); +}); + +describe('makeTable()', () => { + it('returns array of arrays with given fields', () => { + const headerData = {}; + const tableData = [ + { + title: 'test title 1', + presenter: '', + timeStart: 0, + timeEnd: 0, + isPublic: 'x', + user0: 'test', + user1: 'test', + }, + ]; + const userFields = { + user0: 'test', + }; + + const table = makeTable(headerData, tableData, userFields); + expect(table).toMatchSnapshot(); + }); +}); + +describe('make CSV()', () => { + it('joins an array of arrays with commas and newlines', () => { + const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']]; + expect(makeCSV(testdata)).toMatchInlineSnapshot(` +"data:text/csv;charset=utf-8,field +after newline,after comma +,after empty +" +`); + }); +}); diff --git a/client/src/features/table/tableElements/PlaybackIcon.jsx b/client/src/features/table/tableElements/PlaybackIcon.jsx index a8ffbba283..6582109fc3 100644 --- a/client/src/features/table/tableElements/PlaybackIcon.jsx +++ b/client/src/features/table/tableElements/PlaybackIcon.jsx @@ -6,12 +6,14 @@ import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline'; import PropTypes from 'prop-types'; +import { tooltipDelayFast } from '../../../ontimeConfig'; + export default function PlaybackIcon(props) { const { state } = props; if (state === 'stop') { return ( - + ); @@ -19,7 +21,7 @@ export default function PlaybackIcon(props) { if (state === 'start') { return ( - + ); @@ -27,7 +29,7 @@ export default function PlaybackIcon(props) { if (state === 'pause') { return ( - + ); @@ -35,7 +37,7 @@ export default function PlaybackIcon(props) { if (state === 'roll') { return ( - + ); diff --git a/client/src/features/table/tableElements/SortableCell.jsx b/client/src/features/table/tableElements/SortableCell.jsx index 11ade7848b..19a6e92bbc 100644 --- a/client/src/features/table/tableElements/SortableCell.jsx +++ b/client/src/features/table/tableElements/SortableCell.jsx @@ -4,6 +4,8 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import PropTypes from 'prop-types'; +import { tooltipDelayFast } from '../../../ontimeConfig'; + import styles from '../Table.module.scss'; export default function SortableCell({ column }) { @@ -29,7 +31,7 @@ export default function SortableCell({ column }) { return (
- + {column.render('Header')}
diff --git a/client/src/features/table/utils.js b/client/src/features/table/utils.js new file mode 100644 index 0000000000..ae085d4b16 --- /dev/null +++ b/client/src/features/table/utils.js @@ -0,0 +1,107 @@ +/** + * @description parses a field for export + * @param {string} field + * @param {*} data + * @return {string} + */ +import { stringFromMillis } from '../../common/utils/time'; + +export const parseField = (field, data) => { + let val; + switch (field) { + case 'timeStart': + case 'timeEnd': + val = stringFromMillis(data); + break; + case 'isPublic': + val = data ? 'x' : ''; + break; + default: + val = data; + break; + } + if (typeof data === 'undefined') { + return '' + } + return val; +}; + +/** + * @description Creates an array of arrays usable by xlsx for export + * @param {object} headerData + * @param {array} tableData + * @param {object} userFields + * @return {(string[])[]} + */ +export const makeTable = (headerData, tableData, userFields) => { + const data = [ + ['Ontime · Schedule Template'], + ['Event Name', headerData?.title || ''], + ['Event URL', headerData?.url || ''], + [], + ]; + + const fieldOrder = [ + 'timeStart', + 'timeEnd', + 'title', + 'presenter', + 'subtitle', + 'isPublic', + 'notes', + 'colour', + 'user0', + 'user1', + 'user2', + 'user3', + 'user4', + 'user5', + 'user6', + 'user7', + 'user8', + 'user9', + ]; + + const fieldTitles = [ + 'Time Start', + 'Time End', + 'Event Title', + 'Presenter Name', + 'Event Subtitle', + 'Is Public? (x)', + 'Notes', + 'Colour', + ]; + + for (const field in userFields) { + const fieldValue = userFields[field]; + const displayName = `${field}${ + fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : '' + }`; + fieldTitles.push(displayName); + } + + data.push(fieldTitles); + + tableData.forEach((entry) => { + const row = []; + fieldOrder.forEach((field) => row.push(parseField(field, entry[field]))); + data.push(row); + }); + + return data; +}; + +/** + * @description Converts an array of arrays to a csv file + * @param {array[]} arrayOfArrays + * @return {string} + */ +export const makeCSV = (arrayOfArrays) => { + let csvData = 'data:text/csv;charset=utf-8,'; + arrayOfArrays.forEach((rowArray) => { + const row = rowArray.join(','); + csvData += `${row}\n`; + }); + return csvData; +}; diff --git a/client/src/features/viewers/countdown/__tests__/Countdown.test.js b/client/src/features/viewers/countdown/__tests__/Countdown.test.js index c5e65b5417..7a9b6708a8 100644 --- a/client/src/features/viewers/countdown/__tests__/Countdown.test.js +++ b/client/src/features/viewers/countdown/__tests__/Countdown.test.js @@ -1,5 +1,5 @@ -import { DAY_TO_MS } from '../../../../../../server/src/classes/classUtils'; import { millisToSeconds } from '../../../../common/utils/dateConfig'; +import { DAY_TO_MS } from '../../../../common/utils/timeConstants'; import { fetchTimerData, sanitiseTitle, timerMessages } from '../countdown.helpers'; describe('sanitiseTitle() function', () => { diff --git a/client/src/ontimeConfig.js b/client/src/ontimeConfig.js new file mode 100644 index 0000000000..2bc6e511bd --- /dev/null +++ b/client/src/ontimeConfig.js @@ -0,0 +1,3 @@ +export const tooltipDelaySlow = 1000; +export const tooltipDelayMid = 500 +export const tooltipDelayFast = 300; diff --git a/server/package.json b/server/package.json index bdb6caae50..892d41fcef 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "1.6.0", + "version": "1.7.0", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/server/src/app.js b/server/src/app.js index 0345eb198f..017d31a5fe 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -20,7 +20,7 @@ import { router as ontimeRouter } from './routes/ontimeRouter.js'; import { router as playbackRouter } from './routes/playbackRouter.js'; // Global Objects -import { EventTimer } from './classes/EventTimer.js'; +import { EventTimer } from './classes/timer/EventTimer.js'; // Start OSC server import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js'; import { fileURLToPath } from 'url'; diff --git a/server/src/classes/EventTimer.js b/server/src/classes/timer/EventTimer.js similarity index 99% rename from server/src/classes/EventTimer.js rename to server/src/classes/timer/EventTimer.js index 8b4d95d722..b4c580498b 100644 --- a/server/src/classes/EventTimer.js +++ b/server/src/classes/timer/EventTimer.js @@ -3,10 +3,10 @@ import { Server } from 'socket.io'; import { DAY_TO_MS, getSelectionByRoll, replacePlaceholder, updateRoll } from './classUtils.js'; import { OSCIntegration } from './integrations/Osc.js'; import { HTTPIntegration } from './integrations/Http.js'; -import { cleanURL } from '../utils/url.js'; -import getRandomName from '../utils/getRandomName.js'; -import { generateId } from '../utils/generate_id.js'; -import { stringFromMillis } from '../utils/time.js'; +import { cleanURL } from '../../utils/url.js'; +import getRandomName from '../../utils/getRandomName.js'; +import { generateId } from '../../utils/generate_id.js'; +import { stringFromMillis } from '../../utils/time.js'; /* * Class EventTimer adds functions specific to APP diff --git a/server/src/classes/Timer.js b/server/src/classes/timer/Timer.js similarity index 99% rename from server/src/classes/Timer.js rename to server/src/classes/timer/Timer.js index d6aac7c955..43df4f08a5 100644 --- a/server/src/classes/Timer.js +++ b/server/src/classes/timer/Timer.js @@ -1,4 +1,4 @@ -import { stringFromMillis } from '../utils/time.js'; +import { stringFromMillis } from '../../utils/time.js'; /** * @description Implements simple countdown timer functions diff --git a/server/src/classes/__tests__/classUtils.test.js b/server/src/classes/timer/__tests__/classUtils.test.js similarity index 100% rename from server/src/classes/__tests__/classUtils.test.js rename to server/src/classes/timer/__tests__/classUtils.test.js diff --git a/server/src/classes/__tests__/eventtimer.test.js b/server/src/classes/timer/__tests__/eventtimer.test.js similarity index 100% rename from server/src/classes/__tests__/eventtimer.test.js rename to server/src/classes/timer/__tests__/eventtimer.test.js diff --git a/server/src/classes/__tests__/timer.test.js b/server/src/classes/timer/__tests__/timer.test.js similarity index 100% rename from server/src/classes/__tests__/timer.test.js rename to server/src/classes/timer/__tests__/timer.test.js diff --git a/server/src/classes/classUtils.js b/server/src/classes/timer/classUtils.js similarity index 100% rename from server/src/classes/classUtils.js rename to server/src/classes/timer/classUtils.js diff --git a/server/src/classes/integrations/Http.js b/server/src/classes/timer/integrations/Http.js similarity index 100% rename from server/src/classes/integrations/Http.js rename to server/src/classes/timer/integrations/Http.js diff --git a/server/src/classes/integrations/Osc.js b/server/src/classes/timer/integrations/Osc.js similarity index 100% rename from server/src/classes/integrations/Osc.js rename to server/src/classes/timer/integrations/Osc.js diff --git a/server/src/classes/integrations/__tests__/Osc.test.js b/server/src/classes/timer/integrations/__tests__/Osc.test.js similarity index 100% rename from server/src/classes/integrations/__tests__/Osc.test.js rename to server/src/classes/timer/integrations/__tests__/Osc.test.js diff --git a/server/src/utils/__tests__/time.test.js b/server/src/utils/__tests__/time.test.js new file mode 100644 index 0000000000..668d34052d --- /dev/null +++ b/server/src/utils/__tests__/time.test.js @@ -0,0 +1,28 @@ +import { parseExcelDate } from '../time'; + +describe('parseExcelDate', () => { + it('parses a valid date string as expected from excel', () => { + const millis = parseExcelDate('1899-12-30T07:00:00.000Z'); + expect(millis).not.toBe(0); + }); + + describe('parses a time string that passes validation', () => { + const validFields = ['10:00:00', '10:00']; + validFields.forEach((field) => { + it(`handles ${field}`, () => { + const millis = parseExcelDate(field); + expect(millis).not.toBe(0); + }); + }); + }); + + describe('returns 0 on other strings', () => { + const invalidFields = ['10', 'test', '']; + invalidFields.forEach((field) => { + it(`handles ${field}`, () => { + const millis = parseExcelDate(field); + expect(millis).toBe(0); + }); + }); + }); +}); diff --git a/server/src/utils/parser.js b/server/src/utils/parser.js index 4d19547428..b37ffa58e3 100644 --- a/server/src/utils/parser.js +++ b/server/src/utils/parser.js @@ -12,7 +12,7 @@ import { parseSettings_v1, parseUserFields_v1, } from './parserUtils_v1.js'; -import { excelDateStringToMillis } from './time.js'; +import { parseExcelDate } from './time.js'; import { generateId } from './generate_id.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -79,9 +79,9 @@ export const parseExcel_v1 = async (excelData) => { eventData.url = column; eventUrlNext = false; } else if (j === timeStartIndex) { - event.timeStart = excelDateStringToMillis(column); + event.timeStart = parseExcelDate(column); } else if (j === timeEndIndex) { - event.timeEnd = excelDateStringToMillis(column); + event.timeEnd = parseExcelDate(column); } else if (j === titleIndex) { event.title = column; } else if (j === presenterIndex) { diff --git a/server/src/utils/time.js b/server/src/utils/time.js index 1ba5254052..acf0714564 100644 --- a/server/src/utils/time.js +++ b/server/src/utils/time.js @@ -2,22 +2,6 @@ const mts = 1000; // millis to seconds const mtm = 1000 * 60; // millis to minutes const mth = 1000 * 60 * 60; // millis to hours -/** - * Returns current time in milliseconds - * @returns {number} - */ -export const nowInMillis = () => { - const now = new Date(); - - // extract milliseconds since midnight - let elapsed = now.getHours() * 3600000; - elapsed += now.getMinutes() * 60000; - elapsed += now.getSeconds() * 1000; - elapsed += now.getMilliseconds(); - - return elapsed; -}; - /** * @description Converts milliseconds to string representing time * @param {number} ms - time in milliseconds @@ -51,17 +35,114 @@ export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = ' /** * @description Converts an excel date to milliseconds - * @argument {string} excelDate - excel string date + * @argument {string} date - excel string date * @returns {number} - time in milliseconds */ -export const excelDateStringToMillis = (excelDate) => { +export const dateToMillis = (date) => { + const h = date.getHours(); + const m = date.getMinutes(); + const s = date.getSeconds(); + + return h * mth + m * mtm + s * mts; +}; + +/** + * @description Parses an excel date using the correct parser + * @param {string} excelDate + * @returns {number} - time in milliseconds + + */ +export const parseExcelDate = (excelDate) => { + // attempt converting to date object const date = new Date(excelDate); if (date instanceof Date && !isNaN(date)) { - const h = date.getHours(); - const m = date.getMinutes(); - const s = date.getSeconds(); - - return h * mth + m * mtm + s * mts; + return dateToMillis(date); + } else if (isTimeString(excelDate)) { + return forgivingStringToMillis(excelDate); } return 0; }; + +export const timeFormat = 'HH:mm'; +export const timeFormatSeconds = 'HH:mm:ss'; + +/** + * @description Validates a time string + * @param {string} string - time string "23:00:12" + * @returns {boolean} string represents time + */ +export const isTimeString = (string) => { + // ^ # Start of string + // (?: # Try to match... + // (?: # Try to match... + // ([01]?\d|2[0-3]): # HH: + // )? # (optionally). + // ([0-5]?\d): # MM: (required) + // )? # (entire group optional, so either HH:MM:, MM: or nothing) + // ([0-5]?\d) # SS (required) + // $ # End of string + + const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/; + return regex.test(string); +}; + +/** + * @description safe parse string to int, copied from client code + * @param valueAsString + * @return {number} + */ +const parse = (valueAsString) => { + const parsed = parseInt(valueAsString, 10); + if (isNaN(parsed)) { + return 0; + } + return Math.abs(parsed); +}; + +/** + * @description Parses a time string to millis, copied from client code + * @param {string} value - time string + * @param {boolean} fillLeft - autofill left = hours / right = seconds + * @returns {number} - time string in millis + */ +export const forgivingStringToMillis = (value, fillLeft = true) => { + let millis = 0; + + // split string at known separators : , . + const separatorRegex = /[\s,:.]+/; + const [first, second, third] = value.split(separatorRegex); + + if (first != null && second != null && third != null) { + // if string has three sections, treat as [hours] [minutes] [seconds] + millis = parse(first) * mth; + millis += parse(second) * mtm; + millis += parse(third) * mts; + } else if (first != null && second == null && third == null) { + // if string has one section, + // could be a complete string like 121010 - 12:10:10 + if (first.length === 6) { + const hours = first.substring(0, 2); + const minutes = first.substring(2, 4); + const seconds = first.substring(4); + millis = parse(hours) * mth; + millis += parse(minutes) * mtm; + millis += parse(seconds) * mts; + } else { + // otherwise lets treat as [minutes] + millis = parse(first) * mtm; + } + } + if (first != null && second != null && third == null) { + // if string has two sections + if (fillLeft) { + // treat as [hours] [minutes] + millis = parse(first) * mth; + millis += parse(second) * mtm; + } else { + // treat as [minutes] [seconds] + millis = parse(first) * mtm; + millis += parse(second) * mts; + } + } + return millis; +};