Skip to content

Feat/sofie 449 duration based timing mode#79

Merged
jesperstarkar merged 15 commits intofeat/top-bar-t-timersfrom
feat/SOFIE-449-duration-based-timing-mode
Mar 27, 2026
Merged

Feat/sofie 449 duration based timing mode#79
jesperstarkar merged 15 commits intofeat/top-bar-t-timersfrom
feat/SOFIE-449-duration-based-timing-mode

Conversation

@jesperstarkar
Copy link
Copy Markdown
Collaborator

@jesperstarkar jesperstarkar commented Mar 25, 2026

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.

Screenshot 2026-03-25 at 11 08 34 Screenshot 2026-03-25 at 11 08 41 Screenshot 2026-03-25 at 11 08 54
  • I have added one or more unit tests for this PR
  • I have updated the relevant unit tests
  • No unit test changes are needed for this PR

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

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

rjmunro and others added 10 commits March 20, 2026 11:10
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
@jesperstarkar
Copy link
Copy Markdown
Collaborator Author

Testing:

The new mode is feature-tested, with manual regression tests for all other modes on the estimated end and diff calculations.

image

@jesperstarkar jesperstarkar marked this pull request as ready for review March 25, 2026 12:30
@Saftret Saftret requested a review from justandras March 25, 2026 12:38
Copy link
Copy Markdown
Collaborator

@justandras justandras left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've suggested some small formatting changes to fix the failing linting and some code/readability improvements.

}
}

export function getExpectedDuration(timing: RundownPlaylistTiming): number | undefined {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@jesperstarkar jesperstarkar changed the base branch from rjmunro/t-timers-lsg to feat/top-bar-t-timers March 27, 2026 14:33
@jesperstarkar jesperstarkar merged commit 6a98cb3 into feat/top-bar-t-timers Mar 27, 2026
42 checks passed
@jesperstarkar jesperstarkar deleted the feat/SOFIE-449-duration-based-timing-mode branch March 27, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants