Compare commits

..

3 Commits

Author SHA1 Message Date
alex-arc 6d79764ffe fix: pause timer over midnight 2026-08-09 16:46:31 +02:00
Claude a67dbd8a59 test(timer): assert elapsed stays frozen while paused over midnight
Make the midnight pause test's intent explicit: elapsed is active time
since start and must not advance during a pause (even one crossing
midnight). Add a frozen-elapsed assertion while paused and keep
pausedDuration - the corrupted pause count - as the headline assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-08-09 16:46:31 +02:00
Claude 361b6eb875 test(timer): expose pause-over-midnight duration bug
Pause is tracked as pausedAt (TimeOfDay, ms since local midnight) and
paused duration is derived via the naive `clock - pausedAt`. When a pause
spans midnight the clock has wrapped to a small value while pausedAt is
still large, so the subtraction goes negative and every paused-duration
result is corrupted (runtimeState.start resume accumulation, and
getExpectedFinish/getCurrent/getRuntimeOffset in timerUtils).

Add two currently-failing tests that reproduce this:
- runtimeState: full start/pause/resume cycle where the pause crosses
  midnight, asserting pausedDuration and elapsed exclude the pause.
- timerUtils.getRuntimeOffset: over-midnight variant of the paused-offset
  case (the site carrying the "brakes when crossing midnight" TODO).

Both fail today (report ~ -86,100,000 instead of the real 5-minute pause)
and will pass once the pause math adopts the wrap-aware primitives
(timeCore.elapsedTime / epoch-based tracking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136N3FnyuUmLJbMNJiZd6YX
2026-08-09 16:46:31 +02:00
14 changed files with 201 additions and 242 deletions
@@ -200,7 +200,8 @@ $card-padding: 2rem;
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
inset: 0; width: 100%;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -3,8 +3,6 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
@@ -20,12 +18,7 @@ export default function AppVersion() {
return ( return (
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title={ title={`Ontime ${appVersion}`}
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={ description={
isOntimeCloud isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.` ? `Version ${data.version} is available. Restart your stage to update.`
@@ -33,7 +26,7 @@ export default function AppVersion() {
} }
/> />
{!isOntimeCloud && ( {!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink> <ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
)} )}
</Panel.ListItem> </Panel.ListItem>
); );
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Loader isLoading={status === 'pending'} />
{rootError && <Panel.Error>{rootError}</Panel.Error>} {rootError && <Panel.Error>{rootError}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{data.pendingRestart && ( {data.pendingRestart && (
<Info type='warning'>A port change is pending and will happen on the next restart.</Info> <Info type='warning'>A port change is pending and will happen on the next restart.</Info>
)} )}
+1 -13
View File
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
submenu: [ submenu: [
{ {
label: 'New project...', label: 'New project...',
click: () => redirectWindow('/editor?settings=project__create'), click: () => redirectWindow('/editor?settings=project__manage&new=true'),
}, },
{ {
label: 'Load...', label: 'Load...',
@@ -202,18 +202,6 @@ function makeSettingsMenu(redirectWindow) {
label: 'View settings', label: 'View settings',
click: () => redirectWindow('/editor?settings=settings__view'), click: () => redirectWindow('/editor?settings=settings__view'),
}, },
{
label: 'Custom views',
click: () => redirectWindow('/editor?settings=settings__custom-views'),
},
{
label: 'MCP Server',
click: () => redirectWindow('/editor?settings=settings__mcp'),
},
{
label: 'Server port',
click: () => redirectWindow('/editor?settings=settings__port'),
},
], ],
}, },
{ {
+21
View File
@@ -0,0 +1,21 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js'; import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js'; import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js'; import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services // Services
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
// serve static external files // serve static external files
app.use( app.use(
@@ -1,35 +1,6 @@
import type { IncomingMessage } from 'node:http'; import { describe, expect, it } from 'vitest';
import type { NextFunction, Request, Response } from 'express'; import { isPublicAssetRequest } from '../authenticate.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import {
authenticateSocket,
isPublicAssetRequest,
makeAuthenticateMiddleware,
} from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
describe('isPublicAssetRequest()', () => { describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => { it('allows root public assets without a prefix', () => {
@@ -47,102 +18,3 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false); expect(isPublicAssetRequest('/backstage', '')).toBe(false);
}); });
}); });
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
+48 -55
View File
@@ -77,16 +77,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`; const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) { function authenticate(req: Request, res: Response, next: NextFunction) {
if (getTokenFromCookies(req.cookies) === hashedPassword) { if (req.query.token) {
return next(); if (req.query.token === hashedPassword) {
return next();
}
} }
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) { if (req.cookies?.token) {
return next(); const tokenFromCookie = getTokenFromCookie(req.cookies.token);
} if (tokenFromCookie === hashedPassword) {
return next();
if (getTokenFromParams(req.query) === hashedPassword) { }
return next();
} }
res.status(401).send('Unauthorized'); res.status(401).send('Unauthorized');
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next(); return next();
} }
if (getTokenFromCookies(req.cookies) === hashedPassword) { // we expect the token to be in the cookies
return next(); if (req.cookies?.token) {
} const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) { return next();
return next(); }
} }
// we use query params for generating authenticated URLs and for clients like the companion module // we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests // if the user gives is a token in the query params, we set the cookie to be used in further requests
if (getTokenFromParams(req.query) === hashedPassword) { if (req.query.token === hashedPassword) {
if (hashedPassword !== undefined) { if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix); setSessionCookie(res, hashedPassword, prefix);
} }
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next(); return next();
} }
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) { // check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
return next(); return next();
} }
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) { // fallback to full URL parsing for other formats
return next(); try {
} const url = new URL(urlString, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) { if (token === hashedPassword) {
return next(); return next();
}
} catch (_) {
// ignore URL parsing errors
} }
return next(new Error('Unauthorized')); return next(new Error('Unauthorized'));
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
}); });
} }
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined { /**
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token; * When calling this function we already know a cookie called 'token' exists
if (typeof cookieContents !== 'string') { * And want to extract its value
return undefined; */
} function getTokenFromCookie(cookieContents: string): string | undefined {
// Fast path: check if the hashed password is directly in the cookie string
// Fast path: avoid JSON parsing when the expected token can be found directly // This avoids JSON parsing for the common case
const cookieTokenString = '"token":"' + hashedPassword + '}"'; const cookieTokenString = '"token":"' + hashedPassword + '}"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) { if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword; return hashedPassword;
} }
// Fallback to JSON parsing for other cases or validation
try { try {
const cookie = JSON.parse(cookieContents); const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') { if (cookie && typeof cookie.token === 'string') {
@@ -184,29 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
// no error handling to do here // no error handling to do here
} }
} }
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
if (authorization?.startsWith('Bearer ')) {
return authorization.slice(7);
}
}
function getTokenFromParams(
params: string | Record<string, unknown> | undefined,
host?: string,
): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
// Fast path for WebSocket URLs
if (params.includes(`token=${hashedPassword}`)) {
return hashedPassword;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}
@@ -1,6 +1,7 @@
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { EndAction, Instant, 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 { RuntimeState } from '../../stores/runtimeState.js';
import { import {
findDayOffset, findDayOffset,
@@ -53,11 +54,12 @@ describe('getElapsed()', () => {
it('uses the current pause start while paused', () => { it('uses the current pause start while paused', () => {
const state = { const state = {
clock: 10 * MILLIS_PER_MINUTE, clock: 10 * MILLIS_PER_MINUTE,
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: { timer: {
startedAt: 2 * MILLIS_PER_MINUTE, startedAt: 2 * MILLIS_PER_MINUTE,
}, },
_timer: { _timer: {
pausedAt: 7 * MILLIS_PER_MINUTE, pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
pausedDuration: 1 * MILLIS_PER_MINUTE, pausedDuration: 1 * MILLIS_PER_MINUTE,
}, },
} as RuntimeState; } as RuntimeState;
@@ -975,6 +977,40 @@ describe('getRuntimeOffset()', () => {
expect(absolute).toBe(25); expect(absolute).toBe(25);
}); });
it('paused time is delayed time when the pause spans midnight', () => {
const state = {
eventNow: {
id: '1',
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
dayOffset: 0,
},
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
timer: {
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
current: 25, // still counting down
addedTime: 0,
},
_timer: {
pausedAt: timeCore.toInstant(
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
(timeCore.now() - dayInMs) as Instant,
), // 23:58, before midnight
pausedDuration: 0,
},
rundown: {
actualStart: 23 * MILLIS_PER_HOUR,
plannedStart: 23 * MILLIS_PER_HOUR,
currentDay: 0,
},
_startDayOffset: 0,
} as RuntimeState;
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
});
it('offset doesnt exist if we havent started', () => { it('offset doesnt exist if we havent started', () => {
const state = { const state = {
clock: 78480789, clock: 78480789,
+7 -5
View File
@@ -1,6 +1,7 @@
import { Day, MaybeNumber, 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 type { RuntimeState } from '../stores/runtimeState.js';
/** /**
@@ -96,17 +97,18 @@ export function getCurrent(state: RuntimeState): number {
* Calculates active time elapsed since the timer started. * Calculates active time elapsed since the timer started.
*/ */
export function getElapsed(state: RuntimeState): MaybeNumber { export function getElapsed(state: RuntimeState): MaybeNumber {
const { clock } = state; const { clock, _now } = state;
const { startedAt } = state.timer; const { startedAt } = state.timer;
const { pausedAt, pausedDuration } = state._timer; const { pausedDuration, pausedAt } = state._timer;
if (startedAt === null) { if (startedAt === null) {
return null; return null;
} }
const referenceClock = pausedAt ?? clock; const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration; const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
return Math.max(0, activeElapsed); return Math.max(0, activeElapsed);
} }
@@ -1,10 +1,11 @@
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types'; import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js'; import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = { const baseState: RuntimeState = {
clock: 0 as TimeOfDay, clock: 0 as TimeOfDay,
_now: 0 as Instant,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
eventFlag: null, eventFlag: null,
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
playback: Playback.Pause, playback: Playback.Pause,
addedTime: 0, addedTime: 0,
}); });
expect(newState._timer.pausedAt).toEqual(newState.clock); expect(newState._timer.pausedAt).toEqual(newState._now);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
@@ -248,6 +248,59 @@ describe('mutation on runtimeState', () => {
state = getState(); state = getState();
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE); expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
}); });
test('elapsed excludes a pause that spans midnight', async () => {
clearState();
// an event that runs over midnight (23:00 -> 01:00)
const event = {
...mockEvent,
id: 'elapsed-pause-midnight',
timeStart: 23 * MILLIS_PER_HOUR,
timeEnd: 1 * MILLIS_PER_HOUR,
duration: 2 * MILLIS_PER_HOUR,
};
const mockRundown = makeRundown({
entries: { [event.id]: event },
order: [event.id],
});
await initRundown(mockRundown, {});
vi.runAllTimers();
const { metadata, rundown } = rundownCache.get();
// start before midnight
vi.setSystemTime('jan 1 23:50');
load(event, rundown, metadata);
start();
// 8 minutes of active running before we pause
vi.setSystemTime('jan 1 23:58');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
pause();
// elapsed is active time since start, so it must not advance while paused,
// not even when the pause itself crosses midnight
vi.setSystemTime('jan 2 00:01');
update();
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
vi.setSystemTime('jan 2 00:03');
start();
let state = getState();
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
// and elapsed still reflects only the 8 active minutes
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
// 2 more active minutes after resume -> 10 minutes elapsed
vi.setSystemTime('jan 2 00:05');
update();
state = getState();
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
});
}); });
test('runtime offset', async () => { test('runtime offset', async () => {
+21 -16
View File
@@ -63,7 +63,9 @@ export type RuntimeState = {
// private properties of the timer calculations // private properties of the timer calculations
_timer: { _timer: {
forceFinish: Maybe<TimeOfDay>; // 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<Instant>;
/** Accumulate pause duration but dose not include the current pause */
pausedDuration: number; pausedDuration: number;
secondaryTarget: Maybe<TimeOfDay>; secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean; hasFinished: boolean;
@@ -76,10 +78,12 @@ export type RuntimeState = {
_end: ExpectedMetadata; _end: ExpectedMetadata;
_startEpoch: Maybe<Instant>; _startEpoch: Maybe<Instant>;
_startDayOffset: Maybe<Day>; _startDayOffset: Maybe<Day>;
_now: Instant;
}; };
const runtimeState: RuntimeState = { const runtimeState: RuntimeState = {
clock: timeCore.timeOfDayNow(), clock: timeCore.timeOfDayNow(),
_now: timeCore.now(),
groupNow: null, groupNow: null,
eventNow: null, eventNow: null,
eventNext: null, eventNext: null,
@@ -104,6 +108,12 @@ const runtimeState: RuntimeState = {
_startDayOffset: null, _startDayOffset: null,
}; };
/** set the current clock to ensure parity between _now and clock */
function setClock(state: RuntimeState) {
state._now = timeCore.now();
state.clock = timeCore.toTimeOfDay(state._now);
}
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 {
@@ -136,7 +146,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null; runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow(); setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.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
@@ -169,7 +179,7 @@ export function clearState() {
runtimeState._end = null; runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop; runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeCore.timeOfDayNow(); setClock(runtimeState);
runtimeState.timer = { ...runtimeStorePlaceholder.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
@@ -422,15 +432,12 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false; return false;
} }
const epoch = timeCore.now(); setClock(state);
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 = state.clock - state._timer.pausedAt; const timeToAdd = state._now - state._timer.pausedAt;
state.timer.addedTime += timeToAdd; state.timer.addedTime += timeToAdd;
state._timer.pausedDuration += timeToAdd; state._timer.pausedDuration += timeToAdd;
state._timer.pausedAt = null; state._timer.pausedAt = null;
@@ -447,7 +454,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.rundown.actualStart === null) { if (state.rundown.actualStart === null) {
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day; state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
state.rundown.currentDay = state._startDayOffset; state.rundown.currentDay = state._startDayOffset;
state._startEpoch = epoch; state._startEpoch = state._now;
state.rundown.actualStart = state.clock; state.rundown.actualStart = state.clock;
} }
@@ -481,8 +488,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
} }
state.timer.playback = Playback.Pause; state.timer.playback = Playback.Pause;
state.clock = timeCore.timeOfDayNow(); setClock(state);
state._timer.pausedAt = state.clock; state._timer.pausedAt = state._now;
return true; return true;
} }
@@ -547,9 +554,7 @@ 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 = runtimeState.clock; const previousClock = runtimeState.clock;
const epoch = timeCore.now(); setClock(runtimeState); // we update the clock on every update call
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)) {
@@ -558,13 +563,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, epoch); const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
runtimeState.rundown.currentDay = 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, now); const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
return updateIfWaitingToRoll(clockHasCrossedMidnight); return updateIfWaitingToRoll(clockHasCrossedMidnight);
} }