From e8f159d8945c6112b8306782ac7407e97ad0d69f Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 16 Feb 2026 06:51:43 +0100 Subject: [PATCH] feat: create branded types to determine different handling of time --- .../lib/time-core/__tests__/timeCore.test.ts | 185 ++++++++++++++++++ apps/server/src/lib/time-core/timeCore.ts | 60 ++++++ .../services/restore-service/restore.type.ts | 4 +- .../stores/__mocks__/runtimeState.mocks.ts | 4 +- apps/server/src/stores/runtimeState.ts | 56 ++++-- apps/server/src/utils/time.ts | 15 -- apps/server/tsconfig.json | 1 + apps/server/tsconfig.tests.json | 5 +- apps/server/vite.config.ts | 1 + apps/server/vitest.global-setup.ts | 3 + .../types/src/definitions/core/Temporal.ts | 15 ++ packages/types/src/index.ts | 5 +- packages/types/src/utils/utils.type.ts | 2 + 13 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 apps/server/src/lib/time-core/__tests__/timeCore.test.ts create mode 100644 apps/server/src/lib/time-core/timeCore.ts create mode 100644 apps/server/vitest.global-setup.ts create mode 100644 packages/types/src/definitions/core/Temporal.ts diff --git a/apps/server/src/lib/time-core/__tests__/timeCore.test.ts b/apps/server/src/lib/time-core/__tests__/timeCore.test.ts new file mode 100644 index 000000000..60a8350cd --- /dev/null +++ b/apps/server/src/lib/time-core/__tests__/timeCore.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/lib/time-core/timeCore.ts b/apps/server/src/lib/time-core/timeCore.ts new file mode 100644 index 000000000..25869826e --- /dev/null +++ b/apps/server/src/lib/time-core/timeCore.ts @@ -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; +} diff --git a/apps/server/src/services/restore-service/restore.type.ts b/apps/server/src/services/restore-service/restore.type.ts index 6b7bfa526..3861dea33 100644 --- a/apps/server/src/services/restore-service/restore.type.ts +++ b/apps/server/src/services/restore-service/restore.type.ts @@ -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; currentDay: MaybeNumber; }; diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index be182ae2e..cf0455024 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -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, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 8a37f6b6e..f1a3083e8 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -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; // whether we should declare an event as finished, will contain the finish time + pausedAt: Maybe; + secondaryTarget: Maybe; hasFinished: boolean; }; _rundown: { @@ -61,12 +64,12 @@ export type RuntimeState = { _group: ExpectedMetadata; _flag: ExpectedMetadata; _end: ExpectedMetadata; - _startEpoch: MaybeNumber; + _startEpoch: Maybe; _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 diff --git a/apps/server/src/utils/time.ts b/apps/server/src/utils/time.ts index b1605a975..5b1ffdbfd 100644 --- a/apps/server/src/utils/time.ts +++ b/apps/server/src/utils/time.ts @@ -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]; -} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 39a64f855..13c9df73f 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.common.json", "compilerOptions": { "target": "esnext", + "composite": true, "module": "node16", "moduleResolution": "node16", "noImplicitReturns": false, //TODO: fix this diff --git a/apps/server/tsconfig.tests.json b/apps/server/tsconfig.tests.json index 8b485e145..4dacab9b2 100644 --- a/apps/server/tsconfig.tests.json +++ b/apps/server/tsconfig.tests.json @@ -13,6 +13,9 @@ }, "include": [ "src/**/*.test.ts", - "src/**/*.spec.ts", + "src/**/*.spec.ts" + ], + "references": [ + { "path": "./tsconfig.json" } ] } diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 7382f40e7..c9688bad7 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, + globalSetup: './vitest.global-setup.ts', }, }); diff --git a/apps/server/vitest.global-setup.ts b/apps/server/vitest.global-setup.ts new file mode 100644 index 000000000..92a321a77 --- /dev/null +++ b/apps/server/vitest.global-setup.ts @@ -0,0 +1,3 @@ +export function setup() { + process.env.TZ = 'Europe/Copenhagen'; +} diff --git a/packages/types/src/definitions/core/Temporal.ts b/packages/types/src/definitions/core/Temporal.ts new file mode 100644 index 000000000..faedaa131 --- /dev/null +++ b/packages/types/src/definitions/core/Temporal.ts @@ -0,0 +1,15 @@ +declare const __brand: unique symbol; +type Brand = { [__brand]: B }; +type Branded = T & Brand; + +/** A timestamp in milliseconds from epoch */ +export type Instant = Branded; + +/** A timestamp of milliseconds since midnight today in the set timezone */ +export type TimeOfDay = Branded; + +/** A duration of milliseconds */ +export type Duration = Branded; + +/** A day count integer */ +export type Day = Branded; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4d0bfc7ed..a91fbb5ed 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/packages/types/src/utils/utils.type.ts b/packages/types/src/utils/utils.type.ts index 21b1f2e42..e142fca82 100644 --- a/packages/types/src/utils/utils.type.ts +++ b/packages/types/src/utils/utils.type.ts @@ -1,2 +1,4 @@ export type MaybeNumber = number | null; export type MaybeString = string | null; + +export type Maybe = T | null;