Feat/sofie 449 duration based timing mode#79
Merged
jesperstarkar merged 15 commits intofeat/top-bar-t-timersfrom Mar 27, 2026
Merged
Feat/sofie 449 duration based timing mode#79jesperstarkar merged 15 commits intofeat/top-bar-t-timersfrom
jesperstarkar merged 15 commits intofeat/top-bar-t-timersfrom
Conversation
This adds basic T-Timer support to the Live Status Gateway API by exposing the underlying data model structure directly, rather than denormalizing/reshaping it. Structure: - TimerMode: Configuration (countdown/freeRun/timeOfDay) - TimerState: Runtime data (paused/running, zeroTime/duration) - TTimerStatus: Combines mode, state, label, configured flag This keeps the transformation layer thin - the API just passes through the well-designed internal structures. The types match the corelib TimerState and RundownTTimerMode types exactly. Benefits over previous denormalized approach: - 90% less transformation code - Single source of truth for TimerState structure - Easier to add timeOfDay mode support later - Self-documenting (reuses existing inline docs) Schema generation: Added tTimers example to activePlaylistEvent-example.yaml to fix AsyncAPI parser validation. The parser requires all required fields to be present in examples. Tests: Updated activePlaylist.spec.ts to include tTimers in all cases
- Add pauseTime field to TimerState (both running and paused states) - Running: timestamp for automatic pause when part ends - Paused: typically null when already paused - Add projected field to TTimerStatus (TimerState | null) - Enables calculation of over/under diff relative to anchor - Running state means progressing towards anchor - Paused state means pushing/delaying anchor - Add anchorPartId field to TTimerStatus (string | null) - Target Part ID that timer is counting towards - Update example with realistic projected/pauseTime/anchorPartId values - Fix imports: use specific timer mode/state types and define local unions - Update all test cases to include new fields (projected, anchorPartId) - All tests passing (17/17)
- Add formatTimestampToTimeOfDay() to display timestamps in HH:MM:SS format - Show timer state context: (paused), (pauseTime: HH:MM:SS), or (zeroTime: HH:MM:SS) - Factor in pauseTime when calculating timer display values to freeze countdown correctly - Apply same logic to both current and projected timer states
* feat/top-bar-t-timers: fix: correctly show est end before planned start time fix: correctly show planned start, start in and started fix: allow showing diff in duration based show before start fix: bottom align solo timers in advanced view fix: pipe duration through, but in a conservative non-perfect way to avoid regressions in other views now fix: show planned duration for duration based shows
… duration based mode Which sets Planned End based on Started + Duration
Collaborator
Author
justandras
requested changes
Mar 27, 2026
Collaborator
justandras
left a comment
There was a problem hiding this comment.
I've suggested some small formatting changes to fix the failing linting and some code/readability improvements.
packages/blueprints-integration/src/documents/playlistTiming.ts
Outdated
Show resolved
Hide resolved
| } | ||
| } | ||
|
|
||
| export function getExpectedDuration(timing: RundownPlaylistTiming): number | undefined { |
Collaborator
There was a problem hiding this comment.
I would only have one return statement for timing.expectedDuration for simplicity.
Suggested change
| export function getExpectedDuration(timing: RundownPlaylistTiming): number | undefined { | |
| // Duration can only be known in Forward- an BackTiming | |
| if (PlaylistTiming.isPlaylistTimingForwardTime(timing) || PlaylistTiming.isPlaylistTimingBackTime(timing)) { | |
| return timing.expectedDuration | |
| } else { | |
| return undefined | |
| } | |
| } |
| frontAnchor = Math.max(currentTime, playlist.startedPlayback ?? Math.max(timing.expectedStart, currentTime)) | ||
| } else if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { | ||
| backAnchor = timingContext.nextRundownAnchor ?? timing.expectedEnd | ||
| } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { |
Collaborator
There was a problem hiding this comment.
I would move the anchor resolution into a helper for readibility and clarity.
Suggested change
| } else if (PlaylistTiming.isPlaylistDurationTimed(timing)) { | |
| export function getAnchors( | |
| playlist: Pick<DBRundownPlaylist, 'timing' | 'startedPlayback' | 'activationId'>, | |
| timingContext: RundownTimingContext | |
| ): { front: number; back: number } { | |
| const { timing, startedPlayback } = playlist | |
| const currentTime = timingContext.currentTime || getCurrentTime() | |
| if (PlaylistTiming.isPlaylistTimingForwardTime(timing)) { | |
| const start = startedPlayback ?? Math.max(timing.expectedStart, currentTime) | |
| const duration = timing.expectedDuration ?? timingContext.totalPlaylistDuration ?? 0 | |
| return { | |
| front: Math.max(currentTime, start), | |
| back: timingContext.nextRundownAnchor ?? timing.expectedEnd ?? start + duration, | |
| } | |
| } | |
| if (PlaylistTiming.isPlaylistTimingBackTime(timing)) { | |
| return { | |
| front: currentTime, | |
| back: timingContext.nextRundownAnchor ?? timing.expectedEnd, | |
| } | |
| } | |
| if (PlaylistTiming.isPlaylistDurationTimed(timing)) { | |
| const start = startedPlayback || PlaylistTiming.getExpectedStart(timing) || 0 | |
| const end = PlaylistTiming.getExpectedEnd(timing, startedPlayback) ?? currentTime + timing.expectedDuration | |
| return { | |
| front: Math.max(currentTime, start), | |
| back: timingContext.nextRundownAnchor ?? end, | |
| } | |
| } | |
| return { front: currentTime, back: currentTime } | |
| } | |
| export function getPlaylistTimingDiff( | |
| playlist: Pick<DBRundownPlaylist, 'timing' | 'startedPlayback' | 'activationId'>, | |
| timingContext: RundownTimingContext | |
| ): number | undefined { | |
| const { timing, startedPlayback, activationId } = playlist | |
| const active = !!activationId | |
| const { front: frontAnchor, back: backAnchor } = getAnchors(playlist, timingContext) | |
| frontAnchor = Math.max(currentTime, playlist.startedPlayback || PlaylistTiming.getExpectedStart(timing) || 0) | ||
| } | ||
|
|
||
| let diff = PlaylistTiming.isPlaylistTimingNone(timing) |
Collaborator
There was a problem hiding this comment.
Since we are changing this function, I would also update this calculation for clarity
Suggested change
| let diff = PlaylistTiming.isPlaylistTimingNone(timing) | |
| const { | |
| asPlayedPlaylistDuration: playedDuration = 0, | |
| remainingPlaylistDuration: remainingDuration = 0, | |
| totalPlaylistDuration: totalDuration = 0, | |
| } = timingContext | |
| const expectedDuration = timing.expectedDuration ?? totalDuration | |
| let diff = PlaylistTiming.isPlaylistTimingNone(timing) | |
| ? playedDuration - expectedDuration | |
| : frontAnchor + remainingDuration - backAnchor |
…e before any planned start, as well as starting prematurely vs late
justandras
approved these changes
Mar 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Type of Contribution
This is a: Feature
Current Behavior
Today you can specify a planned start and planned duration, which to Sofie means the show is scheduled to run within a certain block of walltime.
For floating-start shows, today, you can specify just the duration, then manually start it at the right time. You can currently not hint about the preliminary start time, the best estimate, from the editorial side.
New Behavior
Adding a new timing mode which changes the rules. The intention is to protect the Duration property, and enforce a rule that generates the Planned end property from the actual started time + this planned Duration.
As described in the code comments:
This mode is inteded for shows with a "floating start", meaning they will start based on when the show before them on the channel ends. In this mode, we will preserve the Duration and automatically calculate the expectedEnd based on the actual start of the show (playlist.startedPlayback).
The optional expectedStart property allows setting a start property of the show that will not affect timing calculations, only purpose is to drive UI and inform the users about the preliminary plan as planned in the editorial planning tool.
Testing
Feature tested with simple regression testing on other timing modes in the adjacent code spaces.
Affected areas
This is a bit of a mess...
We follow existing patterns of timing properties and calculations being scattered across corelib, ui libs and ui frontend files.
There's duplications, and nested if/else statements in various places, stacked on top of each other as needed.
We want to change this to a clear pattern in the next scope of work!
Time Frame
Other Information
Status