Compare commits

..

4 Commits

Author SHA1 Message Date
Claude 4e2baf1fa1 feat(network): add session status card
Display server health metrics from the unused /session endpoint:
- Connected clients count (live from useClientStore)
- Server uptime since last restart
- Last client connection time (relative)
- Last integration request time (relative)
- Server timezone
- Cloud latency (Docker only) with warning threshold

Adds useSessionStats hook with 60s refetch interval, new NetworkStatus
component as first card in Network panel, and navigation menu entries
for Server status and Network interfaces sections.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 18:28:15 +00:00
Claude 46e4ac94f1 feat(log): add filters to the event log and cap the log store
Finding a single error in a stream of RX/TX traffic was not practical
with only the origin filters.

- add a text filter and an issues only filter
- show how many entries are being displayed
- add an empty state which distinguishes no activity from no matches
- collapse the six origin flags into a single set
- cap the log store at 500 entries, it was growing unbounded for the
  duration of a session
- the event log button was labelled Extract, which did not convey that
  it opens the log in a new window

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:20:46 +00:00
Claude b5e1bccc70 feat(network): help users orient themselves in the network panel
- add a network interfaces card to the network panel. The addresses
  users need to reach Ontime from other devices were only available
  in the sharing panel
- show the port along with the address, the bare IP is not enough to
  reach the server
- explain what each section does and how the client actions behave
- the client path was selectable text but could not be opened or
  copied. It now uses a copy tag with the full client URL, which also
  makes the client origin visible in setups with several interfaces

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:18:52 +00:00
Claude c840add9c5 fix(network): correct defects in client management UI
- self row highlight lost a specificity fight with the table zebra
  striping, so the current client was only highlighted on odd rows.
  Reuse the data attribute pattern already used for data-warn
- drop reference to a `blink` class which was never defined, the button
  variant already communicates the identify state
- use `th` in table headers so the shared table header styling applies,
  matching the other settings panels
- add empty states to both client tables
- keep a stable row order (self first, then by name) so rows do not
  move as clients connect and disconnect
- the redirect dialog disabled its view select when no URL presets
  existed, making it impossible to redirect to a plain view. Its
  default value also disagreed with component state, leaving the
  button disabled until the visible value was re-picked
- rename dialog now autofocuses and submits on Enter
- move the cloud ping into a card, matching the panel conventions, and
  flag high latency values

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrhcEgY8mqaxZi8veMPrrx
2026-08-01 13:15:43 +00:00
25 changed files with 650 additions and 542 deletions
+1
View File
@@ -15,6 +15,7 @@ export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const SESSION_STATS = ['sessionStats'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
+9 -1
View File
@@ -1,11 +1,19 @@
import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types';
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve statistics of the running session
*/
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
const res = await axios.get(sessionPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to retrieve application info
*/
@@ -13,6 +13,9 @@ import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
/** the first navigator view, used as the initial value of the view select */
const defaultView = `/${navigatorConstants[0].url}`;
interface RedirectClientModalProps {
id: string;
name: string;
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const [selected, setSelected] = useState(defaultView);
const { setRedirect } = setClientRemote;
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
defaultValue={defaultView}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
disabled={selected === currentPath}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
@@ -2,6 +2,7 @@ import { useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket';
import { isKeyEnter } from '../../utils/keyEvent';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
showCloseButton
onClose={onClose}
bodyElements={
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<Input
height='large'
placeholder='New name'
autoFocus
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (isKeyEnter(event) && canSubmit) {
handleRename();
}
}}
/>
}
footerElements={
<>
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { SessionStats } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { SESSION_STATS } from '../api/constants';
import { getSessionStats } from '../api/session';
export default function useSessionStats() {
const { data, status, isError, refetch } = useQuery<SessionStats>({
queryKey: SESSION_STATS,
queryFn: ({ signal }) => getSessionStats({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchInterval,
});
return { data, status, isError, refetch };
}
+4 -1
View File
@@ -15,11 +15,14 @@ const logger = createStore<LogStore>(() => ({
logs: [],
}));
/** the log is kept in memory, we cap it to avoid it growing through a long show */
const maxLogEntries = 500;
export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs],
logs: [log, ...state.logs].slice(0, maxLogEntries),
}));
export const clearLogs = () => logger.setState({ logs: [] });
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
td[data-warn='true'] {
background-color: $orange-1300;
}
tr[data-highlight='true'] {
background-color: $blue-1100;
}
}
.listGroup {
@@ -19,10 +19,12 @@ export default function InfoNif() {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
// we show the address as it would be typed in a browser, including the port
const label = new URL(address).host;
return (
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
@@ -19,10 +19,13 @@ export default function LogExport() {
<Panel.SubHeader>
Event log
<Button onClick={extract}>
Extract <IoArrowUp className={style.iconRotate} />
Open in new window <IoArrowUp className={style.iconRotate} />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Activity in this session. The log is not saved with the project and is cleared when Ontime restarts.
</Panel.Paragraph>
<Log />
</Panel.Card>
</Panel.Section>
@@ -1,26 +1,38 @@
import { MessageTag } from 'ontime-types';
import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import { isOntimeCloud } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from './client-control/ClientControlPanel';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
import NetworkStatus from './NetworkStatus';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const statusRef = useScrollIntoView<HTMLDivElement>('status', location);
const interfacesRef = useScrollIntoView<HTMLDivElement>('interfaces', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
{isDocker && (
<Panel.Section>
<OntimeCloudStats />
</Panel.Section>
<div ref={statusRef}>
<NetworkStatus />
</div>
{!isOntimeCloud && (
<div ref={interfacesRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Network interfaces</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Addresses which can be used to reach Ontime from other devices in the network. <br />
Click an address to open it in a new window, or use the icon to copy it to the clipboard.
</Panel.Paragraph>
<InfoNif />
</Panel.Card>
</Panel.Section>
</div>
)}
<div ref={logRef}>
<LogExport />
@@ -31,29 +43,3 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
</>
);
}
function OntimeCloudStats() {
const ping = usePing();
/**
* Send immediate ping request, and keep sending on an interval
*/
useEffect(() => {
sendSocket(MessageTag.Ping, new Date());
const doPing = setInterval(() => {
sendSocket(MessageTag.Ping, new Date());
}, 5000);
return () => {
clearInterval(doPing);
};
}, []);
return (
<Panel.SubHeader>
Ontime cloud
<Panel.Description>Current ping: {ping}ms</Panel.Description>
</Panel.SubHeader>
);
}
@@ -0,0 +1,109 @@
import { MaybeString, MessageTag } from 'ontime-types';
import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { useEffect } from 'react';
import Tag from '../../../../common/components/tag/Tag';
import useSessionStats from '../../../../common/hooks-query/useSessionStats';
import { usePing } from '../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../common/stores/clientStore';
import { formatDuration } from '../../../../common/utils/time';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
/** ping values above this are flagged to the user (ms) */
const slowPingThreshold = 100;
/** how often we ask the server for a ping (ms) */
const pingInterval = 5000;
/**
* Presents a date as the time elapsed since it happened
*/
function timeSince(date: MaybeString): string {
if (date === null) {
return 'Never';
}
const elapsed = Date.now() - new Date(date).getTime();
if (elapsed < MILLIS_PER_MINUTE) {
return 'Just now';
}
return `${formatDuration(elapsed)} ago`;
}
export default function NetworkStatus() {
const { data, status, isError } = useSessionStats();
// the client list is kept up to date over websocket, unlike the polled session stats
const clients = useClientStore((store) => store.clients);
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Server status</Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{isError && <Panel.Error>Failed to load session data</Panel.Error>}
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Connected clients' description='Clients currently connected to this server' />
<Tag>{Object.keys(clients).length}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Server running for' description='Time since the Ontime server was started' />
<Tag>{data ? formatDuration(Date.now() - new Date(data.startedAt).getTime()) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Last client connection' description='When a client last connected to the server' />
<Tag>{data ? timeSince(data.lastConnection) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Last integration request'
description='When the server last received a request from an integration'
/>
<Tag>{data ? timeSince(data.lastRequest) : '...'}</Tag>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Server time zone' description='Time zone used by the machine running Ontime' />
<Tag>{data ? data.timezone : '...'}</Tag>
</Panel.ListItem>
{isDocker && <CloudPing />}
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
);
}
/**
* Ontime cloud users are not in the same network as the server
* we show the round trip time to help them qualify the connection
*/
function CloudPing() {
const ping = usePing();
/**
* Send immediate ping request, and keep sending on an interval
*/
useEffect(() => {
sendSocket(MessageTag.Ping, new Date());
const doPing = setInterval(() => {
sendSocket(MessageTag.Ping, new Date());
}, pingInterval);
return () => {
clearInterval(doPing);
};
}, []);
return (
<Panel.ListItem>
<Panel.Field
title='Ontime cloud latency'
description='Time for a message to travel to the server and back. Lower is better'
/>
<Tag variant={ping > slowPingThreshold ? 'warning' : 'default'}>{ping}ms</Tag>
</Panel.ListItem>
);
}
@@ -1,11 +1,3 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.halfWidthNoWrap {
width: 50%;
white-space: nowrap;
@@ -16,6 +8,11 @@
user-select: text;
}
.self {
background-color: $blue-1100;
.muted {
color: $label-gray;
}
// align section notes with the table contents
.note {
padding: 0 2rem;
}
@@ -5,14 +5,42 @@ import { useState } from 'react';
import Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Tag from '../../../../../common/components/tag/Tag';
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore';
import { cx } from '../../../../../common/utils/styleUtils';
import { openLink } from '../../../../../common/utils/linkUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss';
/**
* Shows the path a client is showing
* The full URL is available for copying or opening in a new window
*/
function ClientPath({ origin, path }: { origin: string; path: string }) {
// clients report their path on connection, we may not have it yet
if (!path) {
return <span className={style.muted}>unknown</span>;
}
// origin is empty for clients which have not reported their location
if (!origin) {
return <span className={style.copiable}>{path}</span>;
}
const fullUrl = `${origin}${path}`;
return (
<Tooltip text={fullUrl} render={<span />}>
<CopyTag copyValue={fullUrl} size='small' onClick={() => openLink(fullUrl)}>
{path}
</CopyTag>
</Tooltip>
);
}
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
@@ -32,8 +60,19 @@ export default function ClientList() {
redirectHandler.open();
};
const ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
/**
* Clients are given by the server in connection order, which means rows move
* as clients come and go. We keep a stable order: self first, then by name
*/
const sortClients = (clientEntries: [string, Client][]) =>
clientEntries.sort(([keyA, clientA], [keyB, clientB]) => {
if (keyA === id) return -1;
if (keyB === id) return 1;
return clientA.name.localeCompare(clientB.name);
});
const ontimeClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type === 'ontime'));
const otherClients = sortClients(Object.entries(clients).filter(([_, { type }]) => type !== 'ontime'));
const targetClient: Client | undefined = clients[targetId];
@@ -59,29 +98,37 @@ export default function ClientList() {
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Ontime views connected to this server. Identify shows a marker in the client screen until you turn it off,
redirect sends the client to a different view.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td />
<th style={{ width: '20%' }}>Client Name</th>
<th>Path</th>
<th />
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key} className={cx([isCurrent && style.self])}>
<tr key={key} data-highlight={isCurrent}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td className={style.copiable}>{path}</td>
<td>
<ClientPath origin={client.origin} path={path} />
</td>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
@@ -117,14 +164,21 @@ export default function ClientList() {
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Connections which are not Ontime views, such as integrations and custom clients. These cannot be controlled
from Ontime.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidthNoWrap}>Client Name</td>
<td className={style.halfWidthNoWrap}>Client type</td>
<th className={style.halfWidthNoWrap}>Client Name</th>
<th className={style.halfWidthNoWrap}>Client type</th>
</tr>
</thead>
<tbody>
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -67,6 +67,14 @@ const staticOptions = [
id: 'network',
label: 'Network',
secondary: [
{
id: 'network__status',
label: 'Server status',
},
{
id: 'network__interfaces',
label: 'Network interfaces',
},
{
id: 'network__log',
label: 'Event log',
@@ -51,3 +51,13 @@ $info-hover: $section-white;
flex-wrap: wrap;
padding-bottom: 0.5rem;
}
.count {
color: $info-gray;
white-space: nowrap;
}
.empty {
color: $info-gray;
padding: 0.5rem 0;
}
+64 -86
View File
@@ -1,115 +1,90 @@
import { LogOrigin } from 'ontime-types';
import { LogLevel, LogOrigin } from 'ontime-types';
import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import Input from '../../common/components/input/input/Input';
import { clearLogs, useLogData } from '../../common/stores/logger';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
const allOrigins = [
LogOrigin.User,
LogOrigin.Client,
LogOrigin.Server,
LogOrigin.Playback,
LogOrigin.Rx,
LogOrigin.Tx,
];
export default function Log() {
const { logs: logData } = useLogData();
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
// log entries are not guaranteed to have an origin from the enum
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
const [onlyProblems, setOnlyProblems] = useState(false);
const [search, setSearch] = useState('');
const matchers: LogOrigin[] = [];
if (showUser) {
matchers.push(LogOrigin.User);
}
if (showClient) {
matchers.push(LogOrigin.Client);
}
if (showServer) {
matchers.push(LogOrigin.Server);
}
if (showRx) {
matchers.push(LogOrigin.Rx);
}
if (showTx) {
matchers.push(LogOrigin.Tx);
}
if (showPlayback) {
matchers.push(LogOrigin.Playback);
}
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const disableOthers = useCallback((toEnable: LogOrigin) => {
setShowUser(toEnable === LogOrigin.User);
setShowClient(toEnable === LogOrigin.Client);
setShowServer(toEnable === LogOrigin.Server);
setShowRx(toEnable === LogOrigin.Rx);
setShowTx(toEnable === LogOrigin.Tx);
setShowPlayback(toEnable === LogOrigin.Playback);
const toggleOrigin = useCallback((origin: LogOrigin) => {
setOrigins((previous) => {
const newOrigins = new Set(previous);
if (newOrigins.has(origin)) {
newOrigins.delete(origin);
} else {
newOrigins.add(origin);
}
return newOrigins;
});
}, []);
/** middle click on an origin shows only that origin */
const showOnlyOrigin = useCallback((origin: LogOrigin) => {
setOrigins(new Set([origin]));
}, []);
const searchTerm = search.toLowerCase();
const filteredData = logData.filter((entry) => {
if (!origins.has(entry.origin)) return false;
if (onlyProblems && entry.level === LogLevel.Info) return false;
if (searchTerm && !entry.text.toLowerCase().includes(searchTerm)) return false;
return true;
});
const isFiltered = filteredData.length !== logData.length;
return (
<>
<Panel.InlineElements className={style.buttonBar}>
{allOrigins.map((origin) => (
<Button
key={origin}
variant={origins.has(origin) ? 'primary' : 'subtle'}
size='small'
onClick={() => toggleOrigin(origin)}
onAuxClick={() => showOnlyOrigin(origin)}
onContextMenu={(event) => event.preventDefault()}
>
{origin}
</Button>
))}
<Button
variant={showUser ? 'primary' : 'subtle'}
variant={onlyProblems ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
onClick={() => setOnlyProblems((previous) => !previous)}
>
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
Issues only
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
</Panel.InlineElements>
<Panel.InlineElements className={style.buttonBar}>
<Input fluid placeholder='Filter messages' value={search} onChange={(event) => setSearch(event.target.value)} />
<span className={style.count}>
{isFiltered ? `${filteredData.length} of ${logData.length}` : logData.length} entries
</span>
</Panel.InlineElements>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
@@ -118,6 +93,9 @@ export default function Log() {
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
{filteredData.length === 0 && (
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
)}
</ul>
</>
);
+1 -17
View File
@@ -1,5 +1,5 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs } from 'ontime-utils';
import { MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
@@ -59,10 +59,6 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant;
}
export function combineValues<T extends Duration | Day>(...duration: T[]): T {
return Array.isArray(duration) ? (duration.reduce<number>((total, current) => total + current, 0) as T) : duration;
}
/**
* Calculates elapsed time on the clock from a starting time to the current time
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
@@ -84,15 +80,3 @@ export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day
return (currentDaySerial - startDaySerial) as Day;
}
export const ZERO_DURATION = 0 as Duration;
/**
* utility function to generate Instant times
*/
export function generateInstanceFromClock(hour: number, minutes: number, second: number, ms = 0) {
return toInstant(
(hour * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + second * MILLIS_PER_SECOND + ms) as TimeOfDay,
now(),
);
}
@@ -1,8 +1,7 @@
import { Duration, EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
import * as timeCore from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import {
findDayOffset,
getCurrent,
@@ -20,48 +19,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 5, 0),
clock: 5 * MILLIS_PER_MINUTE,
timer: {
addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('subtracts accumulated pause time', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0),
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
});
it('uses the current pause start while paused', () => {
const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0),
clock: 10 * MILLIS_PER_MINUTE,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0),
startedAt: 2 * MILLIS_PER_MINUTE,
},
_timer: {
pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE,
},
} as InternalRuntimeState;
} as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
});
@@ -83,7 +82,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(null);
});
@@ -96,13 +95,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: true,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(20);
});
@@ -115,13 +114,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(11);
});
@@ -134,13 +133,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 20,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(31);
@@ -154,13 +153,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -10,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -174,13 +173,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: -100,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -194,13 +193,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1),
startedAt: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1);
@@ -214,13 +213,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(10);
@@ -235,13 +234,13 @@ describe('getExpectedFinish()', () => {
timer: {
addedTime: 10,
duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
@@ -254,7 +253,7 @@ describe('getExpectedFinish()', () => {
},
timer: {
addedTime: 0,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
_timer: {
pausedAt: null,
@@ -264,7 +263,7 @@ describe('getExpectedFinish()', () => {
actualStart: 79200000,
plannedEnd: 600000,
},
} as InternalRuntimeState;
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time
@@ -280,7 +279,7 @@ describe('getCurrent()', () => {
timeEnd: 30,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0),
clock: 0,
timer: {
addedTime: 10,
duration: 111, // <-- we take the duration value
@@ -290,7 +289,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(111);
@@ -301,17 +300,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
clock: 1,
timer: {
addedTime: 0,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(9);
@@ -322,17 +321,17 @@ describe('getCurrent()', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1),
clock: 1,
timer: {
addedTime: 10,
duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(19);
@@ -343,17 +342,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10),
clock: 10,
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs + 10);
@@ -364,17 +363,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 0,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(15);
@@ -385,17 +384,17 @@ describe('getCurrent()', () => {
timeEnd: 20,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 20,
duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(35);
@@ -408,7 +407,7 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 0,
duration: 100,
@@ -421,7 +420,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -433,11 +432,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 0,
duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
rundown: {
plannedEnd: 100,
@@ -446,7 +445,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(70);
@@ -458,11 +457,11 @@ describe('getCurrent()', () => {
timeEnd: 100,
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30),
clock: 30,
timer: {
addedTime: 7,
duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10),
startedAt: 10,
},
rundown: {
plannedEnd: 100,
@@ -471,7 +470,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(77);
@@ -484,11 +483,11 @@ describe('getCurrent()', () => {
timeEnd: 600000, // 00:10:00
countToEnd: true,
},
_now: timeCore.generateInstanceFromClock(22, 5, 0),
clock: 79500000, // 22:05:00
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
rundown: {
actualStart: 79200000,
@@ -498,7 +497,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000);
@@ -506,7 +505,7 @@ describe('getCurrent()', () => {
it('handles events that were started late', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16 min after the scheduled end
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
eventNow: {
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
@@ -516,7 +515,7 @@ describe('getCurrent()', () => {
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0),
startedAt: 79200000, // 22:00:00
},
rundown: {
actualStart: 82000000, // 22:46:40 <--- started now
@@ -526,7 +525,7 @@ describe('getCurrent()', () => {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
@@ -542,17 +541,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0),
clock: 0,
timer: {
addedTime: 0,
duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
@@ -570,25 +569,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10,
timerType: TimerType.CountDown,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5),
clock: 5,
timer: {
addedTime: 3,
duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0),
startedAt: 0,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state);
const elapsed = duration - current;
expect(expectedFinish).toBe(13);
expect(current).toBe(8);
expect(elapsed).toBe(2);
expect(current).toBe(8);
});
});
@@ -872,7 +871,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -884,9 +883,9 @@ describe('getRuntimeOffset()', () => {
plannedStart: 100,
currentDay: 0,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
clock: 150,
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -900,7 +899,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed
startedAt: 150, // we started 50ms delayed
addedTime: 10, // we compensated with 10ms
current: 10, // we are 10ms into the timer
},
@@ -913,7 +912,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(60);
@@ -928,7 +927,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime
startedAt: 100, // we started ontime
current: -10, // we are 10 seconds over
addedTime: 0, // we have not compensated with added time
},
@@ -941,7 +940,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(10);
@@ -955,9 +954,9 @@ describe('getRuntimeOffset()', () => {
timeEnd: 150,
dayOffset: 0,
},
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150),
clock: 150,
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time
startedAt: 100, // started on time
current: 25, // are 25ms into it
addedTime: 0,
},
@@ -970,7 +969,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(25);
@@ -978,7 +977,7 @@ describe('getRuntimeOffset()', () => {
it('offset doesnt exist if we havent started', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789
clock: 78480789,
eventNow: {
id: 'd6a2ce',
timeStart: 77400000,
@@ -1012,7 +1011,7 @@ describe('getRuntimeOffset()', () => {
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1020,7 +1019,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offsets dont exist if we are not in overtime', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 13, 20),
clock: 80000000, // 22:13:20
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1064,11 +1063,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1076,7 +1075,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offset is the overtime', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40),
clock: 82000000, // 22:46:40
eventNow: {
id: 'd6a2ce',
type: 'event',
@@ -1120,11 +1119,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000,
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0),
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
@@ -1132,7 +1131,7 @@ describe('getRuntimeOffset()', () => {
it('handles time-to-end started after the end time', () => {
const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), // 22:46:40 <--- starting 16m 40s after the scheduled end
clock: 82000000, // 22:46:40 <--- starting 16m 40s after the scheduled end
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
@@ -1165,14 +1164,14 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 82000000 + 3600000, // <--- now + duration
playback: Playback.Play,
secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(22, 46, 40),
startedAt: 82000000, // <--- started now
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as InternalRuntimeState;
} as RuntimeState;
const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent as Duration;
state.timer.current = updateCurrent;
const { absolute } = getRuntimeOffset(state);
expect(millisToString(absolute)).toBe('00:16:40');
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
@@ -1188,7 +1187,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -1201,7 +1200,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(0);
@@ -1215,7 +1214,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150),
startedAt: 150,
addedTime: 0,
current: 0,
},
@@ -1228,7 +1227,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(50);
@@ -1242,7 +1241,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0,
},
timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100),
startedAt: 100,
addedTime: 0,
current: 0,
},
@@ -1255,7 +1254,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0,
},
_startDayOffset: 0,
} as InternalRuntimeState;
} as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(-50);
@@ -1277,7 +1276,7 @@ describe('getTimerPhase()', () => {
secondaryTimer: null,
startedAt: null,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None);
@@ -1295,7 +1294,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime);
@@ -1313,7 +1312,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger);
@@ -1331,7 +1330,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning);
@@ -1349,7 +1348,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100,
timeWarning: 200,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default);
@@ -1357,7 +1356,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
clock: 55691050,
eventNow: null,
eventNext: null,
rundown: {
@@ -1383,13 +1382,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
@@ -1397,7 +1396,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => {
const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050
clock: 55691050,
eventNow: null,
eventNext: null,
rundown: {
@@ -1423,13 +1422,13 @@ describe('getTimerPhase()', () => {
startedAt: null,
},
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
},
_rundown: {
totalDelay: 0,
},
} as InternalRuntimeState;
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
-2
View File
@@ -5,8 +5,6 @@ import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
import { normaliseEndTime } from './timerUtils.js';
// import * as timeCore from '../lib/time-core/timeCore.js';
/**
* Finds current event in a rolling rundown
*/
@@ -760,17 +760,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// save the restore state
if (hasImmediateChanges) {
const internalState = runtimeState.getInternalState();
restoreService
.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: internalState._timer.pausedAt,
pausedDuration: internalState._timer.pausedDuration,
pausedAt: state._timer.pausedAt,
pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart,
startEpoch: internalState._startEpoch,
startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay,
})
.catch((_e) => {
+36 -39
View File
@@ -1,9 +1,7 @@
import { Day, Duration, Maybe, TimeOfDay, TimerPhase } from 'ontime-types';
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js';
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
import type { RuntimeState } from '../stores/runtimeState.js';
/**
* handle events that span over midnight
@@ -22,46 +20,48 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
/**
* Calculates expected finish time of a running timer
* @param {InternalRuntimeState} state runtime state
* @param {RuntimeState} state runtime state
* @returns {number | null} new current time or null if nothing is running
*/
export function getExpectedFinish(state: InternalRuntimeState): Maybe<TimeOfDay> {
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) {
return null;
}
const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { _now } = state;
const { clock } = state;
if (startedAt === null) {
return null;
}
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0;
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (countToEnd) {
return (timeEnd + addedTime + pausedTime) as TimeOfDay;
return timeEnd + addedTime + pausedTime;
}
// handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) {
return (expectedFinish - dayInMs) as TimeOfDay;
return expectedFinish - dayInMs;
}
// an event cannot finish before it started (user added too much negative time)
return Math.max(expectedFinish, timeCore.toTimeOfDay(startedAt)) as TimeOfDay;
return Math.max(expectedFinish, startedAt);
}
/**
* Calculates running countdown
* @param {InternalRuntimeState} state runtime state
* @param {RuntimeState} state runtime state
* @returns {number} current time for timer
*/
export function getCurrent(state: InternalRuntimeState): Duration {
export function getCurrent(state: RuntimeState): number {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
@@ -71,12 +71,12 @@ export function getCurrent(state: InternalRuntimeState): Duration {
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { _now } = state;
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration;
return correctDay - clock + timeEnd + addedTime;
}
if (startedAt === null) {
@@ -84,19 +84,19 @@ export function getCurrent(state: InternalRuntimeState): Duration {
}
if (pausedAt != null) {
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - pausedAt) as Duration;
return startedAt + duration + addedTime - pausedAt;
}
const hasPassedMidnight = timeCore.toTimeOfDay(startedAt) > timeCore.toTimeOfDay(_now);
const hasPassedMidnight = startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return (timeCore.toTimeOfDay(startedAt) + duration + addedTime - timeCore.toTimeOfDay(_now) - correctDay) as Duration;
return startedAt + duration + addedTime - clock - correctDay;
}
/**
* Calculates active time elapsed since the timer started.
*/
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
const { _now } = state;
export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state;
const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer;
@@ -104,11 +104,11 @@ export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
return null;
}
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now);
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt));
const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed) as Duration;
return Math.max(0, activeElapsed);
}
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
@@ -127,7 +127,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
* @returns {boolean}
*/
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): boolean {
// we cant have skipped if we haven't started
// we cant have skipped if we havent started
if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false;
}
@@ -151,12 +151,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule
*/
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } {
const { eventNow, _now, _startDayOffset } = state;
export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, clock, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: ZERO_DURATION, relative: ZERO_DURATION };
return { absolute: 0, relative: 0 };
}
const { countToEnd, timeStart, dayOffset } = eventNow;
@@ -171,23 +171,20 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
}
// difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset =
timeCore.toTimeOfDay(startedAt) + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
// how long has the event been running over (is a negative number when in over timer so inverted before adding to offset)
const overtime = Math.abs(Math.min(current, 0));
// time the playback was paused, the different from now to when we paused is added to the offset TODO: brakes when crossing midnight
const pausedTime = state._timer.pausedAt === null ? 0 : timeCore.toTimeOfDay(_now) - state._timer.pausedAt;
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
// absolute offset is difference between schedule and playback time
// in case of count to end, the absolute offset is overtime and added time
const absolute = (
countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime
) as Duration;
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
// the relative offset is the same as the absolute but adjusted relative to the actual start time
const relative = (absolute + plannedStart - actualStart - _startDayOffset * dayInMs) as Duration;
const relative = absolute + plannedStart - actualStart - _startDayOffset * dayInMs;
return { absolute, relative };
}
@@ -196,7 +193,7 @@ export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Durat
* Checks running timer to see which phase it currently is in
* @param state
*/
export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state.timer.playback)) {
return TimerPhase.None;
}
@@ -226,7 +223,7 @@ export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
/**
* Finds the day offset relative to an event start
* used by the RuntimeState on first start to get correct offsets
* used byt the runtimeState on first start to get correct offsets
*/
export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart;
@@ -1,12 +1,33 @@
import { Day, type Duration, type Instant, OffsetMode, Playback, TimeOfDay, TimerPhase } from 'ontime-types';
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../runtimeState.js';
import type { RuntimeState } from '../runtimeState.js';
export const runtimePlaceholder = {
const baseState: RuntimeState = {
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
actualGroupStart: null,
currentDay: 0,
},
offset: {
absolute: 0,
relative: 0,
mode: OffsetMode.Absolute,
expectedRundownEnd: null,
expectedGroupEnd: null,
expectedFlagStart: null,
},
timer: {
addedTime: ZERO_DURATION,
addedTime: 0,
current: null,
duration: null,
elapsed: null,
@@ -16,43 +37,10 @@ export const runtimePlaceholder = {
secondaryTimer: null,
startedAt: null,
},
offset: {
absolute: ZERO_DURATION,
relative: ZERO_DURATION,
mode: OffsetMode.Absolute,
expectedGroupEnd: null,
expectedRundownEnd: null,
expectedFlagStart: null,
},
rundown: {
selectedEventIndex: null,
numEvents: 0,
plannedStart: 0 as TimeOfDay,
plannedEnd: 0 as TimeOfDay,
actualStart: null,
actualGroupStart: null,
currentDay: 0 as Day,
},
} as Readonly<InternalRuntimeState>;
const baseState: InternalRuntimeState = {
_now: 0 as Instant,
eventNow: null,
eventNext: null,
eventFlag: null,
groupNow: null,
rundown: {
...runtimePlaceholder.rundown,
},
offset: {
...runtimePlaceholder.offset,
},
timer: { ...runtimePlaceholder.timer },
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
pausedDuration: 0 as Duration,
pausedDuration: 0,
secondaryTarget: null,
hasFinished: false,
},
@@ -6,10 +6,9 @@ import { rundownCache } from '../../api-data/rundown/rundown.dao.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import {
type InternalRuntimeState,
type RuntimeState,
addTime,
clearState,
getInternalState,
getState,
load,
loadGroupFlagAndEnd,
@@ -33,7 +32,7 @@ const mockEvent = {
} as PlayableEvent;
const mockState = {
_now: 666,
clock: 666,
eventNow: null,
eventNext: null,
rundown: {
@@ -54,7 +53,7 @@ const mockState = {
pausedAt: null,
hasFinished: false,
},
} as InternalRuntimeState;
} as RuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
@@ -109,34 +108,34 @@ describe('mutation on runtimeState', () => {
const { metadata, rundown } = rundownCache.get();
load(mockEvent, rundown, metadata);
let newState = getInternalState();
let newState = getState();
expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.eventNext?.id).toBe('event2');
expect(newState.timer.playback).toBe(Playback.Armed);
expect(getState().clock).not.toBe(666);
expect(newState.clock).not.toBe(666);
expect(newState.groupNow).toBeNull();
// 2. Start event
vi.setSystemTime('jan 1 00:02');
let success = start();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
});
expect(newState.rundown.actualStart).toBe(getState().clock);
expect(newState.rundown.actualStart).toBe(newState.clock);
// 3. Pause event
vi.setSystemTime('jan 1 00:03');
success = pause();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(getState().clock).not.toBe(666);
expect(newState.clock).not.toBe(666);
expect(newState.timer).toMatchObject({
playback: Playback.Pause,
addedTime: 0,
});
expect(newState._timer.pausedAt).toEqual(getState().clock);
expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause();
expect(success).toBe(false);
@@ -144,7 +143,7 @@ describe('mutation on runtimeState', () => {
// 4. Restart event
vi.setSystemTime('jan 1 00:04');
success = start();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.timer).toMatchObject({
playback: Playback.Play,
@@ -164,7 +163,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event
vi.setSystemTime('jan 1 00:05');
success = stop();
newState = getInternalState();
newState = getState();
expect(success).toBe(true);
expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({
@@ -826,7 +825,7 @@ describe('roll mode', () => {
test('pending event', () => {
const { rundown, metadata } = rundownCache.get();
const { eventId, didStart } = roll(rundown, metadata);
const state = getInternalState();
const state = getState();
expect(eventId).toBe('1');
expect(didStart).toBe(false);
@@ -945,7 +944,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[11],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -973,7 +972,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[22],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1001,7 +1000,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1],
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1026,7 +1025,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
@@ -1088,7 +1087,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1170,7 +1169,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null,
eventNow: rundown.entries[0],
rundown: { actualGroupStart: null },
} as InternalRuntimeState;
} as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+105 -154
View File
@@ -17,6 +17,7 @@ import {
TimerPhase,
TimerState,
isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types';
import {
calculateDuration,
@@ -43,18 +44,14 @@ import {
hasCrossedMidnight,
} from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js';
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
type ExpectedMetadata = {
event: OntimeEvent;
accumulatedGap: Duration;
accumulatedGap: number;
isLinkedToLoaded: boolean;
} | null;
export type RuntimeState = {
/**
* @deprecated we want to phase out the 24h clock
*/
clock: TimeOfDay;
groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null;
@@ -64,72 +61,36 @@ export type RuntimeState = {
timer: TimerState;
rundown: RundownState;
// private properties of the timer calculations
};
export type InternalRuntimeState = Omit<RuntimeState, 'clock' | 'timer' | 'offset' | 'rundown'> & {
timer: {
addedTime: Duration;
current: Maybe<Duration>;
duration: Maybe<Duration>;
elapsed: Maybe<Duration>;
expectedFinish: Maybe<TimeOfDay>;
phase: TimerPhase;
playback: Playback;
secondaryTimer: Maybe<Duration>;
startedAt: Maybe<Instant>;
};
offset: {
absolute: Duration;
relative: Duration;
mode: OffsetMode;
expectedGroupEnd: Maybe<TimeOfDay>;
expectedRundownEnd: Maybe<TimeOfDay>;
expectedFlagStart: Maybe<TimeOfDay>;
};
rundown: {
selectedEventIndex: Maybe<number>;
numEvents: number;
plannedStart: Maybe<TimeOfDay>;
plannedEnd: Maybe<TimeOfDay>;
actualStart: Maybe<TimeOfDay>;
currentDay: Maybe<Day>;
actualGroupStart: Maybe<TimeOfDay>;
};
_now: Instant;
_timer: {
forceFinish: boolean; // whether we should declare an event as finished, will contain the finish time
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
pausedDuration: Duration;
pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
};
_rundown: {
totalDelay: number; // this value comes from rundown service
};
_startDayOffset: Maybe<Day>;
_startEpoch: Maybe<Instant>;
_group: ExpectedMetadata;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
};
const runtimeState: InternalRuntimeState = {
_now: timeCore.now(),
const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(),
groupNow: null,
eventNow: null,
eventNext: null,
eventFlag: null,
offset: { ...runtimePlaceholder.offset },
timer: {
...runtimePlaceholder.timer,
},
rundown: {
...runtimePlaceholder.rundown,
},
offset: { ...runtimeStorePlaceholder.offset },
timer: { ...runtimeStorePlaceholder.timer },
rundown: { ...runtimeStorePlaceholder.rundown },
_timer: {
forceFinish: false,
forceFinish: null,
pausedAt: null,
pausedDuration: 0 as Duration,
pausedDuration: 0,
secondaryTarget: null,
hasFinished: false,
},
@@ -143,32 +104,19 @@ const runtimeState: InternalRuntimeState = {
_startDayOffset: null,
};
export function getInternalState(): Readonly<InternalRuntimeState> {
return runtimeState;
}
export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state
return {
...runtimeState,
clock: timeCore.toTimeOfDay(runtimeState._now),
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown },
timer: {
addedTime: runtimeState.timer.addedTime,
current: runtimeState.timer.current,
duration: runtimeState.timer.duration,
elapsed: runtimeState.timer.elapsed,
expectedFinish: runtimeState.timer.expectedFinish,
phase: runtimeState.timer.phase,
playback: runtimeState.timer.playback,
secondaryTimer: runtimeState.timer.secondaryTimer,
startedAt: runtimeState.timer.startedAt ? timeCore.toTimeOfDay(runtimeState.timer.startedAt) : null,
},
timer: { ...runtimeState.timer },
_timer: { ...runtimeState._timer },
_rundown: { ...runtimeState._rundown },
};
}
@@ -179,18 +127,22 @@ export function clearEventData() {
runtimeState.eventNow = null;
runtimeState.eventNext = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedFlagStart = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false;
runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
}
@@ -208,18 +160,22 @@ export function clearState() {
runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = null;
runtimeState.offset = { ...runtimePlaceholder.offset };
runtimeState.offset.absolute = 0;
runtimeState.offset.relative = 0;
runtimeState.offset.expectedRundownEnd = null;
runtimeState.offset.expectedGroupEnd = null;
runtimeState.offset.expectedFlagStart = null;
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now();
runtimeState.timer = { ...runtimePlaceholder.timer };
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false;
runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration;
runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false;
@@ -261,9 +217,9 @@ export function updateRundownData(rundownData: {
runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay;
runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.rundown.plannedEnd =
rundownData.firstStart === null ? null : ((rundownData.firstStart + rundownData.totalDuration) as TimeOfDay);
rundownData.firstStart === null ? null : rundownData.firstStart + rundownData.totalDuration;
if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
}
@@ -297,7 +253,7 @@ export function load(
// update state
runtimeState.timer.playback = Playback.Armed;
runtimeState.timer.duration = event.duration as Duration;
runtimeState.timer.duration = calculateDuration(event.timeStart, event.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
@@ -311,13 +267,13 @@ export function load(
(firstStart === null || typeof firstStart === 'number') &&
(startEpoch === null || typeof startEpoch === 'number')
) {
runtimeState.rundown.actualStart = firstStart as TimeOfDay;
runtimeState.rundown.actualStart = firstStart;
runtimeState._startEpoch = startEpoch;
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
}
if (currentDay !== undefined) {
runtimeState.rundown.currentDay = currentDay as Day;
runtimeState.rundown.currentDay = currentDay;
}
const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.offset.absolute = absolute;
@@ -409,16 +365,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
@@ -435,7 +391,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.startedAt = null;
runtimeState._timer.hasFinished = false;
runtimeState.timer.addedTime = 0 as Duration;
runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null;
// this could be looked after by the timer
@@ -458,7 +414,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
}
export function start(state: InternalRuntimeState = runtimeState): boolean {
export function start(state: RuntimeState = runtimeState): boolean {
if (state.eventNow === null) {
return false;
}
@@ -466,19 +422,22 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return false;
}
state._now = timeCore.now();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
// add paused time if it exists
if (state._timer.pausedAt) {
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration;
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd);
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd);
const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null;
}
if (state.timer.startedAt === null) {
state.timer.startedAt = state._now;
state.timer.startedAt = state.clock;
}
state.timer.playback = Playback.Play;
@@ -486,15 +445,14 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) +
state.eventNow.dayOffset) as Day;
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now;
state.rundown.actualStart = timeCore.toTimeOfDay(state._now);
state._startEpoch = epoch;
state.rundown.actualStart = state.clock;
}
if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now);
state.rundown.actualGroupStart = state.clock;
}
// update timer phase
@@ -517,18 +475,18 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return true;
}
export function pause(state: InternalRuntimeState = runtimeState): boolean {
export function pause(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback !== Playback.Play) {
return false;
}
state.timer.playback = Playback.Pause;
state._now = timeCore.now();
state._timer.pausedAt = timeCore.toTimeOfDay(state._now);
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
export function stop(state: InternalRuntimeState = runtimeState): boolean {
export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) {
return false;
}
@@ -558,7 +516,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = true;
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
@@ -567,8 +525,8 @@ export function addTime(amount: number) {
}
// we can update the state after handling the side effects
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration);
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration);
runtimeState.timer.addedTime += amount;
runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under
@@ -588,8 +546,10 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = timeCore.toTimeOfDay(runtimeState._now);
runtimeState._now = timeCore.now(); // we update the clock on every update call
const previousClock = runtimeState.clock;
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -598,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart);
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
}
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, timeCore.toTimeOfDay(runtimeState._now));
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
return updateIfWaitingToRoll(clockHasCrossedMidnight);
}
@@ -658,24 +618,22 @@ export function update(): UpdateResult {
}
// account for offset
const offsetClock = timeCore.toTimeOfDay(
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
);
const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return {
hasTimerFinished: false,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer! <= 0,
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
};
}
}
@@ -692,7 +650,9 @@ export function roll(
}
// we will need to do some calculations, update the time first
runtimeState._now = timeCore.now();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
// 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
@@ -704,9 +664,7 @@ export function roll(
}
if (offset) {
runtimeState.offset = {
...offset,
} as InternalRuntimeState['offset'];
runtimeState.offset = { ...offset };
}
runtimeState.timer.playback = Playback.Roll;
@@ -715,16 +673,15 @@ export function roll(
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay;
runtimeState.timer.expectedFinish = normalisedEndTime;
// account for offset
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration);
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0 as Duration;
runtimeState.timer.elapsed = 0;
// check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
@@ -733,8 +690,8 @@ export function roll(
* If we are starting an event in roll mode
* we backtrace all the start times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
const plannedStart = runtimeState.eventNow.timeStart;
runtimeState.timer.startedAt = plannedStart;
// reset the secondary timer to cancel any countdowns
runtimeState.timer.secondaryTimer = null;
@@ -752,20 +709,18 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = timeCore.combineValues(
runtimeState._startDayOffset,
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
);
runtimeState.rundown.currentDay =
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay,
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending;
}
@@ -782,9 +737,9 @@ export function roll(
// account for offset but we only keep it if passed to us
if (offset) {
runtimeState.offset = { ...offset } as InternalRuntimeState['offset'];
runtimeState.offset = { ...offset };
}
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -811,7 +766,7 @@ export function roll(
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -834,18 +789,18 @@ export function roll(
* when we start in roll mode
* we need to backtrace all times to the supposed start time of the event
*/
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay;
const plannedStart = runtimeState.eventNow.timeStart;
const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now);
runtimeState.timer.expectedFinish = endTime as TimeOfDay;
runtimeState.timer.startedAt = plannedStart;
runtimeState.timer.expectedFinish = endTime;
// state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0 as Duration;
runtimeState.timer.elapsed = 0;
// update runtime
runtimeState.rundown.actualStart = plannedStart;
@@ -858,11 +813,11 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration);
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now)) as Day;
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
return { eventId: runtimeState.eventNow.id, didStart: true };
}
@@ -897,11 +852,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedGroupEnd = getExpectedEnd(
lastEvent,
lastEventExpectedStart,
state.rundown.currentDay!,
) as TimeOfDay;
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
}
}
@@ -918,7 +869,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedFlagStart = expectedStart as TimeOfDay;
state.offset.expectedFlagStart = expectedStart;
}
}
@@ -933,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart,
actualStart,
});
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!) as TimeOfDay;
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
}
}
@@ -1006,13 +957,13 @@ export function loadGroupFlagAndEnd(
if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true;
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._flag = { event: entry, isLinkedToLoaded, accumulatedGap };
}
}
if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true;
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
}
}
}
@@ -1020,7 +971,7 @@ export function loadGroupFlagAndEnd(
const lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) {
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap: accumulatedGap as Duration };
state._end = { event: lastEvent, isLinkedToLoaded, accumulatedGap };
}
}
@@ -1,4 +1,4 @@
import type { Duration, OntimeEvent } from 'ontime-types';
import type { OntimeEvent } from 'ontime-types';
import { TimeStrategy } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js';
@@ -103,17 +103,16 @@ export function validateTimes(
/**
* @description calculates event duration considering midnight
* @deprecated - duration is the more useful value from the event data, prefer using that directly
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export function calculateDuration(timeStart: number, timeEnd: number): Duration {
export function calculateDuration(timeStart: number, timeEnd: number): number {
// Durations must be positive
if (timeEnd < timeStart) {
return (timeEnd + dayInMs - timeStart) as Duration;
return timeEnd + dayInMs - timeStart;
}
return (timeEnd - timeStart) as Duration;
return timeEnd - timeStart;
}
export function calculateEnd(timeStart: number, duration: number): number {