Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 6d7cd416dc fix: count to end ccrossing midnight 2026-07-25 17:47:13 +02:00
18 changed files with 273 additions and 363 deletions
-1
View File
@@ -15,7 +15,6 @@ export const RUNDOWN = ['rundown'];
export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current'];
export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId];
export const RUNTIME = ['runtimeStore'];
export const SESSION_STATS = ['sessionStats'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
+1 -9
View File
@@ -1,19 +1,11 @@
import axios from 'axios';
import { GetInfo, LinkOptions, SessionStats } from 'ontime-types';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
const sessionPath = `${apiEntryUrl}/session`;
/**
* HTTP request to retrieve statistics of the running session
*/
export async function getSessionStats(options?: RequestOptions): Promise<SessionStats> {
const res = await axios.get(sessionPath, { signal: options?.signal });
return res.data;
}
/**
* HTTP request to retrieve application info
*/
@@ -13,9 +13,6 @@ import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
/** the first navigator view, used as the initial value of the view select */
const defaultView = `/${navigatorConstants[0].url}`;
interface RedirectClientModalProps {
id: string;
name: string;
@@ -28,7 +25,7 @@ interface RedirectClientModalProps {
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState(defaultView);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote;
@@ -100,18 +97,19 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
<Select
fluid
options={viewOptions}
defaultValue={defaultView}
defaultValue={viewOptions[0].value}
onValueChange={(value) => {
if (value === null) return;
setSelected(value);
}}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={selected === currentPath}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
@@ -2,7 +2,6 @@ import { useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import { setClientRemote } from '../../hooks/useSocket';
import { isKeyEnter } from '../../utils/keyEvent';
import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input';
@@ -34,18 +33,7 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
showCloseButton
onClose={onClose}
bodyElements={
<Input
height='large'
placeholder='New name'
autoFocus
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (isKeyEnter(event) && canSubmit) {
handleRename();
}
}}
/>
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
}
footerElements={
<>
@@ -1,17 +0,0 @@
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 };
}
+1 -4
View File
@@ -15,14 +15,11 @@ const logger = createStore<LogStore>(() => ({
logs: [],
}));
/** the log is kept in memory, we cap it to avoid it growing through a long show */
const maxLogEntries = 500;
export const useLogData = () => useStore(logger);
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs].slice(0, maxLogEntries),
logs: [log, ...state.logs],
}));
export const clearLogs = () => logger.setState({ logs: [] });
@@ -117,10 +117,6 @@ $inner-padding: 1rem;
td[data-warn='true'] {
background-color: $orange-1300;
}
tr[data-highlight='true'] {
background-color: $blue-1100;
}
}
.listGroup {
@@ -19,12 +19,10 @@ export default function InfoNif() {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
const address = linkToOtherHost(nif.address);
// we show the address as it would be typed in a browser, including the port
const label = new URL(address).host;
return (
<CopyTag key={`${nif.name}-${nif.address}`} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${label}`} <IoArrowUp className={style.goIcon} />
<CopyTag key={nif.name} copyValue={address} onClick={() => handleClick(address)}>
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
</CopyTag>
);
})}
@@ -19,13 +19,10 @@ export default function LogExport() {
<Panel.SubHeader>
Event log
<Button onClick={extract}>
Open in new window <IoArrowUp className={style.iconRotate} />
Extract <IoArrowUp className={style.iconRotate} />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Activity in this session. The log is not saved with the project and is cleared when Ontime restarts.
</Panel.Paragraph>
<Log />
</Panel.Card>
</Panel.Section>
@@ -1,38 +1,26 @@
import { MessageTag } from 'ontime-types';
import { useEffect } from 'react';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import { isOntimeCloud } from '../../../../externals';
import { usePing } from '../../../../common/hooks/useSocket';
import { sendSocket } from '../../../../common/utils/socket';
import { isDocker } from '../../../../externals';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from './client-control/ClientControlPanel';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
import NetworkStatus from './NetworkStatus';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const statusRef = useScrollIntoView<HTMLDivElement>('status', location);
const interfacesRef = useScrollIntoView<HTMLDivElement>('interfaces', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
<div ref={statusRef}>
<NetworkStatus />
</div>
{!isOntimeCloud && (
<div ref={interfacesRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Network interfaces</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>
Addresses which can be used to reach Ontime from other devices in the network. <br />
Click an address to open it in a new window, or use the icon to copy it to the clipboard.
</Panel.Paragraph>
<InfoNif />
</Panel.Card>
</Panel.Section>
</div>
{isDocker && (
<Panel.Section>
<OntimeCloudStats />
</Panel.Section>
)}
<div ref={logRef}>
<LogExport />
@@ -43,3 +31,29 @@ 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>
);
}
@@ -1,109 +0,0 @@
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,3 +1,11 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.halfWidthNoWrap {
width: 50%;
white-space: nowrap;
@@ -8,11 +16,6 @@
user-select: text;
}
.muted {
color: $label-gray;
}
// align section notes with the table contents
.note {
padding: 0 2rem;
.self {
background-color: $blue-1100;
}
@@ -5,42 +5,14 @@ import { useState } from 'react';
import Button from '../../../../../common/components/buttons/Button';
import { RedirectClientModal } from '../../../../../common/components/client-modal/RedirectClientModal';
import { RenameClientModal } from '../../../../../common/components/client-modal/RenameClientModal';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Tag from '../../../../../common/components/tag/Tag';
import Tooltip from '../../../../../common/components/tooltip/Tooltip';
import { setClientRemote } from '../../../../../common/hooks/useSocket';
import { useClientStore } from '../../../../../common/stores/clientStore';
import { openLink } from '../../../../../common/utils/linkUtils';
import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './ClientControlPanel.module.scss';
/**
* Shows the path a client is showing
* The full URL is available for copying or opening in a new window
*/
function ClientPath({ origin, path }: { origin: string; path: string }) {
// clients report their path on connection, we may not have it yet
if (!path) {
return <span className={style.muted}>unknown</span>;
}
// origin is empty for clients which have not reported their location
if (!origin) {
return <span className={style.copiable}>{path}</span>;
}
const fullUrl = `${origin}${path}`;
return (
<Tooltip text={fullUrl} render={<span />}>
<CopyTag copyValue={fullUrl} size='small' onClick={() => openLink(fullUrl)}>
{path}
</CopyTag>
</Tooltip>
);
}
export default function ClientList() {
const id = useClientStore((store) => store.id);
const clients = useClientStore((store) => store.clients);
@@ -60,19 +32,8 @@ export default function ClientList() {
redirectHandler.open();
};
/**
* 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 ontimeClients = Object.entries(clients).filter(([_, { type }]) => type === 'ontime');
const otherClients = Object.entries(clients).filter(([_, { type }]) => type !== 'ontime');
const targetClient: Client | undefined = clients[targetId];
@@ -98,37 +59,29 @@ export default function ClientList() {
)}
<Panel.Section>
<Panel.Title>Ontime Clients ({ontimeClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Ontime views connected to this server. Identify shows a marker in the client screen until you turn it off,
redirect sends the client to a different view.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<th style={{ width: '20%' }}>Client Name</th>
<th>Path</th>
<th />
<td style={{ width: '20%' }}>Client Name</td>
<td>Path</td>
<td />
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && <Panel.TableEmpty label='No Ontime clients connected' />}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
return (
<tr key={key} data-highlight={isCurrent}>
<tr key={key} className={cx([isCurrent && style.self])}>
<Panel.InlineElements relation='inner' as='td'>
{isCurrent && <Tag>SELF</Tag>}
{name}
</Panel.InlineElements>
<td>
<ClientPath origin={client.origin} path={path} />
</td>
<td className={style.copiable}>{path}</td>
<Panel.InlineElements relation='inner' as='td'>
<Button
size='small'
className={`${identify ? style.blink : ''}`}
disabled={isCurrent}
variant={identify ? 'primary' : 'subtle'}
data-testid={isCurrent ? '' : 'not-self-identify'}
@@ -164,21 +117,14 @@ export default function ClientList() {
<Panel.Divider />
<Panel.Section>
<Panel.Title>Other Clients ({otherClients.length})</Panel.Title>
<div className={style.note}>
<Panel.Description>
Connections which are not Ontime views, such as integrations and custom clients. These cannot be controlled
from Ontime.
</Panel.Description>
</div>
<Panel.Table>
<thead>
<tr>
<th className={style.halfWidthNoWrap}>Client Name</th>
<th className={style.halfWidthNoWrap}>Client type</th>
<td className={style.halfWidthNoWrap}>Client Name</td>
<td className={style.halfWidthNoWrap}>Client type</td>
</tr>
</thead>
<tbody>
{otherClients.length === 0 && <Panel.TableEmpty label='No other clients connected' />}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -67,14 +67,6 @@ const staticOptions = [
id: 'network',
label: 'Network',
secondary: [
{
id: 'network__status',
label: 'Server status',
},
{
id: 'network__interfaces',
label: 'Network interfaces',
},
{
id: 'network__log',
label: 'Event log',
@@ -51,13 +51,3 @@ $info-hover: $section-white;
flex-wrap: wrap;
padding-bottom: 0.5rem;
}
.count {
color: $info-gray;
white-space: nowrap;
}
.empty {
color: $info-gray;
padding: 0.5rem 0;
}
+86 -64
View File
@@ -1,90 +1,115 @@
import { LogLevel, LogOrigin } from 'ontime-types';
import { LogOrigin } from 'ontime-types';
import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import Input from '../../common/components/input/input/Input';
import { clearLogs, useLogData } from '../../common/stores/logger';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
const allOrigins = [
LogOrigin.User,
LogOrigin.Client,
LogOrigin.Server,
LogOrigin.Playback,
LogOrigin.Rx,
LogOrigin.Tx,
];
export default function Log() {
const { logs: logData } = useLogData();
// log entries are not guaranteed to have an origin from the enum
const [origins, setOrigins] = useState<Set<string>>(() => new Set<string>(allOrigins));
const [onlyProblems, setOnlyProblems] = useState(false);
const [search, setSearch] = useState('');
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
const toggleOrigin = useCallback((origin: LogOrigin) => {
setOrigins((previous) => {
const newOrigins = new Set(previous);
if (newOrigins.has(origin)) {
newOrigins.delete(origin);
} else {
newOrigins.add(origin);
}
return newOrigins;
});
const matchers: LogOrigin[] = [];
if (showUser) {
matchers.push(LogOrigin.User);
}
if (showClient) {
matchers.push(LogOrigin.Client);
}
if (showServer) {
matchers.push(LogOrigin.Server);
}
if (showRx) {
matchers.push(LogOrigin.Rx);
}
if (showTx) {
matchers.push(LogOrigin.Tx);
}
if (showPlayback) {
matchers.push(LogOrigin.Playback);
}
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const disableOthers = useCallback((toEnable: LogOrigin) => {
setShowUser(toEnable === LogOrigin.User);
setShowClient(toEnable === LogOrigin.Client);
setShowServer(toEnable === LogOrigin.Server);
setShowRx(toEnable === LogOrigin.Rx);
setShowTx(toEnable === LogOrigin.Tx);
setShowPlayback(toEnable === LogOrigin.Playback);
}, []);
/** middle click on an origin shows only that origin */
const showOnlyOrigin = useCallback((origin: LogOrigin) => {
setOrigins(new Set([origin]));
}, []);
const searchTerm = search.toLowerCase();
const filteredData = logData.filter((entry) => {
if (!origins.has(entry.origin)) return false;
if (onlyProblems && entry.level === LogLevel.Info) return false;
if (searchTerm && !entry.text.toLowerCase().includes(searchTerm)) return false;
return true;
});
const isFiltered = filteredData.length !== logData.length;
return (
<>
<Panel.InlineElements className={style.buttonBar}>
{allOrigins.map((origin) => (
<Button
key={origin}
variant={origins.has(origin) ? 'primary' : 'subtle'}
size='small'
onClick={() => toggleOrigin(origin)}
onAuxClick={() => showOnlyOrigin(origin)}
onContextMenu={(event) => event.preventDefault()}
>
{origin}
</Button>
))}
<Button
variant={onlyProblems ? 'primary' : 'subtle'}
variant={showUser ? 'primary' : 'subtle'}
size='small'
onClick={() => setOnlyProblems((previous) => !previous)}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
Issues only
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
</Panel.InlineElements>
<Panel.InlineElements className={style.buttonBar}>
<Input fluid placeholder='Filter messages' value={search} onChange={(event) => setSearch(event.target.value)} />
<span className={style.count}>
{isFiltered ? `${filteredData.length} of ${logData.length}` : logData.length} entries
</span>
</Panel.InlineElements>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
@@ -93,9 +118,6 @@ export default function Log() {
<span className={style.msg}>{logEntry.text}</span>
</li>
))}
{filteredData.length === 0 && (
<li className={style.empty}>{logData.length === 0 ? 'No activity yet' : 'No entries match the filters'}</li>
)}
</ul>
</>
);
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
expect(calculatedFinish).toBe(10);
});
describe('on timers of type time-to-end', () => {
it('finish time is as schedule + added time', () => {
it('finish time is the fixed end, ignoring added time', () => {
const state = {
eventNow: {
timeEnd: 30,
@@ -245,6 +245,29 @@ describe('getExpectedFinish()', () => {
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(40);
});
it('finish time is the fixed end, including paused time', () => {
const state = {
eventNow: {
timeEnd: 30,
countToEnd: true,
},
clock: 25,
timer: {
addedTime: 0,
duration: dayInMs,
startedAt: 10,
},
_timer: {
pausedAt: 20, // paused 5 ago
hasFinished: false,
},
} as RuntimeState;
const calculatedFinish = getExpectedFinish(state);
expect(calculatedFinish).toBe(35);
});
it('handles events that finish the day after', () => {
const state = {
eventNow: {
@@ -404,6 +427,9 @@ describe('getCurrent()', () => {
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -429,6 +455,9 @@ describe('getCurrent()', () => {
it('current time is the time to end', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
dayOffset: 0,
timeEnd: 100,
countToEnd: true,
},
@@ -451,10 +480,13 @@ describe('getCurrent()', () => {
expect(current).toBe(70);
});
it('current time is the time to end + added time', () => {
it('current time is the time to end, ignoring added time', () => {
const state = {
eventNow: {
timeStart: 0,
duration: 100,
timeEnd: 100,
dayOffset: 0,
countToEnd: true,
},
clock: 30,
@@ -480,8 +512,10 @@ describe('getCurrent()', () => {
const state = {
eventNow: {
timeStart: 79200000, // 22:00:00
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
timeEnd: 600000, // 00:10:00
countToEnd: true,
dayOffset: 0,
},
clock: 79500000, // 22:05:00
timer: {
@@ -492,6 +526,7 @@ describe('getCurrent()', () => {
rundown: {
actualStart: 79200000,
plannedEnd: 600000,
currentDay: 0,
},
_timer: {
pausedAt: null,
@@ -503,6 +538,36 @@ describe('getCurrent()', () => {
expect(current).toBe(dayInMs - 79500000 + 600000);
});
it('handles overnight count-to-end after midnight', () => {
const state = {
eventNow: {
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
duration: 2 * MILLIS_PER_HOUR, // 2h
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
timer: {
addedTime: 0,
duration: Infinity, // not relevant,
startedAt: 23 * MILLIS_PER_HOUR,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedEnd: 1 * MILLIS_PER_HOUR,
currentDay: 1,
},
_timer: {
pausedAt: null,
hasFinished: false,
},
} as RuntimeState;
const current = getCurrent(state);
expect(current).toBe(30 * MILLIS_PER_MINUTE);
});
it('handles events that were started late', () => {
const state = {
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
@@ -511,6 +576,7 @@ describe('getCurrent()', () => {
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
countToEnd: true,
dayOffset: 0,
},
timer: {
addedTime: 0,
@@ -1073,7 +1139,7 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(0);
});
it('with time-to-end, offset is the overtime', () => {
it('with time-to-end, offset combines overtime and added time', () => {
const state = {
clock: 82000000, // 22:46:40
eventNow: {
@@ -1126,7 +1192,45 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
// overtime (400000) plus the operator's added time (-200000)
expect(absolute).toBe(200000);
});
it('with time-to-end', () => {
const state = {
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
eventNow: {
id: 'd6a2ce',
timeStart: 77400000, // 21:30:00
timeEnd: 81000000, // 22:30:00
duration: 3600000, // 01:00:00
timeStrategy: TimeStrategy.LockEnd,
countToEnd: true,
dayOffset: 0,
delay: 0,
},
rundown: {
plannedStart: 77400000, // 21:30:00
plannedEnd: 81000000, // 22:30:00
actualStart: 78000000, // 21:40:00
currentDay: 0,
},
offset: {
absolute: 0,
},
timer: {
addedTime: 300000, // operator added 5 minutes
current: 1000000, // still counting down, no overtime
duration: 3600000,
startedAt: 78000000,
},
_startDayOffset: 0,
_timer: { pausedAt: null },
} as RuntimeState;
// the end is anchored, so the added 5 minutes shows up as offset
const { absolute } = getRuntimeOffset(state);
expect(absolute).toBe(300000);
});
it('handles time-to-end started after the end time', () => {
+12 -12
View File
@@ -62,34 +62,34 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
*/
export function getCurrent(state: RuntimeState): number {
const { timer, eventNow } = state;
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (state.eventNow === null || state.timer.duration === null) {
if (eventNow === null || timer.duration === null) {
throw new Error('timerUtils.getCurrent: invalid state received');
}
}
const { startedAt, duration, addedTime } = state.timer;
const { countToEnd, timeStart, timeEnd } = state.eventNow;
const { pausedAt } = state._timer;
const { clock } = state;
if (countToEnd) {
const isEventOverMidnight = timeStart > timeEnd;
const correctDay = isEventOverMidnight ? dayInMs : 0;
return correctDay - clock + timeEnd + addedTime;
if (eventNow.countToEnd) {
const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs;
const eventDayOffset = eventNow.dayOffset * dayInMs;
return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected;
}
if (startedAt === null) {
return duration;
if (timer.startedAt === null) {
return timer.duration;
}
if (pausedAt != null) {
return startedAt + duration + addedTime - pausedAt;
return timer.startedAt + timer.duration + timer.addedTime - pausedAt;
}
const hasPassedMidnight = startedAt > clock;
const hasPassedMidnight = timer.startedAt > clock;
const correctDay = hasPassedMidnight ? dayInMs : 0;
return startedAt + duration + addedTime - clock - correctDay;
return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay;
}
/**