mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c910c079d | |||
| 420cef8b0d | |||
| b4c7b84f92 | |||
| 0450eb9169 | |||
| 7fe5223af9 | |||
| ce8d534953 | |||
| 4a6fbc2a92 | |||
| 50ebcf4e14 | |||
| f0f7d1cede | |||
| d2bdb1ea03 | |||
| 9be6febcda | |||
| aa929fd0dc | |||
| 7baa6a4ab9 | |||
| 082fe8b8d7 | |||
| e9a7cd0400 | |||
| 9ba40c35a1 | |||
| f94db06812 |
@@ -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/dist/ ./server/
|
||||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
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/user/ ./user/
|
||||||
|
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||||
|
|
||||||
# Export default ports
|
# Export default ports
|
||||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
[](https://github.com/sponsors/cpvalente)
|
[](https://github.com/sponsors/cpvalente)
|
||||||
[](https://www.buymeacoffee.com/cpvalente)
|
[](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-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 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>
|
- 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>
|
- 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?
|
## 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.
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.10.3",
|
"version": "3.10.4",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.10.3",
|
"version": "3.10.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
|
|||||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||||
return axios.post(settingsPath, data);
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
|
|||||||
colour: event.colour,
|
colour: event.colour,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: event.timeWarning,
|
timeWarning: event.timeWarning,
|
||||||
timeDanger: event.timeDanger,
|
timeDanger: event.timeDanger,
|
||||||
custom: {},
|
custom: {},
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
|
|||||||
return { isValid: true, message: 'ok' };
|
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
|
* Gets the URL to send a preset to
|
||||||
* @param location
|
* @param location
|
||||||
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
|
|||||||
const currentURL = location.pathname.substring(1);
|
const currentURL = location.pathname.substring(1);
|
||||||
|
|
||||||
// we need to check if the whole url here is an alias, so we can redirect
|
// 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) {
|
if (foundPreset) {
|
||||||
return generateUrlFromPreset(foundPreset);
|
return generateUrlFromPreset(foundPreset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
|||||||
export const documentationUrl = 'https://docs.getontime.no';
|
export const documentationUrl = 'https://docs.getontime.no';
|
||||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
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
|
// resolve environment
|
||||||
export const appVersion = version;
|
export const appVersion = version;
|
||||||
export const isProduction = import.meta.env.MODE === 'production';
|
export const isProduction = import.meta.env.MODE === 'production';
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
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 * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
@@ -9,22 +16,29 @@ export default function AboutPanel() {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>About Ontime</Panel.Header>
|
<Panel.Header>About Ontime</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
<Panel.Card>
|
||||||
<Panel.Paragraph>
|
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||||
Free, open-source software for managing rundowns and event timers
|
<Panel.Paragraph>
|
||||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
Free, open-source software for managing rundowns and event timers
|
||||||
</Panel.Paragraph>
|
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Paragraph>
|
||||||
<Panel.Section>
|
<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>
|
<Panel.SubHeader>Links</Panel.SubHeader>
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
|
||||||
<AppVersion />
|
|
||||||
</Panel.Section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
@@ -38,14 +42,20 @@
|
|||||||
tbody {
|
tbody {
|
||||||
background-color: $gray-1300;
|
background-color: $gray-1300;
|
||||||
tr {
|
tr {
|
||||||
&:has(:hover) {
|
&:hover:not(.current) {
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
}
|
}
|
||||||
|
.current {
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -62,4 +72,8 @@
|
|||||||
|
|
||||||
.current {
|
.current {
|
||||||
background-color: $blue-700;
|
background-color: $blue-700;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue-900;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 { loadDemo, loadProject } from '../../../common/api/db';
|
||||||
|
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||||
|
import * as Editor from '../editor-utils/EditorUtils';
|
||||||
|
|
||||||
import ImportProjectButton from './composite/ImportProjectButton';
|
import ImportProjectButton from './composite/ImportProjectButton';
|
||||||
import WelcomeProjectList from './composite/WelcomeProjectList';
|
import WelcomeProjectList from './composite/WelcomeProjectList';
|
||||||
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<div className={style.header}>Welcome to Ontime</div>
|
<div className={style.header}>Welcome to Ontime</div>
|
||||||
|
<Editor.Title>Select project</Editor.Title>
|
||||||
<div className={style.tableContainer}>
|
<div className={style.tableContainer}>
|
||||||
<table className={style.table}>
|
<table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project Name</th>
|
<th>Project Name</th>
|
||||||
<th>Last Used</th>
|
<th>Last Used</th>
|
||||||
<th />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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'
|
||||||
|
checked
|
||||||
|
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
|
||||||
|
>
|
||||||
|
Show this modal on next startup
|
||||||
|
</Checkbox>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Button } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
|
|
||||||
import style from '../Welcome.module.scss';
|
import style from '../Welcome.module.scss';
|
||||||
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
|
|||||||
{data.reorderedProjectFiles.map((project) => {
|
{data.reorderedProjectFiles.map((project) => {
|
||||||
if (project.filename === data.lastLoadedProject) {
|
if (project.filename === data.lastLoadedProject) {
|
||||||
return (
|
return (
|
||||||
<tr className={style.current} key={project.filename}>
|
<tr className={style.current} key={project.filename} onClick={onClose}>
|
||||||
<td>{project.filename}</td>
|
<td>{project.filename}</td>
|
||||||
<td>Loaded from last session</td>
|
<td>Loaded from last session</td>
|
||||||
<td>
|
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<tr key={project.filename}>
|
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
|
||||||
<td>{project.filename}</td>
|
<td>{project.filename}</td>
|
||||||
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
<td>{new Date(project.updatedAt).toLocaleString()}</td>
|
||||||
<td>
|
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
|
|
||||||
Load
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
|
checkIsNextDay,
|
||||||
getFirstNormal,
|
getFirstNormal,
|
||||||
getLastNormal,
|
getLastNormal,
|
||||||
getNextBlockNormal,
|
getNextBlockNormal,
|
||||||
@@ -267,7 +268,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
let eventIndex = 0;
|
let eventIndex = 0;
|
||||||
// all events before the current selected are in the past
|
// all events before the current selected are in the past
|
||||||
let isPast = Boolean(featureData?.selectedEventId);
|
let isPast = Boolean(featureData?.selectedEventId);
|
||||||
|
let isNextDay = false;
|
||||||
const isEditMode = appMode === AppMode.Edit;
|
const isEditMode = appMode === AppMode.Edit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -286,6 +287,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = 0;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
|
isNextDay = false;
|
||||||
previousEntryId = thisId;
|
previousEntryId = thisId;
|
||||||
thisId = entryId;
|
thisId = entryId;
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
@@ -294,8 +296,9 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
lastEvent = thisEvent;
|
lastEvent = thisEvent;
|
||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
// populate previous entry
|
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||||
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
|
if (isNewLatest(entry, lastEvent)) {
|
||||||
|
// populate previous entry
|
||||||
thisEvent = entry;
|
thisEvent = entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,12 +326,11 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
loaded={isLoaded}
|
loaded={isLoaded}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
previousStart={lastEvent?.timeStart}
|
|
||||||
previousEnd={lastEvent?.timeEnd}
|
|
||||||
previousEntryId={previousEntryId}
|
previousEntryId={previousEntryId}
|
||||||
previousEventId={lastEvent?.id}
|
previousEventId={lastEvent?.id}
|
||||||
playback={isLoaded ? featureData.playback : undefined}
|
playback={isLoaded ? featureData.playback : undefined}
|
||||||
isRolling={featureData.playback === Playback.Roll}
|
isRolling={featureData.playback === Playback.Roll}
|
||||||
|
isNextDay={isNextDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ interface RundownEntryProps {
|
|||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
previousStart?: number;
|
isNextDay: boolean;
|
||||||
previousEnd?: number;
|
|
||||||
previousEntryId?: string;
|
previousEntryId?: string;
|
||||||
previousEventId?: string;
|
previousEventId?: string;
|
||||||
playback?: Playback; // we only care about this if this event is playing
|
playback?: Playback; // we only care about this if this event is playing
|
||||||
@@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
loaded,
|
loaded,
|
||||||
hasCursor,
|
hasCursor,
|
||||||
isNext,
|
isNext,
|
||||||
previousStart,
|
|
||||||
previousEnd,
|
|
||||||
previousEntryId,
|
previousEntryId,
|
||||||
previousEventId,
|
previousEventId,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
|
isNextDay,
|
||||||
} = props;
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||||
@@ -165,8 +163,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
title={data.title}
|
title={data.title}
|
||||||
note={data.note}
|
note={data.note}
|
||||||
delay={data.delay ?? 0}
|
delay={data.delay ?? 0}
|
||||||
previousStart={previousStart}
|
|
||||||
previousEnd={previousEnd}
|
|
||||||
colour={data.colour}
|
colour={data.colour}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
isNext={isNext}
|
isNext={isNext}
|
||||||
@@ -175,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
|
gap={data.gap}
|
||||||
|
isNextDay={isNextDay}
|
||||||
actionHandler={actionHandler}
|
actionHandler={actionHandler}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ interface EventBlockProps {
|
|||||||
title: string;
|
title: string;
|
||||||
note: string;
|
note: string;
|
||||||
delay: number;
|
delay: number;
|
||||||
previousStart?: number;
|
|
||||||
previousEnd?: number;
|
|
||||||
colour: string;
|
colour: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isNext: boolean;
|
isNext: boolean;
|
||||||
@@ -49,6 +47,8 @@ interface EventBlockProps {
|
|||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
|
gap: number;
|
||||||
|
isNextDay: boolean;
|
||||||
actionHandler: (
|
actionHandler: (
|
||||||
action: EventItemActions,
|
action: EventItemActions,
|
||||||
payload?:
|
payload?:
|
||||||
@@ -77,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
title,
|
title,
|
||||||
note,
|
note,
|
||||||
delay,
|
delay,
|
||||||
previousStart,
|
|
||||||
previousEnd,
|
|
||||||
colour,
|
colour,
|
||||||
isPast,
|
isPast,
|
||||||
isNext,
|
isNext,
|
||||||
@@ -87,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
hasCursor,
|
hasCursor,
|
||||||
playback,
|
playback,
|
||||||
isRolling,
|
isRolling,
|
||||||
|
gap,
|
||||||
|
isNextDay,
|
||||||
actionHandler,
|
actionHandler,
|
||||||
} = props;
|
} = props;
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
@@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
id='event-block'
|
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}>
|
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||||
calculateDuration,
|
|
||||||
checkIsNextDay,
|
|
||||||
dayInMs,
|
|
||||||
getTimeFromPrevious,
|
|
||||||
millisToString,
|
|
||||||
removeTrailingZero,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
@@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
|
|||||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||||
return `New start ${timeTag}`;
|
return `New start ${timeTag}`;
|
||||||
}
|
}
|
||||||
|
export function formatGap(gap: number, isNextDay: boolean) {
|
||||||
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
|
if (gap === 0) {
|
||||||
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
|
if (isNextDay) {
|
||||||
if (noPreviousElement) return;
|
// We show a next day warning even if there is no gap
|
||||||
|
return '(next day)';
|
||||||
const normalisedDuration = calculateDuration(previousStart, previousEnd);
|
}
|
||||||
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
|
return;
|
||||||
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)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
|
const gapString = formatDuration(Math.abs(gap), false);
|
||||||
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
|
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';
|
import style from './RundownIndicators.module.scss';
|
||||||
|
|
||||||
interface RundownIndicatorProps {
|
interface RundownIndicatorProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
previousStart?: number;
|
isNextDay: boolean;
|
||||||
previousEnd?: number;
|
|
||||||
delay: number;
|
delay: number;
|
||||||
|
gap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownIndicators(props: RundownIndicatorProps) {
|
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);
|
const hasDelay = formatDelay(timeStart, delay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.indicators}>
|
<div className={style.indicators}>
|
||||||
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
|
||||||
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
|
{hasGap && <div className={style.gap}>{hasGap}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { formatDelay } from '../EventBlock.utils';
|
||||||
|
|
||||||
import { formatDelay, formatOverlap } from '../EventBlock.utils';
|
|
||||||
|
|
||||||
describe('formatDelay()', () => {
|
describe('formatDelay()', () => {
|
||||||
it('adds a given delay to the start time', () => {
|
it('adds a given delay to the start time', () => {
|
||||||
@@ -10,69 +8,3 @@ describe('formatDelay()', () => {
|
|||||||
expect(result).toEqual('New start 00:02');
|
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 { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
||||||
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
||||||
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
|
||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
@@ -45,7 +46,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (timeStart > timeEnd) {
|
if (timeStart + duration > dayInMs) {
|
||||||
warnings.push('Over midnight');
|
warnings.push('Over midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{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>
|
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
|
||||||
<IoAlertCircleOutline />
|
<IoAlertCircleOutline />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useViewportSize } from '@mantine/hooks';
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
|
||||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||||
@@ -30,10 +30,8 @@ function Timeline(props: TimelineProps) {
|
|||||||
const startHour = getStartHour(firstStart);
|
const startHour = getStartHour(firstStart);
|
||||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||||
|
|
||||||
let previousEventStartTime: MaybeNumber = null;
|
|
||||||
// we use selectedEventId as a signifier on whether the timeline is live
|
// we use selectedEventId as a signifier on whether the timeline is live
|
||||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||||
let elapsedDays = 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.timeline}>
|
<div className={style.timeline}>
|
||||||
@@ -52,14 +50,7 @@ function Timeline(props: TimelineProps) {
|
|||||||
eventStatus = 'live';
|
eventStatus = 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only need to check for next day if we have a previous event
|
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
|
||||||
if (
|
|
||||||
previousEventStartTime !== null &&
|
|
||||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
|
||||||
) {
|
|
||||||
elapsedDays++;
|
|
||||||
}
|
|
||||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
|
||||||
|
|
||||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||||
startHour * MILLIS_PER_HOUR,
|
startHour * MILLIS_PER_HOUR,
|
||||||
@@ -69,9 +60,6 @@ function Timeline(props: TimelineProps) {
|
|||||||
screenWidth,
|
screenWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// prepare values for next iteration
|
|
||||||
previousEventStartTime = normalisedStart;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineEntry
|
<TimelineEntry
|
||||||
key={event.id}
|
key={event.id}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||||
let firstStart = null;
|
let firstStart = null;
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
let lastEntry: PlayableEvent | null = null;
|
let lastEntry: PlayableEvent | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < rundown.length; i++) {
|
for (let i = 0; i < rundown.length; i++) {
|
||||||
const currentEntry = rundown[i];
|
const currentEntry = rundown[i];
|
||||||
@@ -142,12 +142,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
firstStart = currentEntry.timeStart;
|
firstStart = currentEntry.timeStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry?.timeStart,
|
|
||||||
lastEntry?.timeEnd,
|
|
||||||
lastEntry?.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
if (timeFromPrevious === 0) {
|
||||||
totalDuration += currentEntry.duration;
|
totalDuration += currentEntry.duration;
|
||||||
@@ -156,7 +151,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
|
|||||||
} else if (timeFromPrevious < 0) {
|
} else if (timeFromPrevious < 0) {
|
||||||
totalDuration += Math.max(currentEntry.duration + 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;
|
lastEntry = currentEntry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "3.10.3",
|
"version": "3.10.4",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.10.3",
|
"version": "3.10.4",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
|
|||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
|
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||||
|
|
||||||
let instance: SocketServer | null = null;
|
let instance: SocketServer | null = null;
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export class SocketServer implements IAdapter {
|
|||||||
private wss: WebSocketServer | null;
|
private wss: WebSocketServer | null;
|
||||||
private readonly clients: Map<string, Client>;
|
private readonly clients: Map<string, Client>;
|
||||||
private lastConnection: Date | null = null;
|
private lastConnection: Date | null = null;
|
||||||
private isFirstEditor = true;
|
private shouldShowWelcome = true;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (instance) {
|
if (instance) {
|
||||||
@@ -47,10 +48,16 @@ export class SocketServer implements IAdapter {
|
|||||||
this.wss = null;
|
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 = 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();
|
const clientId = generateId();
|
||||||
|
|
||||||
this.clients.set(clientId, {
|
this.clients.set(clientId, {
|
||||||
@@ -136,8 +143,8 @@ export class SocketServer implements IAdapter {
|
|||||||
previousData.path = payload;
|
previousData.path = payload;
|
||||||
this.clients.set(clientId, previousData);
|
this.clients.set(clientId, previousData);
|
||||||
|
|
||||||
if (payload.includes('editor') && this.isFirstEditor) {
|
if (payload.includes('editor') && this.shouldShowWelcome) {
|
||||||
this.isFirstEditor = false;
|
this.shouldShowWelcome = false;
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
|
|||||||
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||||
|
import { getTimezoneLabel } from '../../utils/time.js';
|
||||||
|
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
|
|||||||
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
|
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
|
||||||
projectName,
|
projectName,
|
||||||
playback,
|
playback,
|
||||||
timezone: startedAt.getTimezoneOffset(),
|
timezone: getTimezoneLabel(startedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { isDocker } from '../../externals.js';
|
import { isDocker } from '../../externals.js';
|
||||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.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';
|
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 });
|
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 express from 'express';
|
||||||
import { getSettings, postSettings } from './settings.controller.js';
|
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
|
||||||
import { validateSettings } from './settings.validation.js';
|
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
|
||||||
|
|
||||||
router.get('/', getSettings);
|
router.get('/', getSettings);
|
||||||
router.post('/', validateSettings, postSettings);
|
router.post('/', validateSettings, postSettings);
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { body, validationResult } from 'express-validator';
|
import { body, validationResult } from 'express-validator';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
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
|
* @description Validates object for POST /ontime/settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const validateRequestConnection = [
|
|||||||
.exists()
|
.exists()
|
||||||
.isString()
|
.isString()
|
||||||
.isLength({
|
.isLength({
|
||||||
min: 40,
|
min: 20,
|
||||||
max: 100,
|
max: 100,
|
||||||
})
|
})
|
||||||
.withMessage('Sheet ID is usually 44 characters long'),
|
.withMessage('Sheet ID is usually 44 characters long'),
|
||||||
|
|||||||
+22
-19
@@ -5,16 +5,18 @@ import express from 'express';
|
|||||||
import http, { Server } from 'http';
|
import http, { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import serverTiming from 'server-timing';
|
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 middleware configuration
|
||||||
import { bodyParser } from './middleware/bodyParser.js';
|
import { bodyParser } from './middleware/bodyParser.js';
|
||||||
import { compressedStatic } from './middleware/staticGZip.js';
|
import { compressedStatic } from './middleware/staticGZip.js';
|
||||||
|
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.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 Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
@@ -44,6 +46,7 @@ import { clearUploadfolder } from './utils/upload.js';
|
|||||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||||
import { timerConfig } from './config/config.js';
|
import { timerConfig } from './config/config.js';
|
||||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
||||||
|
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
@@ -72,19 +75,18 @@ if (!isProduction) {
|
|||||||
}
|
}
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
// setup cors for all routes
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
// enable pre-flight cors
|
|
||||||
app.options('*', cors());
|
|
||||||
|
|
||||||
// Implement middleware
|
// Implement middleware
|
||||||
|
app.use(cors()); // setup cors for all routes
|
||||||
|
app.options('*', cors()); // enable pre-flight cors
|
||||||
|
|
||||||
app.use(bodyParser);
|
app.use(bodyParser);
|
||||||
app.use(prefix, compressedStatic);
|
app.use(cookieParser());
|
||||||
|
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
|
||||||
|
|
||||||
// Implement route endpoints
|
// Implement route endpoints
|
||||||
app.use(`${prefix}/data`, appRouter); // router for application data
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/api`, integrationRouter); // router for integrations
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
|
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.use(`${prefix}/user`, express.static(publicDir.userDir));
|
||||||
|
|
||||||
app.get(`${prefix}/*`, (_req, res) => {
|
// Base route for static files
|
||||||
res.sendFile(srcFiles.clientIndexHtml);
|
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
|
||||||
});
|
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
|
||||||
|
|
||||||
// Implement catch all
|
// Implement catch all
|
||||||
app.use((_error, response) => {
|
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
|
// 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);
|
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
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
|
* Module initialises the services and provides initial payload for the store
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const environment = isTest ? 'test' : env;
|
|||||||
export const isDocker = env === 'docker';
|
export const isDocker = env === 'docker';
|
||||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||||
|
export const password = process.env.SESSION_PASSWORD;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the router prefix in the index.html file
|
* 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
|
* This is only needed in the cloud environment where the client is not at the root segment
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -47,6 +49,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -73,6 +77,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -99,6 +105,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -125,6 +133,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -156,6 +166,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -182,6 +194,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -208,6 +222,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -234,6 +250,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -260,6 +278,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -291,6 +311,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -317,6 +339,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -343,6 +367,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
@@ -369,6 +395,8 @@ export const demoDb: DatabaseModel = {
|
|||||||
colour: '',
|
colour: '',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 500000,
|
timeWarning: 500000,
|
||||||
timeDanger: 100000,
|
timeDanger: 100000,
|
||||||
custom: {
|
custom: {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
|||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
custom: {},
|
custom: {},
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { loadRoll } from '../rollUtils.js';
|
import { loadRoll } from '../rollUtils.js';
|
||||||
|
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('loadRoll()', () => {
|
describe('loadRoll()', () => {
|
||||||
const eventlist = [
|
const eventlist = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { shouldCrashDev } from '../../utils/development.js';
|
|||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
lastLoadedProject?: string;
|
lastLoadedProject?: string;
|
||||||
|
showWelcomeDialog?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
||||||
@@ -34,3 +35,19 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
|
|||||||
config.data.lastLoadedProject = filename;
|
config.data.lastLoadedProject = filename;
|
||||||
await config.write();
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||||
import { apply } from '../delayUtils.js';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
import { apply } from '../delayUtils.js';
|
||||||
* Small utility to fill in the necessary data for the test
|
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('apply()', () => {
|
describe('apply()', () => {
|
||||||
it('applies a positive delay to the rundown', () => {
|
it('applies a positive delay to the rundown', () => {
|
||||||
@@ -150,11 +138,11 @@ describe('apply()', () => {
|
|||||||
makeOntimeDelay(100),
|
makeOntimeDelay(100),
|
||||||
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||||
// gap 50
|
// 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
|
// gap 50
|
||||||
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
|
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
|
||||||
// gap 50
|
|
||||||
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
|
|
||||||
// linked
|
// linked
|
||||||
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
|
||||||
];
|
];
|
||||||
@@ -165,7 +153,7 @@ describe('apply()', () => {
|
|||||||
// gap 50 (100 - 50)
|
// gap 50 (100 - 50)
|
||||||
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
||||||
// gap 50 (50 - 50)
|
// 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)
|
// gap (delay is 0)
|
||||||
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
||||||
// linked
|
// linked
|
||||||
@@ -178,6 +166,8 @@ describe('apply()', () => {
|
|||||||
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
makeOntimeDelay(2 * MILLIS_PER_HOUR),
|
||||||
makeOntimeEvent({
|
makeOntimeEvent({
|
||||||
id: '1',
|
id: '1',
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
timeStart: 46800000, // 13:00:00
|
timeStart: 46800000, // 13:00:00
|
||||||
timeEnd: 50400000, // 14:00:00
|
timeEnd: 50400000, // 14:00:00
|
||||||
duration: MILLIS_PER_HOUR,
|
duration: MILLIS_PER_HOUR,
|
||||||
@@ -185,6 +175,8 @@ describe('apply()', () => {
|
|||||||
// gap 1h
|
// gap 1h
|
||||||
makeOntimeEvent({
|
makeOntimeEvent({
|
||||||
id: '2',
|
id: '2',
|
||||||
|
gap: 1 * MILLIS_PER_HOUR,
|
||||||
|
dayOffset: 0,
|
||||||
timeStart: 54000000, // 15:00:00
|
timeStart: 54000000, // 15:00:00
|
||||||
timeEnd: 57600000, // 16:00:00
|
timeEnd: 57600000, // 16:00:00
|
||||||
duration: MILLIS_PER_HOUR,
|
duration: MILLIS_PER_HOUR,
|
||||||
|
|||||||
@@ -577,6 +577,8 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
@@ -605,6 +607,8 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -633,6 +637,8 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -661,6 +667,8 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
@@ -701,6 +709,8 @@ describe('getDelayAt()', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
custom: {},
|
custom: {},
|
||||||
},
|
},
|
||||||
@@ -725,6 +735,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -753,6 +765,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -781,6 +795,8 @@ describe('getDelayAt()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
@@ -835,6 +851,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
@@ -863,6 +881,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
@@ -891,6 +911,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
@@ -919,6 +941,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
colour: '',
|
colour: '',
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
addToCustomAssignment,
|
addToCustomAssignment,
|
||||||
|
calculateDayOffset,
|
||||||
getLink,
|
getLink,
|
||||||
handleCustomField,
|
handleCustomField,
|
||||||
handleLink,
|
handleLink,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
isDataStale,
|
isDataStale,
|
||||||
} from '../rundownCacheUtils.js';
|
} from '../rundownCacheUtils.js';
|
||||||
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
describe('getLink()', () => {
|
describe('getLink()', () => {
|
||||||
it('should return null if there is no link', () => {
|
it('should return null if there is no link', () => {
|
||||||
@@ -247,3 +249,61 @@ describe('hasChanges()', () => {
|
|||||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
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 { 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
|
* 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 the event is not linked, we try and maintain gaps
|
||||||
if (lastEntry !== null) {
|
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
|
// when applying negative delays, we need to unlink the event
|
||||||
// if the previous event was fully consumed by the delay
|
// if the previous event was fully consumed by the delay
|
||||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
||||||
shouldUnlink = true;
|
shouldUnlink = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeFromPrevious > 0) {
|
if (currentEntry.gap > 0) {
|
||||||
delayValue = Math.max(delayValue - timeFromPrevious, 0);
|
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delayValue === 0) {
|
if (delayValue === 0) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { createPatch } from '../../utils/parser.js';
|
import { createPatch } from '../../utils/parser.js';
|
||||||
import { apply } from './delayUtils.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 EventID = string;
|
||||||
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
||||||
@@ -55,6 +55,7 @@ function setIsStale() {
|
|||||||
|
|
||||||
let totalDelay = 0;
|
let totalDelay = 0;
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
|
let totalDays = 0;
|
||||||
let firstStart: MaybeNumber = null;
|
let firstStart: MaybeNumber = null;
|
||||||
let lastEnd: MaybeNumber = null;
|
let lastEnd: MaybeNumber = null;
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export function generate(
|
|||||||
firstStart = null;
|
firstStart = null;
|
||||||
lastEnd = null;
|
lastEnd = null;
|
||||||
totalDuration = 0;
|
totalDuration = 0;
|
||||||
|
totalDays = 0;
|
||||||
totalDelay = 0;
|
totalDelay = 0;
|
||||||
|
|
||||||
let lastEntry: PlayableEvent | null = null;
|
let lastEntry: PlayableEvent | null = null;
|
||||||
@@ -117,6 +119,7 @@ export function generate(
|
|||||||
|
|
||||||
if (isOntimeEvent(currentEntry)) {
|
if (isOntimeEvent(currentEntry)) {
|
||||||
currentEntry.delay = 0;
|
currentEntry.delay = 0;
|
||||||
|
currentEntry.gap = 0;
|
||||||
|
|
||||||
// 1. handle links - mutates updatedEvent
|
// 1. handle links - mutates updatedEvent
|
||||||
handleLink(i, initialRundown, currentEntry, links);
|
handleLink(i, initialRundown, currentEntry, links);
|
||||||
@@ -124,6 +127,9 @@ export function generate(
|
|||||||
// 2. handle custom fields - mutates updatedEvent
|
// 2. handle custom fields - mutates updatedEvent
|
||||||
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
|
||||||
|
|
||||||
|
totalDays += calculateDayOffset(currentEntry, lastEntry);
|
||||||
|
currentEntry.dayOffset = totalDays;
|
||||||
|
|
||||||
// update rundown metadata, it only concerns playable events
|
// update rundown metadata, it only concerns playable events
|
||||||
if (isPlayableEvent(currentEntry)) {
|
if (isPlayableEvent(currentEntry)) {
|
||||||
// fist start is always the first event
|
// fist start is always the first event
|
||||||
@@ -131,36 +137,31 @@ export function generate(
|
|||||||
firstStart = currentEntry.timeStart;
|
firstStart = currentEntry.timeStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeFromPrevious: number = getTimeFromPrevious(
|
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
|
||||||
currentEntry.timeStart,
|
|
||||||
lastEntry?.timeStart,
|
|
||||||
lastEntry?.timeEnd,
|
|
||||||
lastEntry?.duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (timeFromPrevious === 0) {
|
if (currentEntry.gap === 0) {
|
||||||
// event starts on previous finish, we add its duration
|
// event starts on previous finish, we add its duration
|
||||||
totalDuration += currentEntry.duration;
|
totalDuration += currentEntry.duration;
|
||||||
} else if (timeFromPrevious > 0) {
|
} else if (currentEntry.gap > 0) {
|
||||||
// event has a gap, we add the gap and the duration
|
// event has a gap, we add the gap and the duration
|
||||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
totalDuration += currentEntry.gap + currentEntry.duration;
|
||||||
} else if (timeFromPrevious < 0) {
|
} else if (currentEntry.gap < 0) {
|
||||||
// there is an overlap, we remove the overlap from the duration
|
// there is an overlap, we remove the overlap from the duration
|
||||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||||
// NOTE: we add the gap since it is a negative number
|
// 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
|
// remove eventual gaps from the accumulated delay
|
||||||
// we only affect positive delays (time forwards)
|
// we only affect positive delays (time forwards)
|
||||||
if (totalDelay > 0 && timeFromPrevious > 0) {
|
if (totalDelay > 0 && currentEntry.gap > 0) {
|
||||||
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
|
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
|
||||||
}
|
}
|
||||||
// current event delay is the current accumulated delay
|
// current event delay is the current accumulated delay
|
||||||
currentEntry.delay = totalDelay;
|
currentEntry.delay = totalDelay;
|
||||||
|
|
||||||
// lastEntry is the event with the latest end time
|
// 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;
|
lastEntry = currentEntry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
OntimeRundownEntry,
|
OntimeRundownEntry,
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { getLinkedTimes } from 'ontime-utils';
|
import { dayInMs, getLinkedTimes } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get linked event
|
* 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],
|
(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,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
@@ -84,6 +86,8 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -132,6 +136,8 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -178,6 +184,8 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
skip: false,
|
skip: false,
|
||||||
colour: 'red',
|
colour: 'red',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
@@ -212,6 +220,8 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
@@ -246,6 +256,8 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
colour: 'red',
|
colour: 'red',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
gap: 0,
|
||||||
|
dayOffset: 0,
|
||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export const srcFiles = {
|
|||||||
userReadme: join(srcDir.root, config.user, 'README.md'),
|
userReadme: join(srcDir.root, config.user, 'README.md'),
|
||||||
/** Path to bundled CSS readme */
|
/** Path to bundled CSS readme */
|
||||||
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
|
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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -384,7 +384,9 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
note: makeString(patchEvent.note, originalEvent.note),
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
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
|
// short circuit empty string
|
||||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||||
revision: originalEvent.revision,
|
revision: originalEvent.revision,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
|
||||||
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
|
|
||||||
|
|
||||||
export const timeFormat = 'HH:mm';
|
export const timeFormat = 'HH:mm';
|
||||||
export const timeFormatSeconds = 'HH:mm:ss';
|
export const timeFormatSeconds = 'HH:mm:ss';
|
||||||
@@ -46,3 +45,18 @@ export function parseExcelDate(excelDate: unknown): number {
|
|||||||
|
|
||||||
return 0;
|
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
@@ -1,307 +1,420 @@
|
|||||||
{
|
{
|
||||||
"rundown": [
|
"rundown": [
|
||||||
{
|
{
|
||||||
"title": "Albania",
|
"id": "32d31",
|
||||||
"note": "SF1.01",
|
"type": "event",
|
||||||
"endAction": "none",
|
"title": "Albania",
|
||||||
"timerType": "count-down",
|
"timeStart": 36000000,
|
||||||
"timeStart": 36000000,
|
"timeEnd": 37200000,
|
||||||
"timeEnd": 37200000,
|
"duration": 1200000,
|
||||||
"duration": 1200000,
|
"timeStrategy": "lock-duration",
|
||||||
"isPublic": true,
|
"linkStart": null,
|
||||||
"skip": false,
|
"endAction": "none",
|
||||||
"colour": "",
|
"timerType": "count-down",
|
||||||
"type": "event",
|
"countToEnd": false,
|
||||||
"revision": 0,
|
"isPublic": true,
|
||||||
"id": "32d31",
|
"skip": false,
|
||||||
"cue": "SF1.01",
|
"note": "SF1.01",
|
||||||
"custom": {
|
"colour": "",
|
||||||
"song": "Sekret",
|
"delay": 0,
|
||||||
"artist": "Ronela Hajati"
|
"dayOffset": 0,
|
||||||
}
|
"gap": 0,
|
||||||
},
|
"cue": "SF1.01",
|
||||||
{
|
"revision": 0,
|
||||||
"title": "Latvia",
|
"timeWarning": 120000,
|
||||||
"note": "SF1.02",
|
"timeDanger": 60000,
|
||||||
"endAction": "none",
|
"custom": {
|
||||||
"timerType": "count-down",
|
"song": "Sekret",
|
||||||
"timeStart": 37500000,
|
"artist": "Ronela Hajati"
|
||||||
"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": "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": {
|
"project": {
|
||||||
"title": "Eurovision Song Contest",
|
"title": "Eurovision Song Contest",
|
||||||
"description": "Turin 2022",
|
"description": "Turin 2022",
|
||||||
"publicUrl": "www.getontime.no",
|
"publicUrl": "www.getontime.no",
|
||||||
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
"publicInfo": "Rehearsal Schedule - Turin 2022",
|
||||||
"backstageUrl": "www.github.com/cpvalente/ontime",
|
"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": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "2.0.0",
|
"version": "3.10.2",
|
||||||
"serverPort": 4001,
|
"serverPort": 4001,
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
@@ -312,10 +425,9 @@
|
|||||||
"overrideStyles": false,
|
"overrideStyles": false,
|
||||||
"normalColor": "#ffffffcc",
|
"normalColor": "#ffffffcc",
|
||||||
"warningColor": "#FFAB33",
|
"warningColor": "#FFAB33",
|
||||||
"warningThreshold": 120000,
|
|
||||||
"dangerColor": "#ED3333",
|
"dangerColor": "#ED3333",
|
||||||
"dangerThreshold": 60000,
|
"endMessage": "",
|
||||||
"endMessage": ""
|
"freezeEnd": false
|
||||||
},
|
},
|
||||||
"urlPresets": [
|
"urlPresets": [
|
||||||
{
|
{
|
||||||
@@ -335,5 +447,17 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"enabledOut": true,
|
"enabledOut": true,
|
||||||
"subscriptions": []
|
"subscriptions": []
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"song": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "song"
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"type": "string",
|
||||||
|
"colour": "",
|
||||||
|
"label": "artist"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -8,9 +8,6 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
|
|||||||
test('project file upload', async ({ page }) => {
|
test('project file upload', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
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: 'Edit' }).click();
|
||||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
@@ -41,9 +38,6 @@ test('project file upload', async ({ page }) => {
|
|||||||
test('project file download', async ({ page }) => {
|
test('project file download', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
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: 'toggle settings' }).click();
|
await page.getByRole('button', { name: 'toggle settings' }).click();
|
||||||
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
await page.getByRole('button', { name: 'Project', exact: true }).click();
|
||||||
|
|
||||||
@@ -61,8 +55,13 @@ test('project file download', async ({ page }) => {
|
|||||||
|
|
||||||
// Wait for the download process to complete and save the downloaded file somewhere.
|
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||||
await download.saveAs(fileToDownload);
|
await download.saveAs(fileToDownload);
|
||||||
await expect(download.failure()).toMatchObject({});
|
expect(download.failure()).toMatchObject({});
|
||||||
|
|
||||||
const original = JSON.parse(await readFile(fileToUpload, { encoding: 'utf-8' }));
|
const original = JSON.parse(await readFile(fileToUpload, { encoding: 'utf-8' }));
|
||||||
const fromServer = JSON.parse(await readFile(fileToDownload, { encoding: 'utf-8' }));
|
const fromServer = JSON.parse(await readFile(fileToDownload, { encoding: 'utf-8' }));
|
||||||
await expect(original).toEqual(fromServer);
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
Vendored
+28
@@ -17,6 +17,8 @@
|
|||||||
"note": "SF1.01",
|
"note": "SF1.01",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.01",
|
"cue": "SF1.01",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -43,6 +45,8 @@
|
|||||||
"note": "SF1.02",
|
"note": "SF1.02",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.02",
|
"cue": "SF1.02",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -69,6 +73,8 @@
|
|||||||
"note": "SF1.03",
|
"note": "SF1.03",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.03",
|
"cue": "SF1.03",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -95,6 +101,8 @@
|
|||||||
"note": "SF1.04",
|
"note": "SF1.04",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.04",
|
"cue": "SF1.04",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -121,6 +129,8 @@
|
|||||||
"note": "SF1.05",
|
"note": "SF1.05",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.05",
|
"cue": "SF1.05",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -152,6 +162,8 @@
|
|||||||
"note": "SF1.06",
|
"note": "SF1.06",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.06",
|
"cue": "SF1.06",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -178,6 +190,8 @@
|
|||||||
"note": "SF1.07",
|
"note": "SF1.07",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.07",
|
"cue": "SF1.07",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -204,6 +218,8 @@
|
|||||||
"note": "SF1.08",
|
"note": "SF1.08",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.08",
|
"cue": "SF1.08",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -230,6 +246,8 @@
|
|||||||
"note": "SF1.09",
|
"note": "SF1.09",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.09",
|
"cue": "SF1.09",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -256,6 +274,8 @@
|
|||||||
"note": "SF1.10",
|
"note": "SF1.10",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.10",
|
"cue": "SF1.10",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -287,6 +307,8 @@
|
|||||||
"note": "SF1.11",
|
"note": "SF1.11",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.11",
|
"cue": "SF1.11",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -313,6 +335,8 @@
|
|||||||
"note": "SF1.12",
|
"note": "SF1.12",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.12",
|
"cue": "SF1.12",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -339,6 +363,8 @@
|
|||||||
"note": "SF1.13",
|
"note": "SF1.13",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.13",
|
"cue": "SF1.13",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
@@ -365,6 +391,8 @@
|
|||||||
"note": "SF1.14",
|
"note": "SF1.14",
|
||||||
"colour": "",
|
"colour": "",
|
||||||
"delay": 0,
|
"delay": 0,
|
||||||
|
"dayOffset": 0,
|
||||||
|
"gap": 0,
|
||||||
"cue": "SF1.14",
|
"cue": "SF1.14",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"timeWarning": 120000,
|
"timeWarning": 120000,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.10.3",
|
"version": "3.10.4",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.42.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "catalog:",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SessionStats {
|
|||||||
lastRequest: MaybeString;
|
lastRequest: MaybeString;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
timezone: number;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetInfo {
|
export interface GetInfo {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
colour: string;
|
colour: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
delay: number; // calculated at runtime
|
delay: number; // calculated at runtime
|
||||||
|
dayOffset: number; // calculated at runtime
|
||||||
|
gap: number; // calculated at runtime
|
||||||
timeWarning: number;
|
timeWarning: number;
|
||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
custom: EventCustomFields;
|
custom: EventCustomFields;
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ export {
|
|||||||
millisToSeconds,
|
millisToSeconds,
|
||||||
secondsInMillis,
|
secondsInMillis,
|
||||||
} from './src/date-utils/conversionUtils.js';
|
} from './src/date-utils/conversionUtils.js';
|
||||||
export { isTimeString } from './src/date-utils/isTimeString.js';
|
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
|
||||||
export {
|
export {
|
||||||
formatFromMillis,
|
formatFromMillis,
|
||||||
millisToString,
|
millisToString,
|
||||||
|
pad,
|
||||||
removeLeadingZero,
|
removeLeadingZero,
|
||||||
removeSeconds,
|
removeSeconds,
|
||||||
removeTrailingZero,
|
removeTrailingZero,
|
||||||
@@ -63,7 +64,9 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
|
|||||||
export { deepmerge } from './src/externals/deepmerge.js';
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|
||||||
// array utils
|
// 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
|
// generic utilities
|
||||||
export { getErrorMessage } from './src/generic/generic.js';
|
export { getErrorMessage } from './src/generic/generic.js';
|
||||||
|
|||||||
@@ -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';
|
import { MILLIS_PER_HOUR } from './conversionUtils';
|
||||||
|
|
||||||
describe('checkIsNextDay', () => {
|
describe('checkIsNextDay', () => {
|
||||||
it('returns false if the previous event duration is 0', () => {
|
it('returns false if there is no previous event', () => {
|
||||||
const previousStart = 0;
|
const current = { timeStart: 0, dayOffset: 0 };
|
||||||
const previousDuration = 0;
|
const previous = undefined;
|
||||||
const timeStart = 0;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false if event starts after one before', () => {
|
it('returns false if event starts after one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 11, dayOffset: 0 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 11;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true if event starts after one before', () => {
|
it('returns true if event starts after one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 9, dayOffset: 1 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 9;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true if event starts at the same time as one before', () => {
|
it('returns true if event starts at the same time as one before', () => {
|
||||||
const previousStart = 10;
|
const current = { timeStart: 10, dayOffset: 1 };
|
||||||
const previousDuration = 2;
|
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
|
||||||
const timeStart = 10;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight', () => {
|
it('should account for an event that crossed midnight', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
|
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
|
||||||
const timeStart = 1 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight and there is a gap', () => {
|
it('should account for an event that crossed midnight and there is a gap', () => {
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00
|
||||||
const previousDuration = 2 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that crossed midnight with no overlaps', () => {
|
it('should account for an event that crossed midnight with no overlaps', () => {
|
||||||
const previousStart = 20 * MILLIS_PER_HOUR;
|
const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
|
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
|
||||||
const timeStart = 19 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeFalsy();
|
||||||
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should account for an event that finishes exactly at midnight', () => {
|
it('should account for an event that finishes exactly at midnight', () => {
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const previousDuration = 1 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00
|
||||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
expect(checkIsNextDay(current, previous)).toBeTruthy();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +1,27 @@
|
|||||||
|
import type { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { dayInMs } from './conversionUtils.js';
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function checks whether a given event is the day after from its predecessor
|
* 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 {
|
export function checkIsNextDay(
|
||||||
if (previousDuration === 0) {
|
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
|
||||||
|
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
): boolean {
|
||||||
|
if (!previous) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedStart = previousStart % dayInMs;
|
// if the day offsets are the same it can't be the next day
|
||||||
if (timeStart <= cappedStart) {
|
if (current.dayOffset <= previous.dayOffset) {
|
||||||
const normalisedPreviousEnd = cappedStart + previousDuration;
|
return false;
|
||||||
if (normalisedPreviousEnd === dayInMs) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// handle exception for an event that finishes exactly at midnight
|
|
||||||
if (normalisedPreviousEnd > dayInMs) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
import { getTimeFromPrevious } from './getTimeFromPrevious';
|
||||||
|
|
||||||
describe('getTimeFromPrevious', () => {
|
describe('getTimeFromPrevious', () => {
|
||||||
it('returns the time elapsed (gap or overlap) from the previous', () => {
|
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
|
const expected = 75600000 - 71700000; // current start - previousEnd
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
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', () => {
|
it('accounts for partially overlapping events', () => {
|
||||||
const previousStart = 10;
|
const expected = -1;
|
||||||
const previousEnd = 12;
|
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
|
||||||
const previousDuration = 2;
|
expected,
|
||||||
const currentStart = 11;
|
);
|
||||||
const expected = -(previousEnd - currentStart);
|
|
||||||
|
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for events that are fully contained', () => {
|
it('accounts for events that are fully contained', () => {
|
||||||
const previousStart = 8;
|
const expected = -6;
|
||||||
const previousEnd = 16;
|
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
|
||||||
const previousDuration = 8;
|
expected,
|
||||||
const currentStart = 10;
|
);
|
||||||
const expected = -(previousEnd - currentStart);
|
|
||||||
|
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fully overlapping events are the next day', () => {
|
it('fully overlapping events are the next day', () => {
|
||||||
const previousStart = 10 * MILLIS_PER_HOUR;
|
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
|
||||||
const previousEnd = 12 * MILLIS_PER_HOUR;
|
expect(
|
||||||
const previousDuration = previousEnd - previousStart;
|
getTimeFromPrevious(
|
||||||
const currentStart = 10 * MILLIS_PER_HOUR;
|
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
|
||||||
const expected = dayInMs - previousDuration;
|
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
|
||||||
|
),
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accounts for events that are the day after', () => {
|
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);
|
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
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', () => {
|
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);
|
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
|
||||||
|
expect(
|
||||||
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
|
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';
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility returns the time elapsed (gap or overlap) from the previous
|
* Utility returns the gap from previous event
|
||||||
* It uses deconstructed parameters to simplify implementation in UI
|
|
||||||
*/
|
*/
|
||||||
export function getTimeFromPrevious(
|
export function getTimeFromPrevious(
|
||||||
currentStart: number,
|
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
|
||||||
previousStart?: number,
|
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
previousEnd?: number,
|
|
||||||
previousDuration?: number,
|
|
||||||
): number {
|
): number {
|
||||||
// there is no previous event
|
// there is no previous event
|
||||||
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
|
if (!previous) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
|
||||||
|
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
|
||||||
|
|
||||||
// event is linked to previous
|
// event is linked to previous
|
||||||
if (currentStart === previousEnd) {
|
if (normalisedCurrentStart === normalisedPreviousEnd) {
|
||||||
return 0;
|
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
|
// event has a gap from previous
|
||||||
if (currentStart > previousEnd) {
|
if (normalisedCurrentStart > normalisedPreviousEnd) {
|
||||||
// time from previous is difference between start and previous end
|
// time from previous is difference between start and previous end
|
||||||
return currentStart - previousEnd;
|
return normalisedCurrentStart - normalisedPreviousEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// event overlaps with previous
|
// event overlaps with previous
|
||||||
const overlap = previousEnd - currentStart;
|
return normalisedCurrentStart - normalisedPreviousEnd;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,52 @@
|
|||||||
import { MILLIS_PER_HOUR } from './conversionUtils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
|
||||||
import { isNewLatest } from './isNewLatest';
|
import { isNewLatest } from './isNewLatest';
|
||||||
|
|
||||||
describe('isNewLatest', () => {
|
describe('isNewLatest', () => {
|
||||||
it('should be true if there is no previous', () => {
|
it('should be true if there is no previous', () => {
|
||||||
expect(isNewLatest(0, 60000)).toBeTruthy();
|
const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
|
const previous = undefined;
|
||||||
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be false if current is contained in the previous', () => {
|
||||||
|
const current = {
|
||||||
|
timeStart: 21 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
|
||||||
|
duration: 10 * MILLIS_PER_MINUTE,
|
||||||
|
dayOffset: 0,
|
||||||
|
};
|
||||||
|
const previous = { timeStart: 21 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
|
|
||||||
|
expect(isNewLatest(current, previous)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it starts when the previous finishes', () => {
|
it('should be true if it starts when the previous finishes', () => {
|
||||||
const nowStart = 10 * MILLIS_PER_HOUR;
|
const current = { timeStart: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const nowEnd = 11 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 10 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it starts the same day the previous finishes', () => {
|
it('should be true if it starts the same day the previous finishes', () => {
|
||||||
const nowStart = 22 * MILLIS_PER_HOUR;
|
const current = { timeStart: 22 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const nowEnd = 23 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 20 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
|
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
|
||||||
const nowStart = 1 * MILLIS_PER_HOUR;
|
const current = { timeStart: 1 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 3 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
|
||||||
const previousEnd = 2 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it the next day', () => {
|
it('should be true if it the next day', () => {
|
||||||
const nowStart = 8 * MILLIS_PER_HOUR;
|
const current = { timeStart: 8 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 10 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 11 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be true if it the next day (2)', () => {
|
it('should be true if it the next day (2)', () => {
|
||||||
const nowStart = 9 * MILLIS_PER_HOUR;
|
const current = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
|
||||||
const nowEnd = 11 * MILLIS_PER_HOUR;
|
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 11 * MILLIS_PER_HOUR, dayOffset: 0 };
|
||||||
const previousStart = 9 * MILLIS_PER_HOUR;
|
expect(isNewLatest(current, previous)).toBe(true);
|
||||||
const previousEnd = 11 * MILLIS_PER_HOUR;
|
|
||||||
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { checkIsNextDay } from './checkIsNextDay.js';
|
import type { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dayInMs } from './conversionUtils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a new element is the latest in the list
|
* Checks whether a new element is the latest in the list
|
||||||
*/
|
*/
|
||||||
export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean {
|
export function isNewLatest(
|
||||||
|
currentEvent: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
previousEvent?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
|
||||||
|
) {
|
||||||
// true if there is no previous
|
// true if there is no previous
|
||||||
if (previousStart === undefined || previousEnd === undefined) {
|
if (!previousEvent) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// true if it starts after the previous is finished
|
const normalisedCurrentEnd = currentEvent.timeStart + currentEvent.duration + currentEvent.dayOffset * dayInMs;
|
||||||
if (timeStart >= previousEnd) {
|
const normalisedPreviousEnd = previousEvent.timeStart + previousEvent.duration + previousEvent.dayOffset * dayInMs;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// true if it finishes later than previous
|
return normalisedCurrentEnd >= normalisedPreviousEnd;
|
||||||
if (timeEnd > previousEnd) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// true if it is the day after
|
|
||||||
return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MaybeNumber } from 'ontime-types';
|
|||||||
|
|
||||||
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
||||||
|
|
||||||
function pad(val: number): string {
|
export function pad(val: number): string {
|
||||||
return String(val).padStart(2, '0');
|
return String(val).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-16
@@ -39,8 +39,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.42.1
|
specifier: ^1.49.1
|
||||||
version: 1.42.1
|
version: 1.49.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 20.14.10
|
version: 20.14.10
|
||||||
@@ -288,6 +288,12 @@ importers:
|
|||||||
'@googleapis/sheets':
|
'@googleapis/sheets':
|
||||||
specifier: ^5.0.5
|
specifier: ^5.0.5
|
||||||
version: 5.0.5
|
version: 5.0.5
|
||||||
|
cookie:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
cors:
|
cors:
|
||||||
specifier: ^2.8.5
|
specifier: ^2.8.5
|
||||||
version: 2.8.5
|
version: 2.8.5
|
||||||
@@ -1680,9 +1686,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
'@playwright/test@1.42.1':
|
'@playwright/test@1.49.1':
|
||||||
resolution: {integrity: sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==}
|
resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@popperjs/core@2.11.8':
|
'@popperjs/core@2.11.8':
|
||||||
@@ -2717,6 +2723,10 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
cookie-signature@1.0.6:
|
cookie-signature@1.0.6:
|
||||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
|
|
||||||
@@ -2724,6 +2734,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
cookie@0.7.2:
|
||||||
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
cookie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
copy-to-clipboard@3.3.3:
|
copy-to-clipboard@3.3.3:
|
||||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||||
|
|
||||||
@@ -4196,14 +4214,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
playwright-core@1.42.1:
|
playwright-core@1.49.1:
|
||||||
resolution: {integrity: sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==}
|
resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
playwright@1.42.1:
|
playwright@1.49.1:
|
||||||
resolution: {integrity: sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==}
|
resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
plist@3.1.0:
|
plist@3.1.0:
|
||||||
@@ -6597,9 +6615,9 @@ snapshots:
|
|||||||
|
|
||||||
'@pkgr/core@0.1.1': {}
|
'@pkgr/core@0.1.1': {}
|
||||||
|
|
||||||
'@playwright/test@1.42.1':
|
'@playwright/test@1.49.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.42.1
|
playwright: 1.49.1
|
||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
@@ -7799,10 +7817,19 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.7.2
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
|
||||||
cookie-signature@1.0.6: {}
|
cookie-signature@1.0.6: {}
|
||||||
|
|
||||||
cookie@0.7.1: {}
|
cookie@0.7.1: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
cookie@1.0.2: {}
|
||||||
|
|
||||||
copy-to-clipboard@3.3.3:
|
copy-to-clipboard@3.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
toggle-selection: 1.0.6
|
toggle-selection: 1.0.6
|
||||||
@@ -9494,11 +9521,11 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
playwright-core@1.42.1: {}
|
playwright-core@1.49.1: {}
|
||||||
|
|
||||||
playwright@1.42.1:
|
playwright@1.49.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright-core: 1.42.1
|
playwright-core: 1.49.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user