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 CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId]; export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SESSION_STATS = ['sessionStats'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride']; export const CSS_OVERRIDE = ['cssOverride'];
+9 -1
View File
@@ -1,11 +1,19 @@
import axios from 'axios'; import axios from 'axios';
import { GetInfo, LinkOptions } from 'ontime-types'; import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions'; import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`; 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 * HTTP request to retrieve application info
*/ */
@@ -13,6 +13,9 @@ import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; 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 { interface RedirectClientModalProps {
id: string; id: string;
name: string; name: string;
@@ -25,7 +28,7 @@ interface RedirectClientModalProps {
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) { export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets(); const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath); const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/'); const [selected, setSelected] = useState(defaultView);
const { setRedirect } = setClientRemote; const { setRedirect } = setClientRemote;
@@ -97,19 +100,18 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
defaultValue={viewOptions[0].value} defaultValue={defaultView}
onValueChange={(value) => { onValueChange={(value) => {
if (value === null) return; if (value === null) return;
setSelected(value); setSelected(value);
}} }}
disabled={enabledPresets.length === 0}
/> />
</label> </label>
<Button <Button
variant='primary' variant='primary'
aria-label='Redirect to preset' aria-label='Redirect to preset'
className={style.redirect} className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'} disabled={selected === currentPath}
onClick={() => handleRedirect(selected)} onClick={() => handleRedirect(selected)}
> >
Redirect <IoArrowForward /> Redirect <IoArrowForward />
@@ -2,6 +2,7 @@ import { useState } from 'react';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
import { isKeyEnter } from '../../utils/keyEvent';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
@@ -33,7 +34,18 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
showCloseButton showCloseButton
onClose={onClose} onClose={onClose}
bodyElements={ 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={ 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: [], 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 useLogData = () => useStore(logger);
export const addLog = (log: Log) => export const addLog = (log: Log) =>
logger.setState((state) => ({ logger.setState((state) => ({
logs: [log, ...state.logs], logs: [log, ...state.logs].slice(0, maxLogEntries),
})); }));
export const clearLogs = () => logger.setState({ logs: [] }); export const clearLogs = () => logger.setState({ logs: [] });
@@ -117,6 +117,10 @@ $inner-padding: 1rem;
td[data-warn='true'] { td[data-warn='true'] {
background-color: $orange-1300; background-color: $orange-1300;
} }
tr[data-highlight='true'] {
background-color: $blue-1100;
}
} }
.listGroup { .listGroup {
@@ -19,10 +19,12 @@ export default function InfoNif() {
// interfaces outside localhost wont have access // interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null; if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address); 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 ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}> <CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} /> {`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
</CopyTag> </CopyTag>
); );
})} })}
@@ -19,10 +19,13 @@ export default function LogExport() {
<Panel.SubHeader> <Panel.SubHeader>
Event log Event log
<Button onClick={extract}> <Button onClick={extract}>
Extract <IoArrowUp className={style.iconRotate} /> Open in new window <IoArrowUp className={style.iconRotate} />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <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 /> <Log />
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -1,26 +1,38 @@
import { MessageTag } from 'ontime-types';
import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { usePing } from '../../../../common/hooks/useSocket'; import { isOntimeCloud } from '../../../../externals';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList'; import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from './client-control/ClientControlPanel'; import ClientControlPanel from './client-control/ClientControlPanel';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport'; import LogExport from './NetworkLogExport';
import NetworkStatus from './NetworkStatus';
export default function NetworkLogPanel({ location }: PanelBaseProps) { 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 clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location); const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return ( return (
<> <>
<Panel.Header>Network</Panel.Header> <Panel.Header>Network</Panel.Header>
{isDocker && ( <div ref={statusRef}>
<Panel.Section> <NetworkStatus />
<OntimeCloudStats /> </div>
</Panel.Section> {!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}> <div ref={logRef}>
<LogExport /> <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 { .halfWidthNoWrap {
width: 50%; width: 50%;
white-space: nowrap; white-space: nowrap;
@@ -16,6 +8,11 @@
user-select: text; user-select: text;
} }
.self { .muted {
background-color: $blue-1100; 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 Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal'; import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal'; import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
import { setClientRemote } from '../../../../../common/hooks/useSocket'; import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore'; 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 * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss'; 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() { export default function ClientList() {
const id = useClientStore((store) => store.id); const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients); const clients = useClientStore((store) => store.clients);
@@ -32,8 +60,19 @@ export default function ClientList() {
redirectHandler.open(); 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]; const targetClient: Client | undefined = clients[targetId];
@@ -59,29 +98,37 @@ export default function ClientList() {
)} )}
<Panel.Section> <Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title> <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> <Panel.Table>
<thead> <thead>
<tr> <tr>
<td style={{ width: '20%' }}>Client Name</td> <th style={{ width: '20%' }}>Client Name</th>
<td>Path</td> <th>Path</th>
<td /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
{ontimeClients.map(([key, client]) => { {ontimeClients.map(([key, client]) => {
const { identify, name, path } = client; const { identify, name, path } = client;
const isCurrent = id === key; const isCurrent = id === key;
return ( return (
<tr key={key} className={cx([isCurrent && style.self])}> <tr key={key} data-highlight={isCurrent}>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>} {isCurrent && <Tag>SELF</Tag>}
{name} {name}
</Panel.InlineElements> </Panel.InlineElements>
<td className={style.copiable}>{path}</td> <td>
<ClientPath origin={client.origin} path={path} />
</td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<Button <Button
size='small' size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent} disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'} variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'} data-testid={isCurrent ? '' : 'not-self-identify'}
@@ -117,14 +164,21 @@ export default function ClientList() {
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title> <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> <Panel.Table>
<thead> <thead>
<tr> <tr>
<td className={style.halfWidthNoWrap}>Client Name</td> <th className={style.halfWidthNoWrap}>Client Name</th>
<td className={style.halfWidthNoWrap}>Client type</td> <th className={style.halfWidthNoWrap}>Client type</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
{otherClients.map(([key, client]) => { {otherClients.map(([key, client]) => {
const { name, type } = client; const { name, type } = client;
@@ -67,6 +67,14 @@ const staticOptions = [
id: 'network', id: 'network',
label: 'Network', label: 'Network',
secondary: [ secondary: [
{
id: 'network__status',
label: 'Server status',
},
{
id: 'network__interfaces',
label: 'Network interfaces',
},
{ {
id: 'network__log', id: 'network__log',
label: 'Event log', label: 'Event log',
@@ -51,3 +51,13 @@ $info-hover: $section-white;
flex-wrap: wrap; flex-wrap: wrap;
padding-bottom: 0.5rem; 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 { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import Input from '../../common/components/input/input/Input';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss'; import style from './Log.module.scss';
const allOrigins = [
LogOrigin.User,
LogOrigin.Client,
LogOrigin.Server,
LogOrigin.Playback,
LogOrigin.Rx,
LogOrigin.Tx,
];
export default function Log() { export default function Log() {
const { logs: logData } = useLogData(); const { logs: logData } = useLogData();
const [showClient, setShowClient] = useState(true); // log entries are not guaranteed to have an origin from the enum
const [showServer, setShowServer] = useState(true); const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
const [showRx, setShowRx] = useState(true); const [onlyProblems, setOnlyProblems] = useState(false);
const [showTx, setShowTx] = useState(true); const [search, setSearch] = useState('');
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
const matchers: LogOrigin[] = []; const toggleOrigin = useCallback((origin: LogOrigin) => {
if (showUser) { setOrigins((previous) => {
matchers.push(LogOrigin.User); const newOrigins = new Set(previous);
} if (newOrigins.has(origin)) {
if (showClient) { newOrigins.delete(origin);
matchers.push(LogOrigin.Client); } else {
} newOrigins.add(origin);
if (showServer) { }
matchers.push(LogOrigin.Server); return newOrigins;
} });
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);
}, []); }, []);
/** 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 ( return (
<> <>
<Panel.InlineElements className={style.buttonBar}> <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 <Button
variant={showUser ? 'primary' : 'subtle'} variant={onlyProblems ? 'primary' : 'subtle'}
size='small' size='small'
onClick={() => setShowUser((s) => !s)} onClick={() => setOnlyProblems((previous) => !previous)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.User} Issues only
</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}
</Button> </Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}> <Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
</Panel.InlineElements> </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}> <ul className={style.log}>
{filteredData.map((logEntry) => ( {filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}> <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> <span className={style.msg}>{logEntry.text}</span>
</li> </li>
))} ))}
{filteredData.length === 0 && (
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
)}
</ul> </ul>
</> </>
); );
+1 -17
View File
@@ -1,5 +1,5 @@
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types'; 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 */ /** Returns the current instant */
export function now(): Instant { export function now(): Instant {
@@ -59,10 +59,6 @@ export function addDuration(instant: Instant, duration: Duration | Duration[]):
return (instant + totalDuration) as Instant; 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 * 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) * 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; 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 { 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 { RuntimeState } from '../../stores/runtimeState.js';
import type { InternalRuntimeState, RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
getCurrent, getCurrent,
@@ -20,48 +19,48 @@ const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeSt
describe('getElapsed()', () => { describe('getElapsed()', () => {
it('returns active elapsed time from startedAt without add-time adjustments', () => { it('returns active elapsed time from startedAt without add-time adjustments', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 5, 0), clock: 5 * MILLIS_PER_MINUTE,
timer: { timer: {
addedTime: -10 * MILLIS_PER_MINUTE, addedTime: -10 * MILLIS_PER_MINUTE,
current: 15 * MILLIS_PER_MINUTE, current: 15 * MILLIS_PER_MINUTE,
duration: 20 * MILLIS_PER_MINUTE, duration: 20 * MILLIS_PER_MINUTE,
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
pausedDuration: 0, pausedDuration: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
}); });
it('subtracts accumulated pause time', () => { it('subtracts accumulated pause time', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0), clock: 10 * MILLIS_PER_MINUTE,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
pausedDuration: 5 * MILLIS_PER_MINUTE, pausedDuration: 5 * MILLIS_PER_MINUTE,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
}); });
it('uses the current pause start while paused', () => { it('uses the current pause start while paused', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(0, 10, 0), clock: 10 * MILLIS_PER_MINUTE,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 2, 0), startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: 7 * MILLIS_PER_MINUTE, pausedAt: 7 * MILLIS_PER_MINUTE,
pausedDuration: 1 * MILLIS_PER_MINUTE, pausedDuration: 1 * MILLIS_PER_MINUTE,
}, },
} as InternalRuntimeState; } as RuntimeState;
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE); expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
}); });
@@ -83,7 +82,7 @@ describe('getExpectedFinish()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(null); expect(calculatedFinish).toBe(null);
}); });
@@ -96,13 +95,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: true, hasFinished: true,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(20); expect(calculatedFinish).toBe(20);
}); });
@@ -115,13 +114,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(11); expect(calculatedFinish).toBe(11);
}); });
@@ -134,13 +133,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 20, addedTime: 20,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(31); expect(calculatedFinish).toBe(31);
@@ -154,13 +153,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: -10, addedTime: -10,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -174,13 +173,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: -100, addedTime: -100,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -194,13 +193,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 0, duration: 0,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 1), startedAt: 1,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(1); expect(calculatedFinish).toBe(1);
@@ -214,13 +213,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs, duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(10); expect(calculatedFinish).toBe(10);
@@ -235,13 +234,13 @@ describe('getExpectedFinish()', () => {
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: dayInMs, duration: dayInMs,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40); expect(calculatedFinish).toBe(40);
@@ -254,7 +253,7 @@ describe('getExpectedFinish()', () => {
}, },
timer: { timer: {
addedTime: 0, addedTime: 0,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
@@ -264,7 +263,7 @@ describe('getExpectedFinish()', () => {
actualStart: 79200000, actualStart: 79200000,
plannedEnd: 600000, plannedEnd: 600000,
}, },
} as InternalRuntimeState; } as RuntimeState;
const calculatedFinish = getExpectedFinish(state); const calculatedFinish = getExpectedFinish(state);
// expected finish is not a duration but a point in time // expected finish is not a duration but a point in time
@@ -280,7 +279,7 @@ describe('getCurrent()', () => {
timeEnd: 30, timeEnd: 30,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0), clock: 0,
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: 111, // <-- we take the duration value duration: 111, // <-- we take the duration value
@@ -290,7 +289,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(111); expect(current).toBe(111);
@@ -301,17 +300,17 @@ describe('getCurrent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1), clock: 1,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(9); expect(current).toBe(9);
@@ -322,17 +321,17 @@ describe('getCurrent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 1), clock: 1,
timer: { timer: {
addedTime: 10, addedTime: 10,
duration: 10, duration: 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(19); expect(current).toBe(19);
@@ -343,17 +342,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 10), clock: 10,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(dayInMs + 10); expect(current).toBe(dayInMs + 10);
@@ -364,17 +363,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(15); expect(current).toBe(15);
@@ -385,17 +384,17 @@ describe('getCurrent()', () => {
timeEnd: 20, timeEnd: 20,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 20, addedTime: 20,
duration: dayInMs + 10, duration: dayInMs + 10,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(35); expect(current).toBe(35);
@@ -408,7 +407,7 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 100, duration: 100,
@@ -421,7 +420,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(70); expect(current).toBe(70);
@@ -433,11 +432,11 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: 100, duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
rundown: { rundown: {
plannedEnd: 100, plannedEnd: 100,
@@ -446,7 +445,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(70); expect(current).toBe(70);
@@ -458,11 +457,11 @@ describe('getCurrent()', () => {
timeEnd: 100, timeEnd: 100,
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 30), clock: 30,
timer: { timer: {
addedTime: 7, addedTime: 7,
duration: 100, duration: 100,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 10), startedAt: 10,
}, },
rundown: { rundown: {
plannedEnd: 100, plannedEnd: 100,
@@ -471,7 +470,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(77); expect(current).toBe(77);
@@ -484,11 +483,11 @@ describe('getCurrent()', () => {
timeEnd: 600000, // 00:10:00 timeEnd: 600000, // 00:10:00
countToEnd: true, countToEnd: true,
}, },
_now: timeCore.generateInstanceFromClock(22, 5, 0), clock: 79500000, // 22:05:00
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
rundown: { rundown: {
actualStart: 79200000, actualStart: 79200000,
@@ -498,7 +497,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(dayInMs - 79500000 + 600000); expect(current).toBe(dayInMs - 79500000 + 600000);
@@ -506,7 +505,7 @@ describe('getCurrent()', () => {
it('handles events that were started late', () => { it('handles events that were started late', () => {
const state = { 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: { eventNow: {
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00 timeEnd: 81000000, // 22:30:00
@@ -516,7 +515,7 @@ describe('getCurrent()', () => {
timer: { timer: {
addedTime: 0, addedTime: 0,
duration: Infinity, // not relevant, duration: Infinity, // not relevant,
startedAt: timeCore.generateInstanceFromClock(22, 0, 0), startedAt: 79200000, // 22:00:00
}, },
rundown: { rundown: {
actualStart: 82000000, // 22:46:40 <--- started now actualStart: 82000000, // 22:46:40 <--- started now
@@ -526,7 +525,7 @@ describe('getCurrent()', () => {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const current = getCurrent(state); const current = getCurrent(state);
expect(current).toBe(81000000 - 82000000); // <-- planned end - now expect(current).toBe(81000000 - 82000000); // <-- planned end - now
@@ -542,17 +541,17 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0), clock: 0,
timer: { timer: {
addedTime: 0, addedTime: 0,
duration, duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const expectedFinish = getExpectedFinish(state); const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state); const current = getCurrent(state);
@@ -570,25 +569,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 5), clock: 5,
timer: { timer: {
addedTime: 3, addedTime: 3,
duration, duration,
startedAt: timeCore.generateInstanceFromClock(0, 0, 0), startedAt: 0,
}, },
_timer: { _timer: {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
const expectedFinish = getExpectedFinish(state); const expectedFinish = getExpectedFinish(state);
const current = getCurrent(state); const current = getCurrent(state);
const elapsed = duration - current; const elapsed = duration - current;
expect(expectedFinish).toBe(13); expect(expectedFinish).toBe(13);
expect(current).toBe(8);
expect(elapsed).toBe(2); expect(elapsed).toBe(2);
expect(current).toBe(8);
}); });
}); });
@@ -872,7 +871,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -884,9 +883,9 @@ describe('getRuntimeOffset()', () => {
plannedStart: 100, plannedStart: 100,
currentDay: 0, currentDay: 0,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150), clock: 150,
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(50); expect(absolute).toBe(50);
@@ -900,7 +899,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), // we started 50ms delayed startedAt: 150, // we started 50ms delayed
addedTime: 10, // we compensated with 10ms addedTime: 10, // we compensated with 10ms
current: 10, // we are 10ms into the timer current: 10, // we are 10ms into the timer
}, },
@@ -913,7 +912,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(60); expect(absolute).toBe(60);
@@ -928,7 +927,7 @@ describe('getRuntimeOffset()', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // we started ontime startedAt: 100, // we started ontime
current: -10, // we are 10 seconds over current: -10, // we are 10 seconds over
addedTime: 0, // we have not compensated with added time addedTime: 0, // we have not compensated with added time
}, },
@@ -941,7 +940,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(10); expect(absolute).toBe(10);
@@ -955,9 +954,9 @@ describe('getRuntimeOffset()', () => {
timeEnd: 150, timeEnd: 150,
dayOffset: 0, dayOffset: 0,
}, },
_now: timeCore.generateInstanceFromClock(0, 0, 0, 150), clock: 150,
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), // started on time startedAt: 100, // started on time
current: 25, // are 25ms into it current: 25, // are 25ms into it
addedTime: 0, addedTime: 0,
}, },
@@ -970,7 +969,7 @@ describe('getRuntimeOffset()', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(25); expect(absolute).toBe(25);
@@ -978,7 +977,7 @@ describe('getRuntimeOffset()', () => {
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 48, 0), // 78480789 clock: 78480789,
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
timeStart: 77400000, timeStart: 77400000,
@@ -1012,7 +1011,7 @@ describe('getRuntimeOffset()', () => {
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1020,7 +1019,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offsets dont exist if we are not in overtime', () => { it('with time-to-end, offsets dont exist if we are not in overtime', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 13, 20), clock: 80000000, // 22:13:20
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
type: 'event', type: 'event',
@@ -1064,11 +1063,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000, expectedFinish: 81600000,
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0), startedAt: 78000000,
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1076,7 +1075,7 @@ describe('getRuntimeOffset()', () => {
it('with time-to-end, offset is the overtime', () => { it('with time-to-end, offset is the overtime', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(22, 46, 40), clock: 82000000, // 22:46:40
eventNow: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
type: 'event', type: 'event',
@@ -1120,11 +1119,11 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 81600000, expectedFinish: 81600000,
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(21, 40, 0), startedAt: 78000000,
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time 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', () => { it('handles time-to-end started after the end time', () => {
const state = { 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: { eventNow: {
id: 'd6a2ce', id: 'd6a2ce',
timeStart: 77400000, // 21:30:00 timeStart: 77400000, // 21:30:00
@@ -1165,14 +1164,14 @@ describe('getRuntimeOffset()', () => {
expectedFinish: 82000000 + 3600000, // <--- now + duration expectedFinish: 82000000 + 3600000, // <--- now + duration
playback: Playback.Play, playback: Playback.Play,
secondaryTimer: null, secondaryTimer: null,
startedAt: timeCore.generateInstanceFromClock(22, 46, 40), startedAt: 82000000, // <--- started now
}, },
_startDayOffset: 0, _startDayOffset: 0,
_timer: { pausedAt: null }, _timer: { pausedAt: null },
} as InternalRuntimeState; } as RuntimeState;
const updateCurrent = getCurrent(state); const updateCurrent = getCurrent(state);
state.timer.current = updateCurrent as Duration; state.timer.current = updateCurrent;
const { absolute } = getRuntimeOffset(state); const { absolute } = getRuntimeOffset(state);
expect(millisToString(absolute)).toBe('00:16:40'); expect(millisToString(absolute)).toBe('00:16:40');
expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end expect(absolute).toBe(82000000 - 81000000); // <-- now - planned end
@@ -1188,7 +1187,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1201,7 +1200,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(0); expect(absolute).toBe(0);
@@ -1215,7 +1214,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 150), startedAt: 150,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1228,7 +1227,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(50); expect(absolute).toBe(50);
@@ -1242,7 +1241,7 @@ describe('getRuntimeOffset() relative', () => {
dayOffset: 0, dayOffset: 0,
}, },
timer: { timer: {
startedAt: timeCore.generateInstanceFromClock(0, 0, 0, 100), startedAt: 100,
addedTime: 0, addedTime: 0,
current: 0, current: 0,
}, },
@@ -1255,7 +1254,7 @@ describe('getRuntimeOffset() relative', () => {
currentDay: 0, currentDay: 0,
}, },
_startDayOffset: 0, _startDayOffset: 0,
} as InternalRuntimeState; } as RuntimeState;
const { absolute, relative } = getRuntimeOffset(state); const { absolute, relative } = getRuntimeOffset(state);
expect(absolute).toBe(-50); expect(absolute).toBe(-50);
@@ -1277,7 +1276,7 @@ describe('getTimerPhase()', () => {
secondaryTimer: null, secondaryTimer: null,
startedAt: null, startedAt: null,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None); expect(phase).toBe(TimerPhase.None);
@@ -1295,7 +1294,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime); expect(phase).toBe(TimerPhase.Overtime);
@@ -1313,7 +1312,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger); expect(phase).toBe(TimerPhase.Danger);
@@ -1331,7 +1330,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning); expect(phase).toBe(TimerPhase.Warning);
@@ -1349,7 +1348,7 @@ describe('getTimerPhase()', () => {
timeDanger: 100, timeDanger: 100,
timeWarning: 200, timeWarning: 200,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default); expect(phase).toBe(TimerPhase.Default);
@@ -1357,7 +1356,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => { it('#1042 identifies waiting to roll', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050 clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -1383,13 +1382,13 @@ describe('getTimerPhase()', () => {
startedAt: null, startedAt: null,
}, },
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
}, },
_rundown: { _rundown: {
totalDelay: 0, totalDelay: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending); expect(phase).toBe(TimerPhase.Pending);
@@ -1397,7 +1396,7 @@ describe('getTimerPhase()', () => {
it('#1042 identifies waiting to roll', () => { it('#1042 identifies waiting to roll', () => {
const state = { const state = {
_now: timeCore.generateInstanceFromClock(16, 28, 11), //55691050 clock: 55691050,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -1423,13 +1422,13 @@ describe('getTimerPhase()', () => {
startedAt: null, startedAt: null,
}, },
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
}, },
_rundown: { _rundown: {
totalDelay: 0, totalDelay: 0,
}, },
} as InternalRuntimeState; } as RuntimeState;
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending); 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 { getTimedIndexFromPlayableIndex } from '../api-data/rundown/rundown.utils.js';
import { normaliseEndTime } from './timerUtils.js'; import { normaliseEndTime } from './timerUtils.js';
// import * as timeCore from '../lib/time-core/timeCore.js';
/** /**
* Finds current event in a rolling rundown * Finds current event in a rolling rundown
*/ */
@@ -760,17 +760,16 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// save the restore state // save the restore state
if (hasImmediateChanges) { if (hasImmediateChanges) {
const internalState = runtimeState.getInternalState();
restoreService restoreService
.save({ .save({
playback: state.timer.playback, playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null, selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt, startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime, addedTime: state.timer.addedTime,
pausedAt: internalState._timer.pausedAt, pausedAt: state._timer.pausedAt,
pausedDuration: internalState._timer.pausedDuration, pausedDuration: state._timer.pausedDuration,
firstStart: state.rundown.actualStart, firstStart: state.rundown.actualStart,
startEpoch: internalState._startEpoch, startEpoch: state._startEpoch,
currentDay: state.rundown.currentDay, currentDay: state.rundown.currentDay,
}) })
.catch((_e) => { .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 { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
import * as timeCore from '../lib/time-core/timeCore.js'; import type { RuntimeState } from '../stores/runtimeState.js';
import { ZERO_DURATION } from '../lib/time-core/timeCore.js';
import type { InternalRuntimeState, RuntimeState } from '../stores/runtimeState.js';
/** /**
* handle events that span over midnight * 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 * 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 * @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; const { startedAt, duration, addedTime } = state.timer;
if (state.eventNow === null) { if (state.eventNow === null) {
return null; return null;
} }
const { countToEnd, timeEnd } = state.eventNow; const { countToEnd, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { _now } = state; const { clock } = state;
if (startedAt === null) { if (startedAt === null) {
return null; return null;
} }
const pausedTime = pausedAt !== null ? timeCore.toTimeOfDay(_now) - pausedAt : 0; const pausedTime = pausedAt != null ? clock - pausedAt : 0;
if (countToEnd) { if (countToEnd) {
return (timeEnd + addedTime + pausedTime) as TimeOfDay; return timeEnd + addedTime + pausedTime;
} }
// handle events that finish the day after // handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if there is a timer // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = timeCore.toTimeOfDay(startedAt) + duration! + addedTime + pausedTime; const expectedFinish = startedAt + duration! + addedTime + pausedTime;
if (expectedFinish > dayInMs) { if (expectedFinish > dayInMs) {
return (expectedFinish - dayInMs) as TimeOfDay; return expectedFinish - dayInMs;
} }
// an event cannot finish before it started (user added too much negative time) // 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 * Calculates running countdown
* @param {InternalRuntimeState} state runtime state * @param {RuntimeState} state runtime state
* @returns {number} current time for timer * @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 // eslint-disable-next-line no-unused-labels -- dev code path
DEV: { DEV: {
if (state.eventNow === null || state.timer.duration === null) { 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 { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow; const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer; const { pausedAt } = state._timer;
const { _now } = state; const { clock } = state;
if (countToEnd) { if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd; const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0; const correctDay = isEventOverMidnight ? dayInMs : 0;
return (correctDay - timeCore.toTimeOfDay(_now) + timeEnd + addedTime) as Duration; return correctDay - clock + timeEnd + addedTime;
} }
if (startedAt === null) { if (startedAt === null) {
@@ -84,19 +84,19 @@ export function getCurrent(state: InternalRuntimeState): Duration {
} }
if (pausedAt != null) { 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; 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. * Calculates active time elapsed since the timer started.
*/ */
export function getElapsed(state: InternalRuntimeState): Maybe<Duration> { export function getElapsed(state: RuntimeState): MaybeNumber {
const { _now } = state; const { clock } = state;
const { startedAt } = state.timer; const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer; const { pausedAt, pausedDuration } = state._timer;
@@ -104,11 +104,11 @@ export function getElapsed(state: InternalRuntimeState): Maybe<Duration> {
return null; return null;
} }
const referenceClock = pausedAt ?? timeCore.toTimeOfDay(_now); const referenceClock = pausedAt ?? clock;
const elapsedSinceStart = getTimeSinceStart(referenceClock, timeCore.toTimeOfDay(startedAt)); const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration; const activeElapsed = elapsedSinceStart - pausedDuration;
return Math.max(0, activeElapsed) as Duration; return Math.max(0, activeElapsed);
} }
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
@@ -127,7 +127,7 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
* @returns {boolean} * @returns {boolean}
*/ */
export function skippedOutOfEvent(state: RuntimeState, previousTime: number, skipLimit: number): 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) { if (state.timer.expectedFinish === null || state.timer.startedAt === null) {
return false; return false;
} }
@@ -151,12 +151,12 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
* Positive offset is over time / behind schedule * Positive offset is over time / behind schedule
* Negative offset is under time / ahead of schedule * Negative offset is under time / ahead of schedule
*/ */
export function getRuntimeOffset(state: InternalRuntimeState): { absolute: Duration; relative: Duration } { export function getRuntimeOffset(state: RuntimeState): { absolute: number; relative: number } {
const { eventNow, _now, _startDayOffset } = state; const { eventNow, clock, _startDayOffset } = state;
const { addedTime, current, startedAt } = state.timer; const { addedTime, current, startedAt } = state.timer;
// nothing to calculate if there are no loaded events or if we havent started // nothing to calculate if there are no loaded events or if we havent started
if (eventNow === null || startedAt === null || _startDayOffset === null) { if (eventNow === null || startedAt === null || _startDayOffset === null) {
return { absolute: ZERO_DURATION, relative: ZERO_DURATION }; return { absolute: 0, relative: 0 };
} }
const { countToEnd, timeStart, dayOffset } = eventNow; 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) // difference between planned event start and actual event start (will be positive if we started behind)
const eventStartOffset = const eventStartOffset = startedAt + _startDayOffset * dayInMs - (timeStart + dayOffset * dayInMs);
timeCore.toTimeOfDay(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) // 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)); 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 // 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 // absolute offset is difference between schedule and playback time
// in case of count to end, the absolute offset is overtime and added time // in case of count to end, the absolute offset is overtime and added time
const absolute = ( const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime
) as Duration;
// the relative offset is the same as the absolute but adjusted relative to the actual start time // 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 }; 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 * Checks running timer to see which phase it currently is in
* @param state * @param state
*/ */
export function getTimerPhase(state: InternalRuntimeState): TimerPhase { export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state.timer.playback)) { if (!isPlaybackActive(state.timer.playback)) {
return TimerPhase.None; return TimerPhase.None;
} }
@@ -226,7 +223,7 @@ export function getTimerPhase(state: InternalRuntimeState): TimerPhase {
/** /**
* Finds the day offset relative to an event start * 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 { export function findDayOffset(plannedStart: number, clock: number): Day {
const distance = clock - plannedStart; 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 { deepmerge } from 'ontime-utils';
import { ZERO_DURATION } from '../../lib/time-core/timeCore.js'; import type { RuntimeState } from '../runtimeState.js';
import type { InternalRuntimeState, 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: { timer: {
addedTime: ZERO_DURATION, addedTime: 0,
current: null, current: null,
duration: null, duration: null,
elapsed: null, elapsed: null,
@@ -16,43 +37,10 @@ export const runtimePlaceholder = {
secondaryTimer: null, secondaryTimer: null,
startedAt: 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: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0 as Duration, pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, 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 { initRundown } from '../../api-data/rundown/rundown.service.js';
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
import { import {
type InternalRuntimeState, type RuntimeState,
addTime, addTime,
clearState, clearState,
getInternalState,
getState, getState,
load, load,
loadGroupFlagAndEnd, loadGroupFlagAndEnd,
@@ -33,7 +32,7 @@ const mockEvent = {
} as PlayableEvent; } as PlayableEvent;
const mockState = { const mockState = {
_now: 666, clock: 666,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
rundown: { rundown: {
@@ -54,7 +53,7 @@ const mockState = {
pausedAt: null, pausedAt: null,
hasFinished: false, hasFinished: false,
}, },
} as InternalRuntimeState; } as RuntimeState;
vi.mock('../../classes/data-provider/DataProvider.js', () => { vi.mock('../../classes/data-provider/DataProvider.js', () => {
return { return {
@@ -109,34 +108,34 @@ describe('mutation on runtimeState', () => {
const { metadata, rundown } = rundownCache.get(); const { metadata, rundown } = rundownCache.get();
load(mockEvent, rundown, metadata); load(mockEvent, rundown, metadata);
let newState = getInternalState(); let newState = getState();
expect(newState.eventNow?.id).toBe(mockEvent.id); expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.eventNext?.id).toBe('event2'); expect(newState.eventNext?.id).toBe('event2');
expect(newState.timer.playback).toBe(Playback.Armed); expect(newState.timer.playback).toBe(Playback.Armed);
expect(getState().clock).not.toBe(666); expect(newState.clock).not.toBe(666);
expect(newState.groupNow).toBeNull(); expect(newState.groupNow).toBeNull();
// 2. Start event // 2. Start event
vi.setSystemTime('jan 1 00:02'); vi.setSystemTime('jan 1 00:02');
let success = start(); let success = start();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Play, playback: Playback.Play,
}); });
expect(newState.rundown.actualStart).toBe(getState().clock); expect(newState.rundown.actualStart).toBe(newState.clock);
// 3. Pause event // 3. Pause event
vi.setSystemTime('jan 1 00:03'); vi.setSystemTime('jan 1 00:03');
success = pause(); success = pause();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(getState().clock).not.toBe(666); expect(newState.clock).not.toBe(666);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Pause, playback: Playback.Pause,
addedTime: 0, addedTime: 0,
}); });
expect(newState._timer.pausedAt).toEqual(getState().clock); expect(newState._timer.pausedAt).toEqual(newState.clock);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
@@ -144,7 +143,7 @@ describe('mutation on runtimeState', () => {
// 4. Restart event // 4. Restart event
vi.setSystemTime('jan 1 00:04'); vi.setSystemTime('jan 1 00:04');
success = start(); success = start();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
playback: Playback.Play, playback: Playback.Play,
@@ -164,7 +163,7 @@ describe('mutation on runtimeState', () => {
// 5. Stop event // 5. Stop event
vi.setSystemTime('jan 1 00:05'); vi.setSystemTime('jan 1 00:05');
success = stop(); success = stop();
newState = getInternalState(); newState = getState();
expect(success).toBe(true); expect(success).toBe(true);
expect(newState.eventNow).toBe(null); expect(newState.eventNow).toBe(null);
expect(newState.timer).toMatchObject({ expect(newState.timer).toMatchObject({
@@ -826,7 +825,7 @@ describe('roll mode', () => {
test('pending event', () => { test('pending event', () => {
const { rundown, metadata } = rundownCache.get(); const { rundown, metadata } = rundownCache.get();
const { eventId, didStart } = roll(rundown, metadata); const { eventId, didStart } = roll(rundown, metadata);
const state = getInternalState(); const state = getState();
expect(eventId).toBe('1'); expect(eventId).toBe('1');
expect(didStart).toBe(false); expect(didStart).toBe(false);
@@ -945,7 +944,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[11], eventNow: rundown.entries[11],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
@@ -973,7 +972,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1], groupNow: rundown.entries[1],
eventNow: rundown.entries[22], eventNow: rundown.entries[22],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1001,7 +1000,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: rundown.entries[1], groupNow: rundown.entries[1],
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
@@ -1026,7 +1025,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
@@ -1088,7 +1087,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
@@ -1170,7 +1169,7 @@ describe('loadGroupFlagAndEnd()', () => {
groupNow: null, groupNow: null,
eventNow: rundown.entries[0], eventNow: rundown.entries[0],
rundown: { actualGroupStart: null }, rundown: { actualGroupStart: null },
} as InternalRuntimeState; } as RuntimeState;
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata; const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
+105 -154
View File
@@ -17,6 +17,7 @@ import {
TimerPhase, TimerPhase,
TimerState, TimerState,
isOntimeEvent, isOntimeEvent,
runtimeStorePlaceholder,
} from 'ontime-types'; } from 'ontime-types';
import { import {
calculateDuration, calculateDuration,
@@ -43,18 +44,14 @@ import {
hasCrossedMidnight, hasCrossedMidnight,
} from '../services/timerUtils.js'; } from '../services/timerUtils.js';
import { timerConfig } from '../setup/config.js'; import { timerConfig } from '../setup/config.js';
import { runtimePlaceholder } from './__mocks__/runtimeState.mocks.js';
type ExpectedMetadata = { type ExpectedMetadata = {
event: OntimeEvent; event: OntimeEvent;
accumulatedGap: Duration; accumulatedGap: number;
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} | null; } | null;
export type RuntimeState = { export type RuntimeState = {
/**
* @deprecated we want to phase out the 24h clock
*/
clock: TimeOfDay; clock: TimeOfDay;
groupNow: OntimeGroup | null; groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null; eventNow: PlayableEvent | null;
@@ -64,72 +61,36 @@ export type RuntimeState = {
timer: TimerState; timer: TimerState;
rundown: RundownState; rundown: RundownState;
// private properties of the timer calculations // 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: { _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>; pausedAt: Maybe<TimeOfDay>;
pausedDuration: Duration; pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
}; };
_rundown: { _rundown: {
totalDelay: number; // this value comes from rundown service totalDelay: number; // this value comes from rundown service
}; };
_startDayOffset: Maybe<Day>;
_startEpoch: Maybe<Instant>;
_group: ExpectedMetadata; _group: ExpectedMetadata;
_flag: ExpectedMetadata; _flag: ExpectedMetadata;
_end: ExpectedMetadata; _end: ExpectedMetadata;
_startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>;
}; };
const runtimeState: InternalRuntimeState = { const runtimeState: RuntimeState = {
_now: timeCore.now(), clock: timeCore.timeOfDayNow(),
groupNow: null, groupNow: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null, eventFlag: null,
offset: { ...runtimePlaceholder.offset }, offset: { ...runtimeStorePlaceholder.offset },
timer: { timer: { ...runtimeStorePlaceholder.timer },
...runtimePlaceholder.timer, rundown: { ...runtimeStorePlaceholder.rundown },
},
rundown: {
...runtimePlaceholder.rundown,
},
_timer: { _timer: {
forceFinish: false, forceFinish: null,
pausedAt: null, pausedAt: null,
pausedDuration: 0 as Duration, pausedDuration: 0,
secondaryTarget: null, secondaryTarget: null,
hasFinished: false, hasFinished: false,
}, },
@@ -143,32 +104,19 @@ const runtimeState: InternalRuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
export function getInternalState(): Readonly<InternalRuntimeState> {
return runtimeState;
}
export function getState(): Readonly<RuntimeState> { export function getState(): Readonly<RuntimeState> {
// create a shallow copy of the state // create a shallow copy of the state
return { return {
...runtimeState, ...runtimeState,
clock: timeCore.toTimeOfDay(runtimeState._now),
eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null, eventNow: runtimeState.eventNow ? { ...runtimeState.eventNow } : null,
eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null, eventNext: runtimeState.eventNext ? { ...runtimeState.eventNext } : null,
eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null, eventFlag: runtimeState.eventFlag ? { ...runtimeState.eventFlag } : null,
groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null, groupNow: runtimeState.groupNow ? { ...runtimeState.groupNow } : null,
offset: { ...runtimeState.offset }, offset: { ...runtimeState.offset },
rundown: { ...runtimeState.rundown }, rundown: { ...runtimeState.rundown },
timer: { timer: { ...runtimeState.timer },
addedTime: runtimeState.timer.addedTime, _timer: { ...runtimeState._timer },
current: runtimeState.timer.current, _rundown: { ...runtimeState._rundown },
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,
},
}; };
} }
@@ -179,18 +127,22 @@ export function clearEventData() {
runtimeState.eventNow = null; runtimeState.eventNow = null;
runtimeState.eventNext = 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.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now(); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration; runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
} }
@@ -208,18 +160,22 @@ export function clearState() {
runtimeState.rundown.actualStart = null; runtimeState.rundown.actualStart = null;
runtimeState.rundown.selectedEventIndex = 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._end = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState._now = timeCore.now(); runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimePlaceholder.timer }; runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown // when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = false; runtimeState._timer.forceFinish = null;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
runtimeState._timer.pausedDuration = 0 as Duration; runtimeState._timer.pausedDuration = 0;
runtimeState._timer.secondaryTarget = null; runtimeState._timer.secondaryTarget = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
@@ -261,9 +217,9 @@ export function updateRundownData(rundownData: {
runtimeState._rundown.totalDelay = rundownData.totalDelay; runtimeState._rundown.totalDelay = rundownData.totalDelay;
runtimeState.rundown.numEvents = rundownData.numEvents; runtimeState.rundown.numEvents = rundownData.numEvents;
runtimeState.rundown.plannedStart = rundownData.firstStart as TimeOfDay; runtimeState.rundown.plannedStart = rundownData.firstStart;
runtimeState.rundown.plannedEnd = 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(); if (isPlaybackActive(runtimeState.timer.playback)) getExpectedTimes();
} }
@@ -297,7 +253,7 @@ export function load(
// update state // update state
runtimeState.timer.playback = Playback.Armed; 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.timer.current = getCurrent(runtimeState);
runtimeState.rundown.numEvents = metadata.timedEventOrder.length; runtimeState.rundown.numEvents = metadata.timedEventOrder.length;
@@ -311,13 +267,13 @@ export function load(
(firstStart === null || typeof firstStart === 'number') && (firstStart === null || typeof firstStart === 'number') &&
(startEpoch === null || typeof startEpoch === 'number') (startEpoch === null || typeof startEpoch === 'number')
) { ) {
runtimeState.rundown.actualStart = firstStart as TimeOfDay; runtimeState.rundown.actualStart = firstStart;
runtimeState._startEpoch = startEpoch; runtimeState._startEpoch = startEpoch;
if (firstStart !== null && runtimeState.rundown.plannedStart !== null) { if (firstStart !== null && runtimeState.rundown.plannedStart !== null) {
runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart); runtimeState._startDayOffset = findDayOffset(runtimeState.rundown.plannedStart, firstStart);
} }
if (currentDay !== undefined) { if (currentDay !== undefined) {
runtimeState.rundown.currentDay = currentDay as Day; runtimeState.rundown.currentDay = currentDay;
} }
const { absolute, relative } = getRuntimeOffset(runtimeState); const { absolute, relative } = getRuntimeOffset(runtimeState);
runtimeState.offset.absolute = absolute; runtimeState.offset.absolute = absolute;
@@ -409,16 +365,16 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll // handle edge cases with roll
if (runtimeState.timer.playback === Playback.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 waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) { if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) { if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start // if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay; 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 { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
} }
@@ -435,7 +391,7 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
runtimeState.timer.startedAt = null; runtimeState.timer.startedAt = null;
runtimeState._timer.hasFinished = false; runtimeState._timer.hasFinished = false;
runtimeState.timer.addedTime = 0 as Duration; runtimeState.timer.addedTime = 0;
runtimeState._timer.pausedAt = null; runtimeState._timer.pausedAt = null;
// this could be looked after by the timer // this could be looked after by the timer
@@ -458,7 +414,7 @@ export function updateAll(rundown: Rundown, metadata: RundownMetadata) {
loadGroupFlagAndEnd(rundown, metadata, eventNowIndex); loadGroupFlagAndEnd(rundown, metadata, eventNowIndex);
} }
export function start(state: InternalRuntimeState = runtimeState): boolean { export function start(state: RuntimeState = runtimeState): boolean {
if (state.eventNow === null) { if (state.eventNow === null) {
return false; return false;
} }
@@ -466,19 +422,22 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return false; return false;
} }
state._now = timeCore.now(); const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null; state.timer.secondaryTimer = null;
// add paused time if it exists // add paused time if it exists
if (state._timer.pausedAt) { if (state._timer.pausedAt) {
const timeToAdd = (timeCore.toTimeOfDay(state._now) - state._timer.pausedAt) as Duration; const timeToAdd = state.clock - state._timer.pausedAt;
state.timer.addedTime = timeCore.combineValues(state.timer.addedTime, timeToAdd); state.timer.addedTime += timeToAdd;
state._timer.pausedDuration = timeCore.combineValues(state._timer.pausedDuration, timeToAdd); state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
} }
if (state.timer.startedAt === null) { if (state.timer.startedAt === null) {
state.timer.startedAt = state._now; state.timer.startedAt = state.clock;
} }
state.timer.playback = Playback.Play; state.timer.playback = Playback.Play;
@@ -486,15 +445,14 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
state.timer.elapsed = getElapsed(state); state.timer.elapsed = getElapsed(state);
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, timeCore.toTimeOfDay(state._now)) + state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset; state.rundown.currentDay = state._startDayOffset;
state._startEpoch = state._now; state._startEpoch = epoch;
state.rundown.actualStart = timeCore.toTimeOfDay(state._now); state.rundown.actualStart = state.clock;
} }
if (state.groupNow !== null && state.rundown.actualGroupStart === null) { if (state.groupNow !== null && state.rundown.actualGroupStart === null) {
state.rundown.actualGroupStart = timeCore.toTimeOfDay(state._now); state.rundown.actualGroupStart = state.clock;
} }
// update timer phase // update timer phase
@@ -517,18 +475,18 @@ export function start(state: InternalRuntimeState = runtimeState): boolean {
return true; return true;
} }
export function pause(state: InternalRuntimeState = runtimeState): boolean { export function pause(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback !== Playback.Play) { if (state.timer.playback !== Playback.Play) {
return false; return false;
} }
state.timer.playback = Playback.Pause; state.timer.playback = Playback.Pause;
state._now = timeCore.now(); state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = timeCore.toTimeOfDay(state._now); state._timer.pausedAt = state.clock;
return true; return true;
} }
export function stop(state: InternalRuntimeState = runtimeState): boolean { export function stop(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Stop) { if (state.timer.playback === Playback.Stop) {
return false; return false;
} }
@@ -558,7 +516,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) { if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered // set finished time so side effects are triggered
runtimeState._timer.forceFinish = true; runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else { } else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) { if (willGoPositive) {
@@ -567,8 +525,8 @@ export function addTime(amount: number) {
} }
// we can update the state after handling the side effects // we can update the state after handling the side effects
runtimeState.timer.addedTime = timeCore.combineValues(runtimeState.timer.addedTime, amount as Duration); runtimeState.timer.addedTime += amount;
runtimeState.timer.current = timeCore.combineValues(runtimeState.timer.current, amount as Duration); runtimeState.timer.current += amount;
runtimeState.timer.elapsed = getElapsed(runtimeState); runtimeState.timer.elapsed = getElapsed(runtimeState);
// update runtime delays: over - under // update runtime delays: over - under
@@ -588,8 +546,10 @@ export type UpdateResult = {
export function update(): UpdateResult { export function update(): UpdateResult {
// 0. there are some things we always do // 0. there are some things we always do
const previousClock = timeCore.toTimeOfDay(runtimeState._now); const previousClock = runtimeState.clock;
runtimeState._now = timeCore.now(); // we update the clock on every update call const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle? // 1. is playback idle?
if (!isPlaybackActive(runtimeState.timer.playback)) { if (!isPlaybackActive(runtimeState.timer.playback)) {
@@ -598,13 +558,13 @@ export function update(): UpdateResult {
// calculate currentDay from epoch (days elapsed since playback was started) // calculate currentDay from epoch (days elapsed since playback was started)
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) { if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now); const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
runtimeState.rundown.currentDay = timeCore.combineValues(runtimeState._startDayOffset, daysSinceStart); runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
} }
// 2. are we waiting to roll? // 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) { 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); return updateIfWaitingToRoll(clockHasCrossedMidnight);
} }
@@ -658,24 +618,22 @@ export function update(): UpdateResult {
} }
// account for offset // account for offset
const offsetClock = timeCore.toTimeOfDay( const offsetClock = runtimeState.clock + runtimeState.offset.absolute;
timeCore.addDuration(runtimeState._now, runtimeState.offset.absolute as Duration),
);
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) { if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target // if we crossed midnight, we need to update the target
// this is the same logic from the roll function // this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
} }
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget! - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return { return {
hasTimerFinished: false, 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 // 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 // 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) { if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
@@ -704,9 +664,7 @@ export function roll(
} }
if (offset) { if (offset) {
runtimeState.offset = { runtimeState.offset = { ...offset };
...offset,
} as InternalRuntimeState['offset'];
} }
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
@@ -715,16 +673,15 @@ export function roll(
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs ? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd; : runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime as TimeOfDay; runtimeState.timer.expectedFinish = normalisedEndTime;
// account for offset // account for offset
// const offsetClock = timeCore.addDuration(runtimeState._now, -runtimeState.offset.absolute as Duration); const offsetClock = runtimeState.clock - runtimeState.offset.absolute;
const offsetClock = timeCore.toTimeOfDay(runtimeState._now) - runtimeState.offset.absolute;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration; 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 // check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock); 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 * If we are starting an event in roll mode
* we backtrace all the start times to the supposed start time of the event * we backtrace all the start times to the supposed start time of the event
*/ */
const plannedStart = runtimeState.eventNow.timeStart as TimeOfDay; const plannedStart = runtimeState.eventNow.timeStart;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now); runtimeState.timer.startedAt = plannedStart;
// reset the secondary timer to cancel any countdowns // reset the secondary timer to cancel any countdowns
runtimeState.timer.secondaryTimer = null; runtimeState.timer.secondaryTimer = null;
@@ -752,20 +709,18 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day; runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started // backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay); const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration); runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch // calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = timeCore.combineValues( runtimeState.rundown.currentDay =
runtimeState._startDayOffset, runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now),
);
} }
} else { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart( runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart as TimeOfDay, runtimeState.eventNow.timeStart,
offsetClock, offsetClock,
) as TimeOfDay; ) as TimeOfDay;
runtimeState.timer.secondaryTimer = (runtimeState._timer.secondaryTarget - offsetClock) as Duration; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
} }
@@ -782,9 +737,9 @@ export function roll(
// account for offset but we only keep it if passed to us // account for offset but we only keep it if passed to us
if (offset) { 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); const { index, isPending } = loadRoll(rundown, metadata, offsetClock);
@@ -811,7 +766,7 @@ export function roll(
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after // 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.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 // preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -834,18 +789,18 @@ export function roll(
* when we start in roll mode * when we start in roll mode
* we need to backtrace all times to the supposed start time of the event * 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 = const endTime =
runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart runtimeState.eventNow.timeEnd < runtimeState.eventNow.timeStart
? runtimeState.eventNow.timeEnd + dayInMs ? runtimeState.eventNow.timeEnd + dayInMs
: runtimeState.eventNow.timeEnd; : runtimeState.eventNow.timeEnd;
runtimeState.timer.startedAt = timeCore.toInstant(plannedStart, runtimeState._now); runtimeState.timer.startedAt = plannedStart;
runtimeState.timer.expectedFinish = endTime as TimeOfDay; runtimeState.timer.expectedFinish = endTime;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, endTime);
runtimeState.timer.current = getCurrent(runtimeState); runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.elapsed = 0 as Duration; runtimeState.timer.elapsed = 0;
// update runtime // update runtime
runtimeState.rundown.actualStart = plannedStart; runtimeState.rundown.actualStart = plannedStart;
@@ -858,11 +813,11 @@ export function roll(
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) + runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
runtimeState.eventNow.dayOffset) as Day; runtimeState.eventNow.dayOffset) as Day;
// backdate _startEpoch to when the event conceptually started // backdate _startEpoch to when the event conceptually started
const timeElapsed = timeCore.elapsedTime(timeCore.toTimeOfDay(runtimeState._now), plannedStart as TimeOfDay); const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
runtimeState._startEpoch = timeCore.addDuration(runtimeState._now, -timeElapsed as Duration); runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
// calculate currentDay from the backdated epoch // calculate currentDay from the backdated epoch
runtimeState.rundown.currentDay = (runtimeState._startDayOffset + 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 }; return { eventId: runtimeState.eventNow.id, didStart: true };
} }
@@ -897,11 +852,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.offset.expectedGroupEnd = getExpectedEnd( state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
lastEvent,
lastEventExpectedStart,
state.rundown.currentDay!,
) as TimeOfDay;
} }
} }
@@ -918,7 +869,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, actualStart,
}); });
state.offset.expectedFlagStart = expectedStart as TimeOfDay; state.offset.expectedFlagStart = expectedStart;
} }
} }
@@ -933,7 +884,7 @@ function getExpectedTimes(state = runtimeState) {
plannedStart, plannedStart,
actualStart, 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)) { if (!foundFlag && metadata.flags.includes(entry.id)) {
foundFlag = true; foundFlag = true;
state.eventFlag = entry as PlayableEvent; // we know it is playable as it is coming from the playableEventOrder list 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) { if (!foundGroupEnd && entry.id === lastEventInGroup?.id) {
foundGroupEnd = true; 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 lastID = playableEventOrder.at(-1);
const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null; const lastEvent = lastID ? (entries[lastID] as OntimeEvent) : null;
if (lastEvent) { 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 { TimeStrategy } from 'ontime-types';
import { dayInMs } from '../date-utils/conversionUtils.js'; import { dayInMs } from '../date-utils/conversionUtils.js';
@@ -103,17 +103,16 @@ export function validateTimes(
/** /**
* @description calculates event duration considering midnight * @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} timeStart
* @param {number} timeEnd * @param {number} timeEnd
* @returns {number} * @returns {number}
*/ */
export function calculateDuration(timeStart: number, timeEnd: number): Duration { export function calculateDuration(timeStart: number, timeEnd: number): number {
// Durations must be positive // Durations must be positive
if (timeEnd < timeStart) { 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 { export function calculateEnd(timeStart: number, duration: number): number {