Compare commits

...

39 Commits

Author SHA1 Message Date
arc-alex 9287248333 ensure extension during rename and delete 2025-02-18 21:36:06 +01:00
Carlos Valente 0957c67474 bump version to 3.10.7 2025-02-18 21:34:33 +01:00
Carlos Valente 979b6dc6d8 bump version to 3.10.6 2025-02-07 10:36:14 +01:00
Carlos Valente 6dc8bf7eb0 fix: project created without extension 2025-02-07 10:35:47 +01:00
Carlos Valente f1f628a48e bump version to 3.10.5 2025-01-24 13:31:18 +01:00
Carlos Valente d58335d1a1 refactor: allow overriding timer type in view 2025-01-24 13:13:53 +01:00
Carlos Valente 78c2bd79b5 refactor: card content on preload 2025-01-24 13:13:53 +01:00
Carlos Valente 28452d1e38 refactor: allow overriding progress bar phases 2025-01-24 13:13:53 +01:00
Carlos Valente 46790441e0 refactor: review view styles 2025-01-24 13:13:53 +01:00
Carlos Valente 3f35b5e297 refactor: extract and simplify logic 2025-01-24 13:13:53 +01:00
Carlos Valente 49cae647d6 refactor: larger progress bar 2025-01-24 13:13:53 +01:00
Carlos Valente 90700c85e8 refactor: subscript clock 2025-01-24 13:13:53 +01:00
Carlos Valente 8fac23c078 refactor: make function private 2025-01-24 13:13:53 +01:00
Carlos Valente b5cf6c4bd1 chore: directory restructure 2025-01-24 13:13:53 +01:00
Carlos Valente 0ea00ff6be chore: remove unused code 2025-01-24 12:41:42 +01:00
Carlos Valente 30e290c320 refactor: remove unnecessary handler 2025-01-22 20:51:12 +01:00
Carlos Valente ac52b3f0c1 chore: use smaller dependency 2025-01-22 20:51:12 +01:00
Carlos Valente 8e3db6c559 refactor: remove dom dependencies 2025-01-22 20:51:12 +01:00
Carlos Valente 1d2013ad41 fix: show correct state for welcome option 2025-01-19 22:18:03 +01:00
Carlos Valente 9c910c079d bump version to 3.10.4 2025-01-19 20:04:52 +01:00
Carlos Valente 420cef8b0d chore: add html files to docker 2025-01-19 19:57:10 +01:00
Carlos Valente b4c7b84f92 refactor: handle trailing slash in URL preset 2025-01-19 19:57:10 +01:00
Carlos Valente 0450eb9169 refactor: set token from query params 2025-01-19 19:57:10 +01:00
Carlos Valente 7fe5223af9 feat: add auth to server requests 2025-01-19 19:57:10 +01:00
arc-alex ce8d534953 feat: optional welcome modal
welcome modal is persisted in app state

created endpoints to set the visibility

added UI to support feature

Co-authored-by: Carlos Valente
<34649812+cpvalente@users.noreply.github.com>
2025-01-18 20:24:46 +01:00
Carlos Valente 4a6fbc2a92 refactor: add timezone data to session 2025-01-17 20:05:56 +01:00
Carlos Valente 50ebcf4e14 refactor: review welcome modal design 2025-01-17 20:04:00 +01:00
Carlos Valente f0f7d1cede docs: add homebrew links 2025-01-17 20:02:44 +01:00
Carlos Valente d2bdb1ea03 docs: add links to sponsor 2025-01-17 20:02:44 +01:00
Carlos Valente 9be6febcda chore: upgrade playwright 2025-01-17 19:57:01 +01:00
Carlos Valente aa929fd0dc refactor: create utility for nested templates 2025-01-12 10:20:24 +01:00
Carlos Valente 7baa6a4ab9 refactor: create mock data utilities 2025-01-12 10:20:24 +01:00
Carlos Valente 082fe8b8d7 chore: write feature spec 2025-01-12 10:14:44 +01:00
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
Carlos Valente 35424cfff2 bump version to 3.10.3 2025-01-06 20:33:39 +01:00
Alex Christoffer Rasmussen 13eadeb0af fix download (#1434)
* fix download

* update test db

* create download test
2025-01-06 20:26:06 +01:00
Carlos Valente 9cfed5a150 fix: issue with persistent script 2025-01-06 20:11:15 +01:00
106 changed files with 2738 additions and 2082 deletions
+1
View File
@@ -28,6 +28,7 @@ test-results
playwright-report
/playwright/.cache/
automated-screenshots
e2e/tests/fixtures/tmp/*
# production
build/
+1
View File
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
+5
View File
@@ -2,6 +2,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -12,7 +13,11 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.10.2",
"version": "3.10.7",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-1
View File
@@ -15,7 +15,6 @@
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
+3 -7
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.10.2",
"version": "3.10.7",
"private": true,
"type": "module",
"dependencies": {
@@ -8,6 +8,7 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
@@ -62,13 +63,9 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
@@ -79,8 +76,7 @@
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
+1 -1
View File
@@ -22,7 +22,7 @@ const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
+1 -1
View File
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
return axios.post(`${dbPath}/download`, { filename });
}
/**
+7
View File
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-size: 1.5rem;
$progress-bar-br: 3px;
.multiprogress-bar {
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,6 +1,7 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import './MultiPartProgressBar.scss';
@@ -13,6 +14,7 @@ interface MultiPartProgressBar {
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
@@ -27,6 +29,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger,
dangerColor,
hidden,
hideOvertime,
ignoreCssOverride,
className = '',
} = props;
@@ -34,12 +37,17 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
const showOvertime = isOvertime && !hideOvertime;
return (
<div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
className={cx([
'multiprogress-bar',
hidden && 'multiprogress-bar--hidden',
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
>
{now !== null && (
<>
@@ -50,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
@@ -1,4 +1,4 @@
import { memo, PropsWithChildren, useRef } from 'react';
import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
@@ -19,7 +19,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
@@ -48,12 +47,8 @@ function NavigationMenu(props: NavigationMenuProps) {
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<div id='navigation-menu-portal'>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
@@ -1,10 +1,12 @@
@use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title-card__title,
@@ -16,13 +18,13 @@
.title-card__title {
font-weight: 600;
font-size: clamp(1.5rem, 3vw, 3rem);
font-size: $title-primary-size;
color: var(--color-override, $viewer-color);
line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
font-size: $title-secondary-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -31,7 +33,7 @@
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss';
interface TitleCardProps {
title: string;
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
@@ -1,46 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -1,25 +0,0 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
@@ -25,6 +25,8 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
colour: event.colour,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: {},
+8 -1
View File
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
return { isValid: true, message: 'ok' };
};
/**
* Utility removes trailing slash from a string
*/
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
/**
* Gets the URL to send a preset to
* @param location
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
}
+3
View File
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
@@ -1,5 +1,12 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import {
buyMeACoffeeUrl,
discordUrl,
documentationUrl,
githubSponsorUrl,
githubUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -9,22 +16,29 @@ export default function AboutPanel() {
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
And trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
</Panel.Section>
</>
);
}
@@ -24,6 +24,10 @@
display: flex;
gap: 1rem;
justify-content: end;
:first-child {
margin-right: auto;
}
}
.tableContainer {
@@ -38,14 +42,20 @@
tbody {
background-color: $gray-1300;
tr {
&:has(:hover) {
&:hover:not(.current) {
background-color: $gray-1350;
}
.current {
&:hover {
background-color: $blue-900;
}
}
}
}
tr {
height: 2rem;
cursor: pointer;
}
th,
@@ -62,4 +72,8 @@
.current {
background-color: $blue-700;
&:hover {
background-color: $blue-900;
}
}
@@ -1,10 +1,12 @@
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import * as Editor from '../editor-utils/EditorUtils';
import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList';
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
</div>
<div className={style.column}>
<div className={style.header}>Welcome to Ontime</div>
<Editor.Title>Select project</Editor.Title>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div>
</div>
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
<Checkbox
size='sm'
variant='ontime-ondark'
defaultChecked
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
>
Show this modal on next startup
</Checkbox>
</ModalBody>
</ModalContent>
</Modal>
@@ -1,5 +1,3 @@
import { Button } from '@chakra-ui/react';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss';
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
{data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) {
return (
<tr className={style.current} key={project.filename}>
<tr className={style.current} key={project.filename} onClick={onClose}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr>
);
}
return (
<tr key={project.filename}>
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr>
);
})}
@@ -105,5 +105,6 @@
}
.progressOverride {
height: 1rem;
--progress-bar-br: 0;
}
+7 -5
View File
@@ -13,6 +13,7 @@ import {
SupportedEvent,
} from 'ontime-types';
import {
checkIsNextDay,
getFirstNormal,
getLastNormal,
getNextBlockNormal,
@@ -267,7 +268,7 @@ export default function Rundown({ data }: RundownProps) {
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
const isEditMode = appMode === AppMode.Edit;
return (
@@ -286,6 +287,7 @@ export default function Rundown({ data }: RundownProps) {
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
@@ -294,8 +296,9 @@ export default function Rundown({ data }: RundownProps) {
lastEvent = thisEvent;
if (isPlayableEvent(entry)) {
// populate previous entry
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
isNextDay = checkIsNextDay(entry, lastEvent);
if (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry;
}
}
@@ -323,12 +326,11 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousStart={lastEvent?.timeStart}
previousEnd={lastEvent?.timeEnd}
previousEntryId={previousEntryId}
previousEventId={lastEvent?.id}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay}
/>
</div>
</div>
@@ -32,8 +32,7 @@ interface RundownEntryProps {
eventIndex: number;
hasCursor: boolean;
isNext: boolean;
previousStart?: number;
previousEnd?: number;
isNextDay: boolean;
previousEntryId?: string;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
@@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) {
loaded,
hasCursor,
isNext,
previousStart,
previousEnd,
previousEntryId,
previousEventId,
playback,
isRolling,
eventIndex,
isNextDay,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -165,8 +163,6 @@ export default function RundownEntry(props: RundownEntryProps) {
title={data.title}
note={data.note}
delay={data.delay ?? 0}
previousStart={previousStart}
previousEnd={previousEnd}
colour={data.colour}
isPast={isPast}
isNext={isNext}
@@ -175,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) {
hasCursor={hasCursor}
playback={playback}
isRolling={isRolling}
gap={data.gap}
isNextDay={isNextDay}
actionHandler={actionHandler}
/>
);
@@ -39,8 +39,6 @@ interface EventBlockProps {
title: string;
note: string;
delay: number;
previousStart?: number;
previousEnd?: number;
colour: string;
isPast: boolean;
isNext: boolean;
@@ -49,6 +47,8 @@ interface EventBlockProps {
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
gap: number;
isNextDay: boolean;
actionHandler: (
action: EventItemActions,
payload?:
@@ -77,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) {
title,
note,
delay,
previousStart,
previousEnd,
colour,
isPast,
isNext,
@@ -87,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) {
hasCursor,
playback,
isRolling,
gap,
isNextDay,
actionHandler,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
@@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
onContextMenu={onContextMenu}
id='event-block'
>
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
@@ -1,11 +1,4 @@
import {
calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
@@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
if (noPreviousElement) return;
const normalisedDuration = calculateDuration(previousStart, previousEnd);
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
if (timeFromPrevious === 0) return;
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const previousCrossMidnight = previousStart > previousEnd;
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
const gap = dayInMs - normalisedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = formatDuration(Math.abs(gap), false);
return `Gap ${gapString} (next day)`;
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
// We show a next day warning even if there is no gap
return '(next day)';
}
return;
}
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
const gapString = formatDuration(Math.abs(gap), false);
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
}
@@ -1,24 +1,24 @@
import { formatDelay, formatOverlap } from './EventBlock.utils';
import { formatDelay, formatGap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
previousStart?: number;
previousEnd?: number;
isNextDay: boolean;
delay: number;
gap: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props;
const { timeStart, delay, gap, isNextDay } = props;
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay);
return (
<div className={style.indicators}>
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
{hasGap && <div className={style.gap}>{hasGap}</div>}
</div>
);
}
@@ -1,6 +1,4 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { formatDelay, formatOverlap } from '../EventBlock.utils';
import { formatDelay } from '../EventBlock.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
@@ -10,69 +8,3 @@ describe('formatDelay()', () => {
expect(result).toEqual('New start 00:02');
});
});
describe('formatOverlap()', () => {
it('recognises an overlap between two times', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 30s');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5m');
});
it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18h (next day)');
});
it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10h (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h (next day)');
});
it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h');
});
});
@@ -6,6 +6,7 @@ import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
@@ -45,7 +46,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
};
const warnings = [];
if (timeStart > timeEnd) {
if (timeStart + duration > dayInMs) {
warnings.push('Over midnight');
}
@@ -129,7 +130,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
</div>
{warnings.length > 0 && (
<div className={style.timerNote}>
<div className={style.timerNote} data-testid='event-warning'>
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
<IoAlertCircleOutline />
</Tooltip>
@@ -2,3 +2,7 @@ sup.period {
top: -1em;
font-size: 0.4em;
}
.subscript {
font-size: 0.75em;
}
@@ -9,17 +9,32 @@ interface SuperscriptTimeProps {
}
/**
* @description receives a string like 12:00 AM and adds the period part to the superscript
* When the timer includes seconds, we want to split it from the rest
*/
function getTimerParts(time: string) {
if (time.length !== 8) {
return [time, ''];
}
return [time.slice(0, 5), time.slice(5)];
}
/**
* Receives a time string and formats it with a subscript or superscript
* @example 12:00 AM -> AM becomes a superscript
* @example 12:00:10 -> the seconds become a subscript
*/
export default function SuperscriptTime(props: SuperscriptTimeProps) {
const { time, className, style } = props;
// we assume anything after space is a period tag
const [timeString, period] = time.split(' ');
const [mainTime, subscript] = getTimerParts(timeString);
return (
<div className={className} style={style}>
{timeString}
{mainTime}
{subscript && <span className='subscript'>{subscript}</span>}
{period && <sup className='period'>{period}</sup>}
</div>
);
@@ -7,11 +7,17 @@ import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'countToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
export function getTimerByType(
freezeEnd: boolean,
timerObject?: TimerTypeParams,
timerTypeOverride?: TimerType,
): number | null {
if (!timerObject) {
return null;
}
const viewTimerType = timerTypeOverride ?? timerObject.timerType;
if (timerObject.countToEnd) {
if (timerObject.current === null) {
return null;
@@ -19,7 +25,7 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (timerObject.timerType) {
switch (viewTimerType) {
case TimerType.CountDown:
if (timerObject.current === null) {
return null;
@@ -31,10 +37,8 @@ export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams
return timerObject.clock;
case TimerType.None:
return null;
default: {
const exhaustiveCheck: never = timerObject.timerType;
return exhaustiveCheck;
}
default:
return null;
}
}
@@ -1,267 +0,0 @@
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import {
CustomFields,
MessageState,
OntimeEvent,
Playback,
ProjectData,
Settings,
SimpleTimerState,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import { FitText } from '../../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
import ViewLogo from '../../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { getTimerOptions } from './timer.options';
import './Timer.scss';
// motion
const titleVariants = {
hidden: {
x: -2500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -2500,
},
};
export const MotionTitleCard = motion(TitleCard);
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
props;
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
useWindowTitle('Timer');
// USER OPTIONS
const userOptions = {
hideClock: false,
hideCards: false,
hideProgress: false,
hideMessage: false,
hideTimerSeconds: false,
hideClockSeconds: false,
removeLeadingZeros: true,
};
const hideClock = searchParams.get('hideClock');
userOptions.hideClock = isStringBoolean(hideClock);
const hideCards = searchParams.get('hideCards');
userOptions.hideCards = isStringBoolean(hideCards);
const hideProgress = searchParams.get('hideProgress');
userOptions.hideProgress = isStringBoolean(hideProgress);
const hideMessage = searchParams.get('hideMessage');
userOptions.hideMessage = isStringBoolean(hideMessage);
const hideClockSeconds = searchParams.get('hideClockSeconds');
userOptions.hideClockSeconds = isStringBoolean(hideClockSeconds);
const clock = formatTime(time.clock);
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const showLeadingZeros = searchParams.get('showLeadingZeros');
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
const main = searchParams.get('main');
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
const showOverlay = message.timer.text !== '' && message.timer.visible;
const isPlaying = time.playback !== Playback.Pause;
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const finished = time.phase === TimerPhase.Overtime;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const shouldShowModifiers = time.timerType === TimerType.CountDown || time.countToEnd;
const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage;
const showProgress =
eventNow !== null &&
time.timerType !== TimerType.None &&
time.timerType !== TimerType.Clock &&
time.playback !== Playback.Stop;
const showFinished = shouldShowModifiers && finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showClock = time.timerType !== TimerType.Clock;
const secondaryContent = ((): string | undefined => {
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(auxTimer.current, TimerType.CountDown, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
})();
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: userOptions.removeLeadingZeros,
});
const stageTimerCharacters = display.replace('/:/g', '').length;
const baseClasses = `stage-timer ${isMirrored ? 'mirror' : ''}`;
let timerFontSize = 89 / (stageTimerCharacters - 1);
// we need to shrink the timer if the external is going to be there
if (secondaryContent) {
timerFontSize *= 0.8;
}
const externalFontSize = timerFontSize * 0.4;
const timerContainerClasses = `timer-container ${message.timer.blink ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields);
const disableProgress = timerIsTimeOfDay || time.timerType === TimerType.None;
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<div className={message.timer.blackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<FitText mode='multi' min={32} max={256} className={`message ${message.timer.blink ? 'blink' : ''}`}>
{message.timer.text}
</FitText>
</div>
)}
{!userOptions.hideClock && (
<div className={`clock-container ${showClock ? '' : 'clock-container--hidden'}`}>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
)}
<div className={timerContainerClasses}>
{showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div>
) : (
<div
className={timerClasses}
style={{
fontSize: `${timerFontSize}vw`,
'--phase-color': timerColor,
}}
>
{display}
</div>
)}
<div
className={`secondary${secondaryContent ? '' : ' secondary--hidden'}`}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
</div>
</div>
{!userOptions.hideProgress && (
<MultiPartProgressBar
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
now={disableProgress ? null : time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hidden={!showProgress}
/>
)}
{!userOptions.hideCards && (
<>
<AnimatePresence>
{eventNow?.title && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={mainFieldNow}
secondary={secondaryTextNow}
/>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext?.title && (
<MotionTitleCard
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={mainFieldNext}
secondary={secondaryTextNext}
className='event next'
/>
)}
</AnimatePresence>
</>
)}
</div>
);
}
@@ -1,75 +0,0 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
-3
View File
@@ -1,3 +0,0 @@
import matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
+6 -1
View File
@@ -21,6 +21,11 @@ $viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
$element-border-radius: 8px;
// Generic element sizes
$view-element-gap: min(2vh, 16px);
$view-outer-padding: min(2vh, 16px) clamp(16px, 2vw, 24px);
$view-card-padding: min(2vh, 8px) clamp(16px, 2vw, 24px);
// Properties related to timer
$timer-color: rgba(white, 80%); // --timer-color-override
$timer-finished-color: $playback-negative;
@@ -30,4 +35,4 @@ $external-color: rgba(white, 85%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
$timer-value-size: clamp(32px, 3.5vw, 50px);
@@ -76,7 +76,7 @@ type CuesheetOptions = {
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with cuesheetOptions
*/
export function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
// we manually make an object that matches the key above
return {
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
+3 -15
View File
@@ -1,7 +1,7 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
@@ -30,10 +30,8 @@ function Timeline(props: TimelineProps) {
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let elapsedDays = 0;
return (
<div className={style.timeline}>
@@ -52,14 +50,7 @@ function Timeline(props: TimelineProps) {
eventStatus = 'live';
}
// we only need to check for next day if we have a previous event
if (
previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
}
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
@@ -69,9 +60,6 @@ function Timeline(props: TimelineProps) {
screenWidth,
);
// prepare values for next iteration
previousEventStartTime = normalisedStart;
return (
<TimelineEntry
key={event.id}
@@ -110,7 +110,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
let lastEntry: PlayableEvent | undefined;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
@@ -142,12 +142,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
@@ -156,7 +151,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '../../theme/viewerDefs' as *;
.stage-timer {
margin: 0;
@@ -11,21 +11,14 @@
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
gap: $view-element-gap;
padding: $view-outer-padding;
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto 1fr auto auto auto;
grid-template-areas:
'clock'
'timer'
'progress'
'now'
'next';
display: flex;
flex-direction: column;
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline: clamp(4px, 1vw, 16px) solid var(--timer-overtime-color-override, $timer-finished-color);
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
transition: $viewer-transition-time;
}
@@ -48,19 +41,18 @@
/* =================== CLOCK ===================*/
.clock-container {
grid-area: clock;
margin-left: auto;
font-weight: 600;
transition: opacity $viewer-transition-time;
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.clock {
font-size: clamp(32px, 3.5vw, 50px);
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -75,8 +67,8 @@
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
border-radius: 8px;
padding: $view-card-padding;
border-radius: $element-border-radius;
&.now {
grid-area: now;
@@ -90,7 +82,8 @@
/* =================== MAIN ===================*/
.timer-container {
grid-area: timer;
flex: 1;
align-content: center;
justify-self: center;
align-self: center;
@@ -102,7 +95,7 @@
font-size: 11.5vw;
line-height: 0.9em;
font-weight: 600;
color: $timer-finished-color;
color: var(--timer-overtime-color-override, $timer-finished-color);
padding: 0;
}
@@ -123,8 +116,16 @@
transition: $viewer-transition-time;
}
// use a class instead of a phase, to allow suppressing overtime style
&--finished {
color: $timer-finished-color;
color: var(--timer-overtime-color-override, $timer-finished-color);
}
&[data-phase="warning"] {
color: var(--timer-warning-color-override, var(--phase-color));
}
&[data-phase="danger"] {
color: var(--timer-danger-color-override, var(--phase-color));
}
}
}
@@ -134,14 +135,14 @@
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25em;
padding-block: 0.25em;
margin-top: 0.125em;
padding-block: 0.125em;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 0.9em;
line-height: 1em;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
@@ -153,7 +154,6 @@
}
.progress-container {
grid-area: progress;
width: 100%;
margin: 0 auto;
opacity: 1;
@@ -195,10 +195,11 @@
font-weight: 600;
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
top: 2vw;
left: 2vw;
top: min(2vh, 16px);
left: min(2vw, 16px);
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
+227
View File
@@ -0,0 +1,227 @@
import { AnimatePresence } from 'framer-motion';
import {
CustomFields,
MessageState,
OntimeEvent,
ProjectData,
Settings,
SimpleTimerState,
ViewSettings,
} from 'ontime-types';
import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getTimerByType } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { MotionTitleCard, titleVariants } from './timer.animations';
import { getTimerOptions, useTimerOptions } from './timer.options';
import {
getCardData,
getEstimatedFontSize,
getIsPlaying,
getSecondaryDisplay,
getShowClock,
getShowMessage,
getShowModifiers,
getShowProgressBar,
getTimerColour,
getTotalTime,
} from './timer.utils';
import './Timer.scss';
interface TimerProps {
auxTimer: SimpleTimerState;
customFields: CustomFields;
eventNext: OntimeEvent | null;
eventNow: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
message: MessageState;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { auxTimer, customFields, eventNow, eventNext, general, isMirrored, message, settings, time, viewSettings } =
props;
const {
hideClock,
hideCards,
hideProgress,
hideMessage,
hideExternal,
hideTimerSeconds,
removeLeadingZeros,
mainSource,
secondarySource,
timerType,
} = useTimerOptions();
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
useWindowTitle('Timer');
// gather modifiers
const viewTimerType = timerType ?? time.timerType;
const showOverlay = getShowMessage(message.timer);
const { showEndMessage, showFinished, showWarning, showDanger } = getShowModifiers(
time.timerType,
time.countToEnd,
time.phase,
viewSettings,
);
const isPlaying = getIsPlaying(time.playback);
const showClock = !hideClock && getShowClock(viewTimerType);
const showProgressBar = !hideProgress && getShowProgressBar(viewTimerType);
// gather card data
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
mainSource,
secondarySource,
time.playback,
time.phase,
);
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const clock = formatTime(time.clock);
const stageTimer = getTimerByType(viewSettings.freezeEnd, time, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
});
const secondaryContent = getSecondaryDisplay(
message,
auxTimer.current,
localisedMinutes,
hideTimerSeconds,
removeLeadingZeros,
hideExternal,
);
// gather presentation styles
const timerColour = getTimerColour(viewSettings, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timerOptions = getTimerOptions(defaultFormat, customFields);
return (
<div
className={cx(['stage-timer', isMirrored && 'mirror', showFinished && 'stage-timer--finished'])}
data-testid='timer-view'
>
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={32} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
)}
{showClock && (
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='clock' />
</div>
)}
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
{showEndMessage ? (
<FitText mode='multi' min={64} max={256} className='end-message'>
{viewSettings.endMessage}
</FitText>
) : (
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{
fontSize: `${timerFontSize}vw`,
'--phase-color': timerColour,
}}
data-phase={time.phase}
>
{display}
</div>
)}
<div
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
</div>
</div>
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
)}
{!hideCards && (
<>
<AnimatePresence>
{showNow && (
<MotionTitleCard
className='event now'
key='now'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='now'
title={nowMain}
secondary={nowSecondary}
/>
)}
</AnimatePresence>
<AnimatePresence>
{showNext && (
<MotionTitleCard
className='event next'
key='next'
variants={titleVariants}
initial='hidden'
animate='visible'
exit='exit'
label='next'
title={nextMain}
secondary={nextSecondary}
/>
)}
</AnimatePresence>
</>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { motion } from 'framer-motion';
import TitleCard from '../../common/components/title-card/TitleCard';
export const titleVariants = {
hidden: {
x: -2500,
},
visible: {
x: 0,
transition: {
duration: 1,
},
},
exit: {
x: -2500,
},
};
export const MotionTitleCard = motion(TitleCard);
@@ -0,0 +1,141 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, TimerType } from 'ontime-types';
import { validateTimerType } from 'ontime-utils';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions = {
'no-overrides': 'No Overrides',
'count-up': 'Count up',
'count-down': 'Count down',
clock: 'Clock',
};
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{
id: 'timerType',
title: 'Timer type',
description: 'Override the timer type',
type: 'option',
values: timerDisplayOptions,
defaultValue: 'no-overrides',
},
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Timer Message',
description: 'Prevents displaying fullscreen messages in the timer',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide Auxiliary timer / External message',
description: 'Prevents the screen from displaying the secondary timer field',
type: 'boolean',
defaultValue: false,
},
];
};
type TimerOptions = {
hideClock: boolean;
hideCards: boolean;
hideProgress: boolean;
hideMessage: boolean;
hideExternal: boolean;
hideTimerSeconds: boolean;
removeLeadingZeros: boolean;
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
timerType?: TimerType;
};
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions {
const timerType = validateTimerType(searchParams.get('timerType'), TimerType.None);
// we manually make an object that matches the key above
return {
hideClock: isStringBoolean(searchParams.get('hideClock')),
hideCards: isStringBoolean(searchParams.get('hideCards')),
hideProgress: isStringBoolean(searchParams.get('hideProgress')),
hideMessage: isStringBoolean(searchParams.get('hideMessage')),
hideExternal: isStringBoolean(searchParams.get('hideExternal')),
hideTimerSeconds: isStringBoolean(searchParams.get('hideTimerSeconds')),
removeLeadingZeros: !isStringBoolean(searchParams.get('showLeadingZeros')),
mainSource: searchParams.get('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
// none doesnt make sense as a configuration of the view
timerType: timerType === TimerType.None ? undefined : timerType,
};
}
/**
* Hook exposes the timer view options
*/
export function useTimerOptions(): TimerOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
+183
View File
@@ -0,0 +1,183 @@
import {
MaybeNumber,
MessageState,
OntimeEvent,
Playback,
TimerMessage,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
* Whether a message should be shown
*/
export function getShowMessage(message: TimerMessage): boolean {
return message.text !== '' && message.visible;
}
/**
* Whether the playback is playing
*/
export function getIsPlaying(playback: Playback): boolean {
return playback === Playback.Play || playback === Playback.Roll;
}
/**
* Gets the total time from the duration and added time of an event
*/
export function getTotalTime(duration: MaybeNumber, addedTime: MaybeNumber): number {
return (duration ?? 0) + (addedTime ?? 0);
}
/**
* Whether the progress bar should be shown for this timer type
*/
export function getShowProgressBar(timerType: TimerType) {
return timerType !== TimerType.None && timerType !== TimerType.Clock;
}
/**
* Whether the clock should be shown with this timer type
*/
export function getShowClock(timerType: TimerType) {
return timerType !== TimerType.Clock;
}
const fontSizeMap: { [key: number]: number } = {
4: 28, // 9:01
5: 28, // -9:01, 10:01, 9 min
6: 25, // -10:01, 10 min
8: 20, // 23:01:01
9: 20, // -23:01:01
};
/**
* Finds a font size that fits the timer in the screen
* Unfortunately hand tweaked
*/
export function getEstimatedFontSize(stageTimer: string, secondaryContent?: string) {
const stageTimerCharacters = stageTimer.length;
let timerFontSize = (100 / (stageTimerCharacters - 1)) * 1.25;
if (fontSizeMap[stageTimerCharacters]) {
timerFontSize = fontSizeMap[stageTimerCharacters];
}
let externalFontSize = timerFontSize * 0.2;
if (secondaryContent) {
// we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen
timerFontSize *= 0.6;
if (secondaryContent.length > 25) {
externalFontSize = (100 / (secondaryContent.length - 1)) * 1.8;
}
}
return {
timerFontSize,
externalFontSize,
};
}
/**
* which, if any, modifier should be shown at any time
*/
export function getShowModifiers(
timerType: TimerType,
countToEnd: boolean,
phase: TimerPhase,
viewSettings: ViewSettings,
) {
const showModifiers = timerType === TimerType.CountDown || countToEnd;
const finished = phase === TimerPhase.Overtime;
return {
showEndMessage: showModifiers && finished && viewSettings.endMessage,
showFinished: showModifiers && finished, // ????
showWarning: showModifiers && phase === TimerPhase.Warning,
showDanger: showModifiers && phase === TimerPhase.Danger,
};
}
/**
* Which colour should the timer have at a given moment
*/
export function getTimerColour(viewSettings: ViewSettings, showWarning: boolean, showDanger: boolean) {
if (showWarning) return viewSettings.warningColor;
if (showDanger) return viewSettings.dangerColor;
return viewSettings.normalColor;
}
/**
* What, if anything, should be displayed in the secondary field
*/
export function getSecondaryDisplay(
message: MessageState,
currentAux: MaybeNumber,
localisedMinutes: string,
removeSeconds: boolean,
removeLeadingZero: boolean,
hideExternal: boolean,
): string | undefined {
if (hideExternal) {
return;
}
if (message.timer.secondarySource === 'aux') {
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
removeSeconds,
removeLeadingZero,
});
}
if (message.timer.secondarySource === 'external' && message.external) {
return message.external;
}
return;
}
/**
* What should we be showing in the cards?
*/
export function getCardData(
eventNow: OntimeEvent | null,
eventNext: OntimeEvent | null,
mainSource: keyof OntimeEvent | null,
secondarySource: keyof OntimeEvent | null,
playback: Playback,
phase: TimerPhase,
) {
if (playback === Playback.Stop) {
return {
showNow: false,
nowMain: undefined,
nowSecondary: undefined,
showNext: false,
nextMain: undefined,
nextSecondary: undefined,
};
}
// pending roll timers would be classified as active
const hasActiveTimer = isPlaybackActive(playback) && phase !== TimerPhase.Pending;
// if we are loaded, we show the upcoming event as next
const nowMain = hasActiveTimer ? getPropertyValue(eventNow, mainSource ?? 'title') : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource) : undefined;
const nextMain = hasActiveTimer
? getPropertyValue(eventNext, mainSource ?? 'title')
: getPropertyValue(eventNow, mainSource ?? 'title');
const nextSecondary = hasActiveTimer
? getPropertyValue(eventNext, secondarySource)
: getPropertyValue(eventNow, secondarySource);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
nextMain,
nextSecondary,
};
}
-1
View File
@@ -42,7 +42,6 @@ export default defineConfig({
},
test: {
globals: true,
setupFiles: './src/setupTests.js',
environment: 'jsdom',
},
build: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.10.2",
"version": "3.10.7",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+3 -1
View File
@@ -2,10 +2,12 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.10.2",
"version": "3.10.7",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.1",
+12 -5
View File
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils';
import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null;
@@ -34,7 +35,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null;
private isFirstEditor = true;
private shouldShowWelcome = true;
constructor() {
if (instance) {
@@ -47,10 +48,16 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server, prefix?: string) {
init(server: Server, showWelcome: boolean, prefix?: string) {
this.shouldShowWelcome = showWelcome;
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
this.wss.on('connection', (ws, req) => {
authenticateSocket(ws, req, (error) => {
if (error) {
ws.close(1008, 'Unauthorized');
}
});
const clientId = generateId();
this.clients.set(clientId, {
@@ -136,8 +143,8 @@ export class SocketServer implements IAdapter {
previousData.path = payload;
this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.isFirstEditor) {
this.isFirstEditor = false;
if (payload.includes('editor') && this.shouldShowWelcome) {
this.shouldShowWelcome = false;
ws.send(
JSON.stringify({
type: 'dialog',
+3 -2
View File
@@ -15,6 +15,7 @@ import {
handleUploaded,
} from '../../services/project-service/projectServiceUtils.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try {
@@ -41,7 +42,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const newFileName = await projectService.createProject(req.body.filename, {
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
@@ -67,7 +68,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const filename = await projectService.createProject(req.body.project.title, req.body);
const filename = await projectService.createProject(req.body.project.title || 'untitled', req.body);
res.status(200).send({
filename,
});
@@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
* @description Validates request for a new project.
*/
export const validateNewProject = [
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -7,6 +7,7 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
const startedAt = new Date();
@@ -24,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName,
playback,
timezone: startedAt.getTimezoneOffset(),
timezone: getTimezoneLabel(startedAt),
};
}
@@ -6,6 +6,7 @@ import type { Request, Response } from 'express';
import { isDocker } from '../../externals.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { extractPin } from './settings.utils.js';
@@ -65,3 +66,8 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
res.status(400).send({ message });
}
}
export async function postWelcomeDialog(req: Request, res: Response) {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).send({ show });
}
@@ -1,8 +1,10 @@
import express from 'express';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -1,6 +1,18 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings/welcomedialog
*/
export const validateWelcomeDialog = [
body('show').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
@@ -8,7 +8,7 @@ export const validateRequestConnection = [
.exists()
.isString()
.isLength({
min: 40,
min: 20,
max: 100,
})
.withMessage('Sheet ID is usually 44 characters long'),
+22 -19
View File
@@ -5,16 +5,18 @@ import express from 'express';
import http, { Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import cookieParser from 'cookie-parser';
// import utils
import { publicDir, srcDir } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import middleware configuration
import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js';
// import utils
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -44,6 +46,7 @@ import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './config/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -72,19 +75,18 @@ if (!isProduction) {
}
app.disable('x-powered-by');
// setup cors for all routes
app.use(cors());
// enable pre-flight cors
app.options('*', cors());
// Implement middleware
app.use(cors()); // setup cors for all routes
app.options('*', cors()); // enable pre-flight cors
app.use(bodyParser);
app.use(prefix, compressedStatic);
app.use(cookieParser());
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
// Implement route endpoints
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
// serve static external files
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
@@ -94,9 +96,9 @@ app.use(`${prefix}/external`, (req, res) => {
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Base route for static files
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
// Implement catch all
app.use((_error, response) => {
@@ -166,8 +168,9 @@ export const startServer = async (
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog();
socket.init(expressServer, prefix);
socket.init(expressServer, showWelcome, prefix);
/**
* Module initialises the services and provides initial payload for the store
+2
View File
@@ -15,6 +15,8 @@ export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD;
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta name="description" content="Login page for Ontime application" />
<title>Ontime Login</title>
<style>
body,
html {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
sans-serif;
background: #101010;
text-align: center;
box-sizing: border-box;
}
h1 {
padding-top: 30vh;
font-size: 3rem;
color: #ff7597;
font-weight: 400;
}
input {
all: unset;
text-align: start;
padding-left: 0.5rem;
background-color: #fffffa;
color: black;
height: 2rem;
border-radius: 2px;
margin-right: 0.5rem;
}
input:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
color: #262626;
}
button {
all: unset;
color: #779be7;
background: #2d2d2d;
height: 2rem;
padding-inline: 2rem;
border-radius: 2px;
cursor: pointer;
}
button:hover {
background: #404040;
}
button:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
}
</style>
</head>
<body>
<form method="post" class="form" autocomplete="off">
<h1>Welcome to Ontime</h1>
<input type="hidden" name="redirect" value="" id="redirect" />
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button type="submit">Login</button>
</form>
</body>
<script>
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect');
if (redirect) {
// Validate redirect URL to prevent open redirect attacks
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
if (isValidRedirect) {
document.getElementById('redirect').value = redirect;
}
}
</script>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { LogOrigin } from 'ontime-types';
import express, { type Request, type Response, type NextFunction } from 'express';
import type { IncomingMessage } from 'node:http';
import type { WebSocket } from 'ws';
import { parse as parseCookie } from 'cookie';
import { hashPassword } from '../utils/hash.js';
import { srcFiles } from '../setup/index.js';
import { logger } from '../classes/Logger.js';
import { password } from '../externals.js';
import { noopMiddleware } from './noop.js';
export const hasPassword = Boolean(password);
const hashedPassword = hasPassword ? hashPassword(password) : '';
/**
* List of public assets that can be accessed without authentication
* should match the files in client/public
*/
const publicAssets = new Set([
'/favicon.ico',
'/manifest.json',
'/ontime-logo.png',
'/robots.txt',
'/site.webmanifest',
]);
export const loginRouter = express.Router();
// serve static files at root
loginRouter.use('/', express.static(srcFiles.login));
// verify password and set cookies + redirect appropriately
loginRouter.post('/', (req, res) => {
res.clearCookie('token');
const { password: reqPassword, redirect } = req.body;
if (hashPassword(reqPassword) === hashedPassword) {
setSessionCookie(res, hashedPassword);
res.redirect(redirect || '/');
return;
}
res.status(401).send('Unauthorized');
});
/**
* Express middleware to authenticate requests
* @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud
*/
export function makeAuthenticateMiddleware(prefix: string) {
// we dont need to initialise the authenticate middleware if there is no password
if (!hasPassword) {
return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware };
}
function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.query.token || req.cookies?.token;
if (token && token === hashedPassword) {
return next();
}
res.status(401).send('Unauthorized');
}
function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) {
// Allow access to specific public assets without authentication
if (publicAssets.has(req.originalUrl)) {
return next();
}
// we shouldnt be here in the login route
if (req.originalUrl.startsWith('/login')) {
return next();
}
// we expect the token to be in the cookies
if (req.cookies?.token === hashedPassword) {
return next();
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
setSessionCookie(res, hashedPassword);
return next();
}
res.redirect(`${prefix}/login?redirect=${req.originalUrl}`);
}
return { authenticate, authenticateAndRedirect };
}
/**
* Middleware to authenticate a WebSocket connection with a token in the cookie
*/
export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) {
if (!hasPassword) {
return next();
}
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token === hashedPassword) {
return next();
}
}
// check if token is in the params
const url = new URL(req.url || '', `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt');
return next(new Error('Unauthorized'));
}
function setSessionCookie(res: Response, token: string) {
res.cookie('token', token, {
httpOnly: false, // allow websocket to access cookie
secure: true,
path: '/', // allow cookie to be accessed from any path
sameSite: 'strict',
});
}
+5
View File
@@ -0,0 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
next();
}
+28
View File
@@ -21,6 +21,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -47,6 +49,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -73,6 +77,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -99,6 +105,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -125,6 +133,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -156,6 +166,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -182,6 +194,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -208,6 +222,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -234,6 +250,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -260,6 +278,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -291,6 +311,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -317,6 +339,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -343,6 +367,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -369,6 +395,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -25,6 +25,8 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
@@ -1,23 +1,7 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
};
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js';
describe('loadRoll()', () => {
const eventlist = [
@@ -1,4 +1,4 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { dayInMs, millisToString } from 'ontime-utils';
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
@@ -6,7 +6,6 @@ import {
getExpectedFinish,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
} from '../timerUtils.js';
@@ -1047,40 +1046,6 @@ describe('getRuntimeOffset()', () => {
});
});
describe('getTotalDuration()', () => {
it('calculates the duration of events in a single day', () => {
const start = MILLIS_PER_HOUR * 9;
const end = MILLIS_PER_HOUR * 17;
const daySpan = 0;
const duration = getTotalDuration(start, end, daySpan);
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9));
});
it('calculates the duration of events across days', () => {
const start = MILLIS_PER_HOUR * 9;
const end = MILLIS_PER_HOUR * 17;
const daySpan = 1;
const duration = getTotalDuration(start, end, daySpan);
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9) + dayInMs);
});
it('calculates the duration of events across days (2)', () => {
const start = new Date(0).setHours(12);
const end = new Date(0).setHours(8);
const daySpan = 1;
const duration = getTotalDuration(start, end, daySpan);
expect(millisToString(duration)).toBe('20:00:00');
});
it('calculates the duration of events across days (3)', () => {
const start = new Date(0).setHours(9);
const end = new Date(0).setHours(23);
const daySpan = 2;
const duration = getTotalDuration(start, end, daySpan);
expect(millisToString(duration)).toBe('62:00:00');
});
});
describe('getTimerPhase()', () => {
it('should be None if the timer is not running', () => {
const state = {
@@ -8,6 +8,7 @@ import { shouldCrashDev } from '../../utils/development.js';
interface AppState {
lastLoadedProject?: string;
showWelcomeDialog?: boolean;
}
const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -34,3 +35,19 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
config.data.lastLoadedProject = filename;
await config.write();
}
export async function getShowWelcomeDialog(): Promise<boolean> {
// in test environment, we do not want the dialog
if (isTest) return false;
await config.read();
return config.data.showWelcomeDialog ?? true; // default to true
}
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
if (isTest) return;
config.data.showWelcomeDialog = show;
await config.write();
return show;
}
@@ -9,6 +9,7 @@ import {
appendToName,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
@@ -20,6 +21,7 @@ import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
@@ -39,7 +41,6 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
// init dependencies
init();
@@ -267,7 +268,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data = safeMerge(dbModel, initialData);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
const fileNameWithExtension = ensureJsonExtension(filename);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
@@ -0,0 +1,31 @@
import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
revision: 1,
};
/**
* Utility to create a Ontime event
*/
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
/**
* Utility to create a delay event
*/
export function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration };
}
/**
* Utility to generate a rundown of OntimeEvents form partial objects
*/
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
@@ -1,20 +1,8 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js';
import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
/**
* Small utility to fill in the necessary data for the test
*/
function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
}
/**
* Small utility to make a delay event
*/
function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
}
import { apply } from '../delayUtils.js';
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('apply()', () => {
it('applies a positive delay to the rundown', () => {
@@ -150,11 +138,11 @@ describe('apply()', () => {
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
// gap 0
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
// gap 50
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
// linked
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
];
@@ -165,7 +153,7 @@ describe('apply()', () => {
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// gap 50 (50 - 50)
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
// gap (delay is 0)
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
@@ -178,6 +166,8 @@ describe('apply()', () => {
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
gap: 0,
dayOffset: 0,
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
@@ -185,6 +175,8 @@ describe('apply()', () => {
// gap 1h
makeOntimeEvent({
id: '2',
gap: 1 * MILLIS_PER_HOUR,
dayOffset: 0,
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
@@ -577,6 +577,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
@@ -605,6 +607,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -633,6 +637,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -661,6 +667,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -701,6 +709,8 @@ describe('getDelayAt()', () => {
timeDanger: 60000,
id: '659e1',
delay: 0,
dayOffset: 0,
gap: 0,
cue: '1',
custom: {},
},
@@ -725,6 +735,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -753,6 +765,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -781,6 +795,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -835,6 +851,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
@@ -863,6 +881,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -891,6 +911,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -919,6 +941,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -9,12 +9,14 @@ import {
} from 'ontime-types';
import {
addToCustomAssignment,
calculateDayOffset,
getLink,
handleCustomField,
handleLink,
hasChanges,
isDataStale,
} from '../rundownCacheUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('getLink()', () => {
it('should return null if there is no link', () => {
@@ -247,3 +249,61 @@ describe('hasChanges()', () => {
expect(hasChanges(existing, newEvent)).toBe(false);
});
});
describe('calculateDayOffset', () => {
it('returns 0 if there is no previous event', () => {
expect(calculateDayOffset({ timeStart: 0 })).toBe(0);
});
it('returns 0 if the previous event duration is 0', () => {
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
});
it('returns 0 if event starts after previous', () => {
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
});
it('returns 1 if event starts before previous', () => {
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('returns 1 if event starts at the same time as one before', () => {
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('should account for an event that crossed midnight and there is a overlap', () => {
expect(
calculateDayOffset(
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight and there is a gap', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that finishes exactly at midnight', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
),
).toBe(1);
});
});
@@ -1,5 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
import { deleteAtIndex } from 'ontime-utils';
/**
* Calculates all delays in a given rundown
@@ -133,21 +133,14 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry.timeStart,
lastEntry.timeEnd,
lastEntry.duration,
);
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (timeFromPrevious > 0) {
delayValue = Math.max(delayValue - timeFromPrevious, 0);
if (currentEntry.gap > 0) {
delayValue = Math.max(delayValue - currentEntry.gap, 0);
}
if (delayValue === 0) {
@@ -24,7 +24,7 @@ import {
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
@@ -55,6 +55,7 @@ function setIsStale() {
let totalDelay = 0;
let totalDuration = 0;
let totalDays = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
@@ -107,6 +108,7 @@ export function generate(
firstStart = null;
lastEnd = null;
totalDuration = 0;
totalDays = 0;
totalDelay = 0;
let lastEntry: PlayableEvent | null = null;
@@ -117,6 +119,7 @@ export function generate(
if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
currentEntry.gap = 0;
// 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links);
@@ -124,6 +127,9 @@ export function generate(
// 2. handle custom fields - mutates updatedEvent
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
totalDays += calculateDayOffset(currentEntry, lastEntry);
currentEntry.dayOffset = totalDays;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
// fist start is always the first event
@@ -131,36 +137,31 @@ export function generate(
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
if (currentEntry.gap === 0) {
// event starts on previous finish, we add its duration
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
} else if (currentEntry.gap > 0) {
// event has a gap, we add the gap and the duration
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += currentEntry.gap + currentEntry.duration;
} else if (currentEntry.gap < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (totalDelay > 0 && timeFromPrevious > 0) {
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
if (totalDelay > 0 && currentEntry.gap > 0) {
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
}
// current event delay is the current accumulated delay
currentEntry.delay = totalDelay;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
@@ -7,7 +7,7 @@ import {
OntimeRundownEntry,
OntimeBaseEvent,
} from 'ontime-types';
import { getLinkedTimes } from 'ontime-utils';
import { dayInMs, getLinkedTimes } from 'ontime-utils';
/**
* Get linked event
@@ -147,3 +147,36 @@ export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
);
}
/**
* Utility for calculating if the current events should have a day offset
* @param current the current event under test
* @param previous the previous event
* @returns 0 or 1 for easy accumulation with the total days
*/
export function calculateDayOffset(
current: Pick<OntimeEvent, 'timeStart'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration'>,
) {
// if there is no previous there can't be a day offset
if (!previous) {
return 0;
}
// if the previous events duration is zero it will push the current event to next day
if (previous.duration === 0) {
return 0;
}
// if the previous event crossed midnight then the current event is in the next day
if (previous.timeStart + previous.duration >= dayInMs) {
return 1;
}
// if the current events starts at the same time or before the previous event then it is the next day
if (current.timeStart <= previous.timeStart) {
return 1;
}
return 0;
}
@@ -37,6 +37,8 @@ describe('cellRequestFromEvent()', () => {
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -84,6 +86,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -132,6 +136,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -178,6 +184,8 @@ describe('cellRequestFromEvent()', () => {
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -212,6 +220,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -246,6 +256,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
-20
View File
@@ -156,26 +156,6 @@ export function getRuntimeOffset(state: RuntimeState): number {
return startOffset - addedTime - pausedTime + overtime;
}
/**
* Calculates total duration of a time span
* @param firstStart
* @param lastEnd
* @param daySpan
* @returns
*/
export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
if (!lastEnd) {
return 0;
}
let correctDay = 0;
if (lastEnd < firstStart) {
correctDay = dayInMs;
daySpan -= 1;
}
// eslint-disable-next-line prettier/prettier -- we like the clarity
return lastEnd + correctDay + daySpan * dayInMs - firstStart;
}
/**
* Calculates the expected end of the rundown
*/
+2
View File
@@ -87,6 +87,8 @@ export const srcFiles = {
userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
};
/**
@@ -0,0 +1,46 @@
import { TimerPhase, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0,
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
};
export function makeRuntimeStateData(patch?: Partial<RuntimeState>) {
return deepmerge(baseState, patch);
}
+49 -47
View File
@@ -5,60 +5,62 @@
*/
:root {
/** Background colour for the views */
--background-color-override: #ececec;
/** Main text colour for the views */
--color-override: #101010;
/** Text colour for the views */
--secondary-color-override: #404040;
/** Background colour for the views */
--background-color-override: #ececec;
/** Accent text colour, used on active elements */
--accent-color-override: #fa5656;
/** Main text colour for the views */
--color-override: #101010;
/** Label text colour, used on active elements */
--label-color-override: #6c6c6c;
/** Text colour for the views */
--secondary-color-override: #404040;
/** Timer text colour */
--timer-color-override: #202020;
/** Accent text colour, used on active elements */
--accent-color-override: #fa5656;
/** Background for card elements on background */
--card-background-color-override: #fff;
/** Label text colour, used on active elements */
--label-color-override: #6c6c6c;
/** Background highlight for blink behaviour, used only in /backstage */
--card-background-color-blink-override: #339e4e;
/** Font used for all text in views */
--font-family-override: "Open Sans";
/** Timer text colour */
--timer-color-override: #202020;
--timer-warning-color-override: #ffbc56;
--timer-danger-color-override: #e69000;
--timer-overtime-color-override: #fa5656;
/** Font used for clock in /minimal and /clock views */
--font-family-bold-override: "Arial Black";
/** Background for card elements on background */
--card-background-color-override: #fff;
/** Colour used for progress bar background */
--timer-progress-bg-override: #fff;
/** Font used for all text in views */
--font-family-override: 'Open Sans';
/** Colour used for progress bar progress */
--timer-progress-override: #202020;
/** Colour used for external message and aux timer in /timer */
--external-color-override: #161616;
/** View specific features: /studio */
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
/** View specific features: /lower */
--lowerThird-font-family-override: 'Courier New';
--lowerThird-top-font-weight-override: bold;
--lowerThird-bottom-font-weight-override: bold;
--lowerThird-top-font-style-override: normal;
--lowerThird-bottom-font-style-override: italic;
--lowerThird-line-height-override: 1vh;
--lowerThird-text-align-override: end;
/** Font used for clock in /minimal and /clock views */
--font-family-bold-override: 'Arial Black';
/** Colour used for external message and aux timer in /timer */
--external-color-override: #161616;
/** View specific features: /backstage */
/** ---- Background highlight for blink behaviour */
--card-background-color-blink-override: #339e4e;
/** ---- Colour used for progress bar background */
--timer-progress-bg-override: #fff;
/** ---- Colour used for progress bar progress */
--timer-progress-override: #202020;
/** View specific features: /studio */
--studio-active: #101010;
--studio-idle: #cfcfcf;
--studio-active-label: #101010;
--studio-idle-label: #595959;
--studio-overtime: #101010;
/** View specific features: /lower */
--lowerThird-font-family-override: 'Courier New';
--lowerThird-top-font-weight-override: bold;
--lowerThird-bottom-font-weight-override: bold;
--lowerThird-top-font-style-override: normal;
--lowerThird-bottom-font-style-override: italic;
--lowerThird-line-height-override: 1vh;
--lowerThird-text-align-override: end;
}
/**
@@ -66,5 +68,5 @@
* In the below example, we change the colour of the overlay message in the stage-timer view.
*/
.stage-timer > .message-overlay--active > div {
color: red;
color: red;
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash } from 'node:crypto';
/**
* Creates a hash of the password that is URL safe
* @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs
*/
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('base64url');
}
+3 -1
View File
@@ -384,7 +384,9 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour),
delay: 0, //delay is always regenerated by the cache
delay: 0, // is always regenerated by the cache
dayOffset: 0, // is always regenerated by the cache
gap: 0, // is always regenerated by the cache
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
revision: originalEvent.revision,
+16 -2
View File
@@ -1,5 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
@@ -46,3 +45,18 @@ export function parseExcelDate(excelDate: unknown): number {
return 0;
}
export function getTimezoneLabel(date: Date): string {
const tz = date.getTimezoneOffset();
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
// timezone offset is inverted
const sign = tz < 0 ? '+' : '-';
const abs = Math.abs(tz);
// convert minutes to hours
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
}
+419 -295
View File
@@ -1,307 +1,420 @@
{
"rundown": [
{
"title": "Albania",
"note": "SF1.01",
"endAction": "none",
"timerType": "count-down",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "32d31",
"cue": "SF1.01",
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
},
{
"title": "Latvia",
"note": "SF1.02",
"endAction": "none",
"timerType": "count-down",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "21cd2",
"cue": "SF1.02",
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"title": "Lithuania",
"note": "SF1.03",
"endAction": "none",
"timerType": "count-down",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "0b371",
"cue": "SF1.03",
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"title": "Switzerland",
"note": "SF1.04",
"endAction": "none",
"timerType": "count-down",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "3cd28",
"cue": "SF1.04",
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"title": "Slovenia",
"note": "SF1.05",
"endAction": "none",
"timerType": "count-down",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "e457f",
"cue": "SF1.05",
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"title": "Ukraine",
"note": "SF1.06",
"endAction": "none",
"timerType": "count-down",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "1c420",
"cue": "SF1.06",
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"title": "Bulgaria",
"note": "SF1.07",
"endAction": "none",
"timerType": "count-down",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "b7737",
"cue": "SF1.07",
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"title": "Netherlands",
"note": "SF1.08",
"endAction": "none",
"timerType": "count-down",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3a80",
"cue": "SF1.08",
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"title": "Moldova",
"note": "SF1.09",
"endAction": "none",
"timerType": "count-down",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "8276c",
"cue": "SF1.09",
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"title": "Portugal",
"note": "SF1.10",
"endAction": "none",
"timerType": "count-down",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "2340b",
"cue": "SF1.10",
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"title": "Croatia",
"note": "SF1.11",
"endAction": "none",
"timerType": "count-down",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "503c4",
"cue": "SF1.11",
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"title": "Denmark",
"note": "SF1.12",
"endAction": "none",
"timerType": "count-down",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "5e965",
"cue": "SF1.12",
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"title": "Austria",
"note": "SF1.13",
"endAction": "none",
"timerType": "count-down",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "bab4a",
"cue": "SF1.13",
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"title": "Greece",
"note": "SF1.14",
"endAction": "none",
"timerType": "count-down",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3eb1",
"cue": "SF1.14",
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
{
"id": "32d31",
"type": "event",
"title": "Albania",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.01",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.01",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
],
},
{
"id": "21cd2",
"type": "event",
"title": "Latvia",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.02",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.02",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"id": "0b371",
"type": "event",
"title": "Lithuania",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.03",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.03",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"id": "3cd28",
"type": "event",
"title": "Switzerland",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.04",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.04",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"id": "e457f",
"type": "event",
"title": "Slovenia",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.05",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.05",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"id": "1c420",
"type": "event",
"title": "Ukraine",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.06",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.06",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"id": "b7737",
"type": "event",
"title": "Bulgaria",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.07",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.07",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"id": "d3a80",
"type": "event",
"title": "Netherlands",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.08",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.08",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"id": "8276c",
"type": "event",
"title": "Moldova",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.09",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.09",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"id": "2340b",
"type": "event",
"title": "Portugal",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.10",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.10",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"id": "503c4",
"type": "event",
"title": "Croatia",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.11",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.11",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"id": "5e965",
"type": "event",
"title": "Denmark",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.12",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.12",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"id": "bab4a",
"type": "event",
"title": "Austria",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.13",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.13",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"id": "d3eb1",
"type": "event",
"title": "Greece",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.14",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.14",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
}
],
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
"publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null
},
"settings": {
"app": "ontime",
"version": "2.0.0",
"version": "3.10.2",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
@@ -312,10 +425,9 @@
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"warningThreshold": 120000,
"dangerColor": "#ED3333",
"dangerThreshold": 60000,
"endMessage": ""
"endMessage": "",
"freezeEnd": false
},
"urlPresets": [
{
@@ -335,5 +447,17 @@
"http": {
"enabledOut": true,
"subscriptions": []
},
"customFields": {
"song": {
"type": "string",
"colour": "",
"label": "song"
},
"artist": {
"type": "string",
"colour": "",
"label": "artist"
}
}
}
+57
View File
@@ -0,0 +1,57 @@
# Automations
The automation feature's purpose is to integrate ontime into users' workflows.
Ontime has a large amount of production information, which users need considerable effort to maintain. We want to allow tools so that:
- allow distribution of Ontime's and other production data
- allow surfacing Ontime events
- allow synchronizing with other tools
## Previous
Previous iterations imposed limitations on the number of integrations and target devices to evaluate performance concerns.
The feature was not as used as we had hoped, and users often escalated the integration to tools like Companion.
I believe this to be in part from a lack of clarity on the feature and the limitations imposed
- Lack of explicit filtering logic made the process hard to reason
- Users met limitations on target devices earlier than expected. We would want users to meet these limitations only when the project grew over a size where a show controller would be needed
- Building the template strings was complex and poorly documented
## Overview
- The user should be able to create as many automations as they want
- Automations are triggered by lifecycle events
- Each automation should go through a user-defined filtering process
- Each automation should be able to target multiple devices and multiple protocols
- We leverage HTTP / OSC as the main protocols ~~and add support for triggering Companion button presses~~
- To allow easier debugging and "learn" workflows, users should be able to test a message before saving the automation
- We should have inline documentation for the template strings
> ⚠️ **Companion module** \
> We have opted for removing the requirement for companion module integration. \
> We did not see as much friction that could be solved with a custom integration as initially thought. Triggering a button press in companion is done trivially over either OSC or HTTP and companion users are familiar with the process.
## Implementation details
- To simplify the usage of template strings, we will generate a list of template strings at runtime from the user project file
- To allow easier implementation and extensions, we want to keep the automations separate from triggers
```ts
type Automation = {
id: AutomationId;
name: string;
filter: Filter[];
output: Output[];
};
type Trigger = {
id: string;
event: TimerLifecycle;
automations: AutomationId[];
};
```
### Extension
- Users have expressed a desire to have automation triggered by the lifecycle of a specific event. By keeping the trigger separate from the automation, we will allow this to be implemented in the future.
+34 -3
View File
@@ -1,13 +1,13 @@
import { test, expect } from '@playwright/test';
import { readFile } from 'fs/promises';
const fileToUpload = 'e2e/tests/fixtures/test-db.json';
const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
@@ -34,3 +34,34 @@ test('project file upload', async ({ page }) => {
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title');
await expect(thirdTitle).toHaveValue('Lithuania');
});
test('project file download', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'toggle settings' }).click();
await page.getByRole('button', { name: 'Project', exact: true }).click();
// workaround to download
// https://playwright.dev/docs/api/class-download
const downloadPromise = page.waitForEvent('download');
await page
.getByRole('row', { name: RegExp('^test-db') })
.getByLabel('Options')
.click();
await page.getByRole('menuitem', { name: 'Download' }).click();
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs(fileToDownload);
expect(download.failure()).toMatchObject({});
const original = JSON.parse(await readFile(fileToUpload, { encoding: 'utf-8' }));
const fromServer = JSON.parse(await readFile(fileToDownload, { encoding: 'utf-8' }));
// when a file is parsed, the server will write the version number to the project file
original.settings.version = 'not-important';
fromServer.settings.version = 'not-important';
expect(original).toMatchObject(fromServer);
});
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
test('show warning when event crosses midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-2').getByTestId('lock__end').getByRole('img').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('23h');
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
await page.getByTestId('entry-3').getByTestId('time-input-duration').click();
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('2h');
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
await expect(page.getByTestId('entry-3').getByTestId('event-warning')).toBeVisible();
});
test('show warning when event starts next day midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByTestId('entry-2').getByText('E').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('0');
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
await page.getByRole('button', { name: 'Event', exact: true }).nth(2).click();
await expect(page.getByText('(next day)')).toBeVisible();
});
+419 -295
View File
@@ -1,307 +1,420 @@
{
"rundown": [
{
"title": "Albania",
"note": "SF1.01",
"endAction": "none",
"timerType": "count-down",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "32d31",
"cue": "SF1.01",
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
},
{
"title": "Latvia",
"note": "SF1.02",
"endAction": "none",
"timerType": "count-down",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "21cd2",
"cue": "SF1.02",
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"title": "Lithuania",
"note": "SF1.03",
"endAction": "none",
"timerType": "count-down",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "0b371",
"cue": "SF1.03",
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"title": "Switzerland",
"note": "SF1.04",
"endAction": "none",
"timerType": "count-down",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "3cd28",
"cue": "SF1.04",
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"title": "Slovenia",
"note": "SF1.05",
"endAction": "none",
"timerType": "count-down",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "e457f",
"cue": "SF1.05",
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"title": "Ukraine",
"note": "SF1.06",
"endAction": "none",
"timerType": "count-down",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "1c420",
"cue": "SF1.06",
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"title": "Bulgaria",
"note": "SF1.07",
"endAction": "none",
"timerType": "count-down",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "b7737",
"cue": "SF1.07",
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"title": "Netherlands",
"note": "SF1.08",
"endAction": "none",
"timerType": "count-down",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3a80",
"cue": "SF1.08",
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"title": "Moldova",
"note": "SF1.09",
"endAction": "none",
"timerType": "count-down",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "8276c",
"cue": "SF1.09",
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"title": "Portugal",
"note": "SF1.10",
"endAction": "none",
"timerType": "count-down",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "2340b",
"cue": "SF1.10",
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"title": "Croatia",
"note": "SF1.11",
"endAction": "none",
"timerType": "count-down",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "503c4",
"cue": "SF1.11",
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"title": "Denmark",
"note": "SF1.12",
"endAction": "none",
"timerType": "count-down",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "5e965",
"cue": "SF1.12",
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"title": "Austria",
"note": "SF1.13",
"endAction": "none",
"timerType": "count-down",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "bab4a",
"cue": "SF1.13",
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"title": "Greece",
"note": "SF1.14",
"endAction": "none",
"timerType": "count-down",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3eb1",
"cue": "SF1.14",
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
{
"id": "32d31",
"type": "event",
"title": "Albania",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.01",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.01",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
],
},
{
"id": "21cd2",
"type": "event",
"title": "Latvia",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.02",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.02",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"id": "0b371",
"type": "event",
"title": "Lithuania",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.03",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.03",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"id": "3cd28",
"type": "event",
"title": "Switzerland",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.04",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.04",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"id": "e457f",
"type": "event",
"title": "Slovenia",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.05",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.05",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"id": "1c420",
"type": "event",
"title": "Ukraine",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.06",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.06",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"id": "b7737",
"type": "event",
"title": "Bulgaria",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.07",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.07",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"id": "d3a80",
"type": "event",
"title": "Netherlands",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.08",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.08",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"id": "8276c",
"type": "event",
"title": "Moldova",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.09",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.09",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"id": "2340b",
"type": "event",
"title": "Portugal",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.10",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.10",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"id": "503c4",
"type": "event",
"title": "Croatia",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.11",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.11",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"id": "5e965",
"type": "event",
"title": "Denmark",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.12",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.12",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"id": "bab4a",
"type": "event",
"title": "Austria",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.13",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.13",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"id": "d3eb1",
"type": "event",
"title": "Greece",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.14",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.14",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
}
],
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
"publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null
},
"settings": {
"app": "ontime",
"version": "2.0.0",
"version": "3.10.2",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
@@ -312,10 +425,9 @@
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"warningThreshold": 120000,
"dangerColor": "#ED3333",
"dangerThreshold": 60000,
"endMessage": ""
"endMessage": "",
"freezeEnd": false
},
"urlPresets": [
{
@@ -335,5 +447,17 @@
"http": {
"enabledOut": true,
"subscriptions": []
},
"customFields": {
"song": {
"type": "string",
"colour": "",
"label": "song"
},
"artist": {
"type": "string",
"colour": "",
"label": "artist"
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.10.2",
"version": "3.10.7",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -38,7 +38,7 @@
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@playwright/test": "^1.49.1",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
@@ -15,7 +15,7 @@ export interface SessionStats {
lastRequest: MaybeString;
projectName: string;
playback: Playback;
timezone: number;
timezone: string;
}
export interface GetInfo {
@@ -39,6 +39,8 @@ export type OntimeEvent = OntimeBaseEvent & {
colour: string;
revision: number;
delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime
gap: number; // calculated at runtime
timeWarning: number;
timeDanger: number;
custom: EventCustomFields;
+5 -2
View File
@@ -44,10 +44,11 @@ export {
millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
export {
formatFromMillis,
millisToString,
pad,
removeLeadingZero,
removeSeconds,
removeTrailingZero,
@@ -63,7 +64,9 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
export { deepmerge } from './src/externals/deepmerge.js';
// array utils
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js';
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils
export { getPropertyFromPath } from './src/common/objectUtils.js';
// generic utilities
export { getErrorMessage } from './src/generic/generic.js';
+18
View File
@@ -0,0 +1,18 @@
/**
* Extracts a value from a nested object using a dot-separated path
*/
export function getPropertyFromPath<T extends object>(path: string, obj: T): any | undefined {
const keys = path.split('.');
let result: any = obj;
// iterate through variable parts, and look for the property in the given object
for (const key of keys) {
if (result && typeof result === 'object' && key in result) {
result = result[key];
} else {
return undefined;
}
}
return result;
}
@@ -0,0 +1,17 @@
import { getPropertyFromPath } from './objectUtils';
describe('getNestedFromTemplate', () => {
it('should return the value of a nested property', () => {
expect(getPropertyFromPath('a', { a: 1 })).toBe(1);
expect(getPropertyFromPath('a.b', { a: { b: 1 } })).toBe(1);
expect(getPropertyFromPath('a.b.c', { a: { b: { c: 1 } } })).toBe(1);
});
it('should guard against values that do not exist', () => {
expect(getPropertyFromPath('c', {})).toBe(undefined);
expect(getPropertyFromPath('c', { a: 1 })).toBe(undefined);
expect(getPropertyFromPath('a.c', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('c.a', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('a.b.b', { a: { b: { c: 1 } } })).toBeUndefined();
});
});
@@ -2,66 +2,51 @@ import { checkIsNextDay } from './checkIsNextDay';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNextDay', () => {
it('returns false if the previous event duration is 0', () => {
const previousStart = 0;
const previousDuration = 0;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
it('returns false if there is no previous event', () => {
const current = { timeStart: 0, dayOffset: 0 };
const previous = undefined;
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns false if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 11;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 11, dayOffset: 0 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns true if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 9;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 9, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('returns true if event starts at the same time as one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 10;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 10, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('should account for an event that crossed midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 1 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight and there is a gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const previousDuration = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight with no overlaps', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 19 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that finishes exactly at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
it('should account for normalised start over multiple days', () => {
const previousStart = 90000000; // 25:00:00
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
});
+16 -30
View File
@@ -1,41 +1,27 @@
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event is the day after from its predecessor
* We consider an event to be the day after, if it begins before the start of the previous
* @example day after
* 09:00 - 10:00
* 08:00 - 10:30
* @example day after
* 23:00 - 00:00
* 02:00 - 03:00
* @example same day
* 09:00 - 10:00
* 09:30 - 10:30
* @example same day, but previous crosses midnight
* 23:00 - 01:00
* 02:00 - 03:00
* @example same day, but previous crosses midnight (with overlap)
* 22:00 - 02:00
* 01:00 - 03:00
*/
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration: number): boolean {
if (previousDuration === 0) {
export function checkIsNextDay(
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): boolean {
if (!previous) {
return false;
}
const cappedStart = previousStart % dayInMs;
if (timeStart <= cappedStart) {
const normalisedPreviousEnd = cappedStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) {
return true;
}
// handle exception for an event that finishes exactly at midnight
if (normalisedPreviousEnd > dayInMs) {
return false;
}
return true;
// if the day offsets are the same it can't be the next day
if (current.dayOffset <= previous.dayOffset) {
return false;
}
return false;
// if the previous event crossed midnight then the current is the same day
if (previous.timeStart + previous.duration > dayInMs) {
return false;
}
return true;
}
@@ -1,64 +1,58 @@
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
import { getTimeFromPrevious } from './getTimeFromPrevious';
describe('getTimeFromPrevious', () => {
it('returns the time elapsed (gap or overlap) from the previous', () => {
const previousStart = 69600000; // 19:20
const previousEnd = 71700000; // 19:55
const previousDuration = 2100000; // 35 minutes
const currentStart = 75600000; // 21:00
const expected = 75600000 - 71700000; // current start - previousEnd
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for partially overlapping events', () => {
const previousStart = 10;
const previousEnd = 12;
const previousDuration = 2;
const currentStart = 11;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -1;
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
expected,
);
});
it('accounts for events that are fully contained', () => {
const previousStart = 8;
const previousEnd = 16;
const previousDuration = 8;
const currentStart = 10;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -6;
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
expected,
);
});
it('fully overlapping events are the next day', () => {
const previousStart = 10 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const previousDuration = previousEnd - previousStart;
const currentStart = 10 * MILLIS_PER_HOUR;
const expected = dayInMs - previousDuration;
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
expect(
getTimeFromPrevious(
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that are the day after', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const previousDuration = 3 * MILLIS_PER_HOUR;
const currentStart = 22 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that cross midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR;
const currentStart = 1 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
});
@@ -1,45 +1,33 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility returns the time elapsed (gap or overlap) from the previous
* It uses deconstructed parameters to simplify implementation in UI
* Utility returns the gap from previous event
*/
export function getTimeFromPrevious(
currentStart: number,
previousStart?: number,
previousEnd?: number,
previousDuration?: number,
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): number {
// there is no previous event
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
if (!previous) {
return 0;
}
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
// event is linked to previous
if (currentStart === previousEnd) {
if (normalisedCurrentStart === normalisedPreviousEnd) {
return 0;
}
// event is the day after
if (checkIsNextDay(previousStart, currentStart, previousDuration)) {
// time from previous is difference between normalised start and previous end
return currentStart + dayInMs - previousEnd;
}
// event has a gap from previous
if (currentStart > previousEnd) {
if (normalisedCurrentStart > normalisedPreviousEnd) {
// time from previous is difference between start and previous end
return currentStart - previousEnd;
return normalisedCurrentStart - normalisedPreviousEnd;
}
// event overlaps with previous
const overlap = previousEnd - currentStart;
if (overlap > 0) {
// time is a negative number indicating the amount of overlap
return -overlap;
}
// we need to make sure we return a number, but there are no business cases for this
return 0;
return normalisedCurrentStart - normalisedPreviousEnd;
}

Some files were not shown because too many files have changed in this diff Show More