feat: create branded types to determine different handling of time

This commit is contained in:
Carlos Valente
2026-02-16 06:51:43 +01:00
committed by Carlos Valente
parent 168d7103b1
commit e8f159d894
13 changed files with 315 additions and 41 deletions
@@ -0,0 +1,185 @@
import { dayInMs, millisToString } from 'ontime-utils';
import { Duration, Instant } from 'ontime-types';
import { timeNow } from '../../../utils/time.js';
import * as timeCore from '../timeCore.js';
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
// TZ is set to Europe/Copenhagen in vitest.global-setup.ts
// Copenhagen is UTC+1 (CET) in winter and UTC+2 (CEST) in summer
describe('toTimeofDay() converts an instant to local milliseconds since midnight', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('produces the correct local time for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBe(timeNow());
});
it('returns a value in the range [0, dayInMs)', () => {
vi.setSystemTime('2025-06-15T23:59:59.999Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThan(dayInMs);
});
describe('handles DST transitions in Europe/Copenhagen', () => {
it('produces CET time just before spring forward', () => {
// 2025-03-30 at 02:00 CET clocks jump to 03:00 CEST
// UTC 00:58:18 → Copenhagen CET (UTC+1) → local 01:58:18
vi.setSystemTime('2025-03-30T00:58:18Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('01:58:18');
});
it('produces CEST time just after spring forward', () => {
// UTC 01:00:00 → Copenhagen CEST (UTC+2) → local 03:00:00
// 02:00 local does not exist, clocks skip to 03:00
vi.setSystemTime('2025-03-30T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('03:00:00');
});
it('produces CEST time just before fall back', () => {
// 2025-10-26 at 03:00 CEST clocks fall back to 02:00 CET
// UTC 00:59:59 → Copenhagen CEST (UTC+2) → local 02:59:59
vi.setSystemTime('2025-10-26T00:59:59Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:59:59');
});
it('produces CET time just after fall back', () => {
// UTC 01:00:00 → Copenhagen CET (UTC+1) → local 02:00:00
vi.setSystemTime('2025-10-26T01:00:00Z');
const result = timeCore.toTimeOfDay(timeCore.now());
expect(millisToString(result)).toBe('02:00:00');
});
});
});
describe('toInstant() converts a time of day back to an instant anchored to a reference day', () => {
const testTimes = [
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
];
test.each(testTimes)('roundtrips through toTimeofDay for $label ($time)', ({ time }) => {
vi.setSystemTime(time);
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
describe('roundtrips through DST transitions in Europe/Copenhagen', () => {
it('roundtrips just before spring forward', () => {
vi.setSystemTime('2025-03-30T00:58:18Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after spring forward', () => {
vi.setSystemTime('2025-03-30T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just before fall back', () => {
vi.setSystemTime('2025-10-26T00:59:59Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
it('roundtrips just after fall back', () => {
vi.setSystemTime('2025-10-26T01:00:00Z');
const instant = timeCore.now();
const clock = timeCore.toTimeOfDay(instant);
expect(timeCore.toInstant(clock, instant)).toBe(instant);
});
});
});
describe('timeSince() returns the duration elapsed since a past point', () => {
it('measures elapsed time between two instants', () => {
const start = 1000 as Instant;
const end = 5000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(4000);
});
it('returns negative when the reference is in the future', () => {
const start = 5000 as Instant;
const end = 1000 as Instant;
expect(timeCore.timeSince(end, start)).toBe(-4000);
});
});
describe('timeUntil() returns the duration until a future point', () => {
it('measures time remaining until a future instant', () => {
const current = 1000 as Instant;
const target = 5000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(4000);
});
it('returns negative when the target is in the past', () => {
const current = 5000 as Instant;
const target = 1000 as Instant;
expect(timeCore.timeUntil(current, target)).toBe(-4000);
});
});
describe('addDuration() moves a point in time by a duration', () => {
it('moves an instant forward', () => {
const instant = 1000 as Instant;
const duration = 500 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(1500);
});
it('moves backward with a negative duration', () => {
const instant = 1000 as Instant;
const duration = -300 as Duration;
expect(timeCore.addDuration(instant, duration)).toBe(700);
});
it('moves by the sum of multiple durations', () => {
const instant = 1000 as Instant;
const durations = [500, -300, 50] as Duration[];
expect(timeCore.addDuration(instant, durations)).toBe(1250);
});
it('keeps the instant unchanged with an empty duration list', () => {
const instant = 1000 as Instant;
expect(timeCore.addDuration(instant, [])).toBe(1000);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { Instant, TimeOfDay, Duration } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
/** Returns the current instant */
export function now(): Instant {
return Date.now() as Instant;
}
/**
* Converts an instant to milliseconds since midnight in the local timezone
* - Accounts for the system's timezone offset including DST
* - Result is always in the range [0, dayInMs)
*/
export function toTimeOfDay(instant: Instant): TimeOfDay {
const tzOffset = new Date(instant).getTimezoneOffset() * MILLIS_PER_MINUTE;
return ((((instant - tzOffset) % dayInMs) + dayInMs) % dayInMs) as TimeOfDay;
}
/**
* Returns the current time of day in milliseconds since midnight
*/
export function timeOfDayNow(): TimeOfDay {
return toTimeOfDay(now());
}
/**
* Converts a time of day to an instant anchored to the same day as the reference
* - Uses the reference instant to determine which calendar day to anchor to
*/
export function toInstant(clock: TimeOfDay, reference: Instant): Instant {
const referenceClock = toTimeOfDay(reference);
const dayStart = reference - referenceClock;
return (dayStart + clock) as Instant;
}
/**
* Returns the duration elapsed since a past instant
* Result is positive when 'since' is before 'now'
*/
export function timeSince(now: Instant, since: Instant): Duration {
return (now - since) as Duration;
}
/**
* Returns the duration remaining until a future instant
* Result is positive when 'until' is after 'now'
*/
export function timeUntil(now: Instant, until: Instant): Duration {
return (until - now) as Duration;
}
/**
* Moves an instant forward or backward by a duration
* Use a negative duration to move backward
*/
export function addDuration(instant: Instant, duration: Duration | Duration[]): Instant {
const totalDuration = Array.isArray(duration) ? duration.reduce((total, current) => total + current, 0) : duration;
return (instant + totalDuration) as Instant;
}
@@ -1,4 +1,4 @@
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
import { Playback, MaybeString, MaybeNumber, Maybe, Instant } from 'ontime-types';
export type RestorePoint = {
playback: Playback;
@@ -7,6 +7,6 @@ export type RestorePoint = {
addedTime: number;
pausedAt: MaybeNumber;
firstStart: MaybeNumber;
startEpoch: MaybeNumber;
startEpoch: Maybe<Instant>;
currentDay: MaybeNumber;
};
@@ -1,9 +1,9 @@
import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
import { TimerPhase, Playback, OffsetMode, type TimeOfDay } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import type { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0,
clock: 0 as TimeOfDay,
eventNow: null,
eventNext: null,
eventFlag: null,
+36 -20
View File
@@ -1,5 +1,6 @@
import {
isOntimeEvent,
Instant,
MaybeNumber,
MaybeString,
OffsetMode,
@@ -10,9 +11,11 @@ import {
Rundown,
Offset,
runtimeStorePlaceholder,
TimeOfDay,
TimerPhase,
TimerState,
RundownState,
Maybe,
} from 'ontime-types';
import {
calculateDuration,
@@ -23,7 +26,6 @@ import {
isPlaybackActive,
} from 'ontime-utils';
import { getTimeObject, timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/restore-service/restore.type.js';
import {
findDayOffset,
@@ -36,11 +38,12 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { timerConfig } from '../setup/config.js';
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
import * as timeCore from '../lib/time-core/timeCore.js';
type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedToLoaded: boolean } | null;
export type RuntimeState = {
clock: number; // realtime clock
clock: TimeOfDay;
groupNow: OntimeGroup | null;
eventNow: PlayableEvent | null;
eventNext: PlayableEvent | null;
@@ -50,9 +53,9 @@ export type RuntimeState = {
rundown: RundownState;
// private properties of the timer calculations
_timer: {
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
pausedAt: MaybeNumber;
secondaryTarget: MaybeNumber;
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
pausedAt: Maybe<TimeOfDay>;
secondaryTarget: Maybe<TimeOfDay>;
hasFinished: boolean;
};
_rundown: {
@@ -61,12 +64,12 @@ export type RuntimeState = {
_group: ExpectedMetadata;
_flag: ExpectedMetadata;
_end: ExpectedMetadata;
_startEpoch: MaybeNumber;
_startEpoch: Maybe<Instant>;
_startDayOffset: MaybeNumber;
};
const runtimeState: RuntimeState = {
clock: timeNow(),
clock: timeCore.timeOfDayNow(),
groupNow: null,
eventNow: null,
eventNext: null,
@@ -122,7 +125,7 @@ export function clearEventData() {
runtimeState.rundown.selectedEventIndex = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow();
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -154,7 +157,7 @@ export function clearState() {
runtimeState._end = null;
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = timeNow();
runtimeState.clock = timeCore.timeOfDayNow();
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
@@ -354,10 +357,13 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
if (runtimeState._timer.secondaryTarget !== null) {
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
// if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
}
}
@@ -403,7 +409,9 @@ export function start(state: RuntimeState = runtimeState): boolean {
return false;
}
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
state.clock = now;
state.timer.secondaryTimer = null;
@@ -459,7 +467,7 @@ export function pause(state: RuntimeState = runtimeState): boolean {
}
state.timer.playback = Playback.Pause;
state.clock = timeNow();
state.clock = timeCore.timeOfDayNow();
state._timer.pausedAt = state.clock;
return true;
}
@@ -494,7 +502,7 @@ export function addTime(amount: number) {
if (willGoNegative && !runtimeState._timer.hasFinished) {
// set finished time so side effects are triggered
runtimeState._timer.forceFinish = timeNow();
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
} else {
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
if (willGoPositive) {
@@ -524,7 +532,8 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now; // we update the clock on every update call
// 1. is playback idle?
@@ -600,10 +609,13 @@ export function update(): UpdateResult {
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
}
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
}
}
@@ -620,7 +632,8 @@ export function roll(
}
// we will need to do some calculations, update the time first
const [epoch, now] = getTimeObject();
const epoch = timeCore.now();
const now = timeCore.toTimeOfDay(epoch);
runtimeState.clock = now;
// 2. if there is an event armed, we use it
@@ -674,7 +687,10 @@ export function roll(
runtimeState._startEpoch = epoch;
}
} else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(
runtimeState.eventNow.timeStart,
offsetClock,
) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending;
}
@@ -720,7 +736,7 @@ export function roll(
// there is nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties
-15
View File
@@ -74,18 +74,3 @@ export function timeNow() {
elapsed += now.getMilliseconds();
return elapsed;
}
/**
* Get current time from system
* @returns [number, number] - [epoch time, milliseconds since midnight]
*/
export function getTimeObject(): [number, number] {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return [now.getTime(), elapsed];
}
+1
View File
@@ -2,6 +2,7 @@
"extends": "../../tsconfig.common.json",
"compilerOptions": {
"target": "esnext",
"composite": true,
"module": "node16",
"moduleResolution": "node16",
"noImplicitReturns": false, //TODO: fix this
+4 -1
View File
@@ -13,6 +13,9 @@
},
"include": [
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.spec.ts"
],
"references": [
{ "path": "./tsconfig.json" }
]
}
+1
View File
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
globalSetup: './vitest.global-setup.ts',
},
});
+3
View File
@@ -0,0 +1,3 @@
export function setup() {
process.env.TZ = 'Europe/Copenhagen';
}
@@ -0,0 +1,15 @@
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
/** A timestamp in milliseconds from epoch */
export type Instant = Branded<number, 'instant'>;
/** A timestamp of milliseconds since midnight today in the set timezone */
export type TimeOfDay = Branded<number, 'time-of-day'>;
/** A duration of milliseconds */
export type Duration = Branded<number, 'duration'>;
/** A day count integer */
export type Day = Branded<number, 'day'>;
+4 -1
View File
@@ -20,6 +20,9 @@ export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/cor
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js';
// ---> Core
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
// ---> Report
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
@@ -125,7 +128,7 @@ export {
isOntimeAction,
isTimerLifeCycle,
} from './utils/guards.js';
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
export type { Maybe, MaybeNumber, MaybeString } from './utils/utils.type.js';
// Colour
export type { RGBColour } from './definitions/Colour.type.js';
+2
View File
@@ -1,2 +1,4 @@
export type MaybeNumber = number | null;
export type MaybeString = string | null;
export type Maybe<T> = T | null;