fix: resync aux timer names on project load, consolidate name handling

Addresses the review findings on the aux timer naming feature.

- Aux timer names are project data, but they were only applied at
  bootstrap and on a settings POST. Loading another project left the
  previous project's names in the runtime store, so the controls and
  views disagreed with the settings panel until a restart. The names are
  now applied when a project is loaded, and patching the current project
  settings applies them and sends a settings refetch to the clients
  (loading a project already triggers a full refetch via the rundown).
- Consolidate the name handling into a single normaliser in ontime-utils,
  used by the project file parser, the API validation and to build the
  default value. This creates the property when importing project files
  saved before the feature existed, and enforces the name length limit
  server side rather than only in the form.
- loadNames normalises its input, so the runtime state is consistent
  regardless of the shape of the stored settings.
- Clarify that auxTimerNames is ordered (index 0 is aux timer 1), and
  derive the settings form from the shared aux timer count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
This commit is contained in:
Claude
2026-07-30 17:31:27 +00:00
parent 2f29060fa4
commit 38e3ab979d
19 changed files with 163 additions and 37 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(['', '', '']);
});
});
@@ -23,6 +23,7 @@ import {
customFieldLabelToKey,
eventDef as eventModel,
isKnownTimerType,
normaliseAuxTimerNames,
validateEndAction,
} from 'ontime-utils';
@@ -74,7 +75,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, auxTimerNames: ['', '', ''] };
return {
version: '4.0.0',
serverPort,
editorKey,
operatorKey,
timeFormat,
language,
auxTimerNames: normaliseAuxTimerNames(),
};
}
}
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { normaliseAuxTimerNames } from 'ontime-utils';
import { is } from '../../../utils/is.js';
@@ -23,7 +24,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
const auxTimerNames = settings?.auxTimerNames ?? ['', '', ''];
const auxTimerNames = normaliseAuxTimerNames(settings?.auxTimerNames);
const version = '4.5.0';
db.settings = {
version,
@@ -33,4 +33,21 @@ describe('parseSettings()', () => {
});
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
});
it('creates the aux timer names for project files made before the feature existed', () => {
// a settings object as found in a project file which predates aux timer naming
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(['', '', '']);
// the rest of the settings are untouched
expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
});
});
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
import { normaliseAuxTimerNames } from 'ontime-utils';
import { getPartialProject } from '../../models/dataModel.js';
@@ -22,18 +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,
auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames, defaultSettings.auxTimerNames),
// property added in v4.6.0, older project files will not contain it
auxTimerNames: normaliseAuxTimerNames(data.settings.auxTimerNames),
};
}
/**
* Ensures the aux timer names are a fixed-length array of strings
* regardless of what is found in the file
*/
function sanitiseAuxTimerNames(maybeNames: unknown, fallback: string[]): string[] {
const source = Array.isArray(maybeNames) ? maybeNames : [];
return fallback.map((defaultName, index) => {
const value = source[index];
return typeof value === 'string' ? value : defaultName;
});
}
@@ -1,4 +1,5 @@
import { body } from 'express-validator';
import { normaliseAuxTimerNames } from 'ontime-utils';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -30,11 +31,8 @@ export const validateSettings = [
body('auxTimerNames')
.isArray()
.withMessage('auxTimerNames must be an array')
.customSanitizer((value: unknown) => {
// normalise to a fixed-length array of trimmed strings
const source = Array.isArray(value) ? value : [];
return [0, 1, 2].map((index) => (typeof source[index] === 'string' ? source[index].trim() : ''));
}),
// normalise to a fixed length array of trimmed, length capped strings
.customSanitizer(normaliseAuxTimerNames),
requestValidationFunction,
];
+2 -1
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 { normaliseAuxTimerNames } from 'ontime-utils';
import serverTiming from 'server-timing';
import { oscServer } from './adapters/OscAdapter.js';
@@ -205,7 +206,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 { auxTimerNames } = getDataProvider().getSettings();
const auxTimerNames = normaliseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
eventStore.init({
clock: state.clock,
timer: state.timer,
+2 -2
View File
@@ -1,5 +1,5 @@
import { DatabaseModel, Rundown } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { generateId, normaliseAuxTimerNames } from 'ontime-utils';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
@@ -30,7 +30,7 @@ const dbModel: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
auxTimerNames: normaliseAuxTimerNames(),
},
viewSettings: {
overrideStyles: false,
+2 -1
View File
@@ -1,4 +1,5 @@
import { DatabaseModel, OntimeView } from 'ontime-types';
import { normaliseAuxTimerNames } from 'ontime-utils';
import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js';
@@ -29,7 +30,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
auxTimerNames: ['', '', ''],
auxTimerNames: normaliseAuxTimerNames(),
},
viewSettings: {
dangerColor: '#ff7300',
@@ -1,4 +1,5 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { normaliseAuxTimerNames } from 'ontime-utils';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { timerConfig } from '../../setup/config.js';
@@ -26,14 +27,16 @@ export class AuxTimerService {
/**
* Applies custom names to the aux timers and broadcasts the change.
* Names are indexed by aux timer position (0 -> aux1, 1 -> aux2, 2 -> aux3).
* Used to seed the names at bootstrap and to keep them in sync with the settings.
* Names are given in aux timer order (index 0 is aux timer 1).
* Used to seed the names at bootstrap and to keep them in sync
* with the settings of the loaded project.
*/
loadNames(names: string[]) {
loadNames(names?: string[]) {
const [name1, name2, name3] = normaliseAuxTimerNames(names);
const patch: AuxTimerStateUpdate = {
auxtimer1: this.aux1.setName(names[0] ?? ''),
auxtimer2: this.aux2.setName(names[1] ?? ''),
auxtimer3: this.aux3.setName(names[2] ?? ''),
auxtimer1: this.aux1.setName(name1),
auxtimer2: this.aux2.setName(name2),
auxtimer3: this.aux3.setName(name3),
};
this.emit(patch);
}
@@ -28,6 +28,18 @@ describe('AuxTimerService', () => {
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);
@@ -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();
// the aux timer names belong to the project, apply the ones from the newly loaded project
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);
// the settings may contain new aux timer names, apply them and notify the clients
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) {