feat: allow naming aux timers

This commit is contained in:
Carlos Valente
2026-08-25 21:45:58 +02:00
parent b9683f00dc
commit be99fbdcb9
36 changed files with 482 additions and 16 deletions
@@ -90,4 +90,14 @@ describe('test parseDatabaseModel() edge cases', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
it('creates the aux timer names when importing a project file which predates the feature', () => {
const oldProject = structuredClone(demoDb);
// @ts-expect-error -- simulating a project file saved before aux timer naming existed
delete oldProject.settings.auxTimerNames;
const { data } = parseDatabaseModel(oldProject);
expect(data.settings.auxTimerNames).toStrictEqual(['', '', '']);
});
});
@@ -74,7 +74,15 @@ export function migrateSettings(jsonData: object): (Settings & { serverPort: num
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
return {
version: '4.0.0',
serverPort,
editorKey,
operatorKey,
timeFormat,
language,
auxTimerNames: ['', '', ''],
};
}
}
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { is } from '../../../utils/is.js';
@@ -23,6 +24,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
const auxTimerNames = sanitiseAuxTimerNames(settings?.auxTimerNames);
const version = '4.5.0';
db.settings = {
version,
@@ -30,6 +32,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
operatorKey,
timeFormat,
language,
auxTimerNames,
app: 'ontime',
} as Settings;
return { db, serverPort: settings?.serverPort };
@@ -184,6 +184,7 @@ describe('v3 to v4', () => {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
};
const newSettings = v3.migrateSettings(oldDb);
expect(newSettings).toEqual(expectSettings);
@@ -16,6 +16,36 @@ describe('parseSettings()', () => {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
});
});
it('carries custom aux timer names through and pads to a length-3 array', () => {
const result = parseSettings({
settings: { version: '1', auxTimerNames: ['Speaker'] } as unknown as Settings,
});
expect(result.auxTimerNames).toStrictEqual(['Speaker', '', '']);
});
it('falls back to defaults when aux timer names are missing or malformed', () => {
const result = parseSettings({
settings: { version: '1', auxTimerNames: 'not-an-array' } as unknown as Settings,
});
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
});
it('creates the aux timer names for project files made before the feature existed', () => {
const oldSettings = {
version: '4.5.0',
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
};
const result = parseSettings({ settings: oldSettings as Settings });
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
});
});
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { getPartialProject } from '../../models/dataModel.js';
@@ -22,5 +23,7 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
language: data.settings.language ?? defaultSettings.language,
// older project files predate this property
auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames),
};
}
@@ -9,6 +9,7 @@ import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { portManager } from '../../classes/port-manager/PortManager.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { auxTimerService } from '../../services/aux-timer-service/AuxTimerService.js';
import { validateSettings, validateWelcomeDialog, validateServerPort } from './settings.validation.js';
export const router: Router = express.Router();
@@ -41,6 +42,10 @@ router.post('/', validateSettings, async (req: Request, res: Response<Settings |
if (!deepEqual(data, settings)) {
await getDataProvider().setSettings(data);
// keep the runtime aux timers in sync so consumers get the new names live
if (!deepEqual(data.auxTimerNames, settings.auxTimerNames)) {
auxTimerService.loadNames(data.auxTimerNames);
}
sendRefetch(RefetchKey.Settings);
}
@@ -1,4 +1,5 @@
import { body } from 'express-validator';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -27,6 +28,7 @@ export const validateSettings = [
pinValidator('operatorKey'),
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
body('language').isString().trim().notEmpty(),
body('auxTimerNames').isArray().withMessage('auxTimerNames must be an array').customSanitizer(sanitiseAuxTimerNames),
requestValidationFunction,
];
+9
View File
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { LogOrigin, SimpleDirection, SimplePlayback, runtimeStorePlaceholder } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import serverTiming from 'server-timing';
import { oscServer } from './adapters/OscAdapter.js';
@@ -25,6 +26,7 @@ import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
import { auxTimerService } from './services/aux-timer-service/AuxTimerService.js';
import * as messageService from './services/message-service/message.service.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
import { restoreService } from './services/restore-service/restore.service.js';
@@ -203,6 +205,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
* Module initialises the services and provides initial payload for the store
*/
const state = getState();
const [auxName1, auxName2, auxName3] = sanitiseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
eventStore.init({
clock: state.clock,
timer: state.timer,
@@ -218,22 +221,28 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName1,
},
auxtimer2: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName2,
},
auxtimer3: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: auxName3,
},
ping: 1,
});
// AuxTimerService owns its own SimpleTimer instances, so the store above doesn't update them
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
// initialise message service
messageService.init(eventStore.set, eventStore.get);
@@ -81,6 +81,7 @@ describe('safeMerge', () => {
editorKey: null,
timeFormat: baseDb.settings.timeFormat,
language: 'pt',
auxTimerNames: baseDb.settings.auxTimerNames,
});
});
@@ -6,6 +6,7 @@ export class SimpleTimer {
current: 0,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
name: '',
};
private startedAt: number | null = null;
private pausedAt: number | null = null;
@@ -23,9 +24,16 @@ export class SimpleTimer {
current: 0,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
// the name is a persisted configuration, independent of the timer runtime
name: this.state.name,
};
}
public setName(name: string): SimpleTimerState {
this.state.name = name;
return this.state;
}
/**
* Sets the duration of the timer
* @param time - time in milliseconds
@@ -16,6 +16,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Stop,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -27,6 +28,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -38,6 +40,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime - 100,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
};
expect(newState).toStrictEqual(expected);
@@ -58,6 +61,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime - 1500,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Pause,
name: '',
};
expect(newState).toStrictEqual(expected);
@@ -83,6 +87,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Stop,
name: '',
};
expect(newState).toStrictEqual(expected);
});
@@ -97,6 +102,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
};
newState = timer.update(100);
@@ -127,6 +133,7 @@ describe('SimpleTimer count-down', () => {
current: 1000,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(100);
@@ -135,6 +142,7 @@ describe('SimpleTimer count-down', () => {
current: initialTime + 100,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(500);
@@ -143,6 +151,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.setDirection(SimpleDirection.CountDown, 600);
@@ -151,6 +160,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(700);
@@ -159,6 +169,7 @@ describe('SimpleTimer count-down', () => {
current: 1400,
direction: SimpleDirection.CountDown,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.setDirection(SimpleDirection.CountUp, 700);
@@ -167,6 +178,7 @@ describe('SimpleTimer count-down', () => {
current: 1400,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
newState = timer.update(800);
@@ -175,6 +187,7 @@ describe('SimpleTimer count-down', () => {
current: 1500,
direction: SimpleDirection.CountUp,
playback: SimplePlayback.Start,
name: '',
});
});
+1
View File
@@ -30,6 +30,7 @@ const dbModel: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
},
viewSettings: {
overrideStyles: false,
+1
View File
@@ -29,6 +29,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
},
viewSettings: {
dangerColor: '#ff7300',
@@ -1,4 +1,5 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { sanitiseAuxTimerNames } from 'ontime-utils';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { timerConfig } from '../../setup/config.js';
@@ -24,6 +25,20 @@ export class AuxTimerService {
this.getTime = getTime;
}
/**
* Called at bootstrap and whenever the loaded project's settings change,
* so the running timers reflect the current project's aux timer names.
*/
loadNames(names?: string[]) {
const [name1, name2, name3] = sanitiseAuxTimerNames(names);
const patch: AuxTimerStateUpdate = {
auxtimer1: this.aux1.setName(name1),
auxtimer2: this.aux2.setName(name2),
auxtimer3: this.aux3.setName(name3),
};
this.emit(patch);
}
/**
* Whether any of the aux timers are currently running
*/
@@ -0,0 +1,53 @@
import { RuntimeStore } from 'ontime-types';
import { AuxTimerService } from '../AuxTimerService.js';
describe('AuxTimerService', () => {
describe('loadNames()', () => {
it('applies the names to each aux timer and broadcasts them', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['Speaker', 'Break', 'Q&A']);
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('Speaker');
expect(patch.auxtimer2?.name).toBe('Break');
expect(patch.auxtimer3?.name).toBe('Q&A');
});
it('defaults missing names to an empty string', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['only-one']);
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('only-one');
expect(patch.auxtimer2?.name).toBe('');
expect(patch.auxtimer3?.name).toBe('');
});
it('handles names missing from a project file', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
expect(() => service.loadNames(undefined)).not.toThrow();
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
expect(patch.auxtimer1?.name).toBe('');
expect(patch.auxtimer2?.name).toBe('');
expect(patch.auxtimer3?.name).toBe('');
});
it('keeps the name on the timer through subsequent commands', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
service.loadNames(['Speaker', '', '']);
const started = service.start(1);
expect(started.name).toBe('Speaker');
});
});
});
@@ -1,9 +1,10 @@
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -28,6 +29,7 @@ import {
removeFileExtension,
} from '../../utils/fileManagement.js';
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
import { runtimeService } from '../runtime-service/runtime.service.js';
import {
doesProjectExist,
@@ -88,12 +90,16 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
// stop the runtime service
runtimeService.stop();
// AuxTimerService holds its own state, independent of the loaded project, so it needs to be updated explicitly
auxTimerService.loadNames(projectData.settings.auxTimerNames);
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
? projectData.rundowns[rundownId]
: getFirstRundown(projectData.rundowns);
// initialising the rundown with reload sends a refetch to the clients
await initRundown(rundown, projectData.customFields, true);
// persist the project selection
@@ -346,6 +352,12 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
// AuxTimerService holds its own state, so a settings patch needs to be applied to it explicitly
if (rest.settings) {
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
sendRefetch(RefetchKey.Settings);
}
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {