From 798729b8140466ab02745018b297fb19bc9d2ac7 Mon Sep 17 00:00:00 2001
From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com>
Date: Mon, 16 Feb 2026 05:09:07 +0100
Subject: [PATCH] SOFIE-261 | add UI for configuring t-timers (WIP)
---
.../src/core/model/StudioSettings.ts | 26 +
packages/webui/src/client/styles/main.scss | 1 +
.../src/client/styles/tTimerSettings.scss | 255 ++++++++++
.../src/client/ui/Settings/Studio/Generic.tsx | 3 +
.../ui/Settings/Studio/TTimerSettings.tsx | 449 ++++++++++++++++++
5 files changed, 734 insertions(+)
create mode 100644 packages/webui/src/client/styles/tTimerSettings.scss
create mode 100644 packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx
diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts
index 1a117f18388..489acb7de65 100644
--- a/packages/shared-lib/src/core/model/StudioSettings.ts
+++ b/packages/shared-lib/src/core/model/StudioSettings.ts
@@ -104,4 +104,30 @@ export interface IStudioSettings {
* How long before their start time a rundown owned piece be added to the timeline
*/
rundownGlobalPiecesPrepareTime?: number
+
+ /** Configuration for T-Timers in this studio */
+ tTimerSettings?: [TTimerSettingsConfig, TTimerSettingsConfig, TTimerSettingsConfig]
+}
+
+export type TTimerMode = 'freeRun' | 'countdown' | 'timeOfDay'
+
+export interface TTimerSettingsConfig {
+ /** User label for this timer (matches RundownTTimer.label) */
+ label: string
+ /** Whether this timer is enabled */
+ enabled: boolean
+ /** The mode for this timer */
+ mode: TTimerMode
+ /** Countdown duration in milliseconds (for 'countdown' mode) */
+ countdownDuration: number
+ /** Target time string for 'timeOfDay' mode (e.g. "14:30") */
+ timeOfDayTarget: string
+ /** Whether the timer should stop at zero, or continue into negative values */
+ stopAtZero: boolean
+ /** Show on the Top Bar */
+ showOnTopBar: boolean
+ /** Show on the Director Screen */
+ showOnDirectorScreen: boolean
+ /** Show on the Presenter Screen + Prompter */
+ showOnPresenterScreen: boolean
}
diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss
index 254f12efbec..39b61032f33 100644
--- a/packages/webui/src/client/styles/main.scss
+++ b/packages/webui/src/client/styles/main.scss
@@ -41,6 +41,7 @@ input {
@import 'rundownList';
@import 'rundownSystemStatus';
@import 'settings';
+@import 'tTimerSettings';
@import 'splitDropdown';
@import 'statusbar';
@import 'studioScreenSaver';
diff --git a/packages/webui/src/client/styles/tTimerSettings.scss b/packages/webui/src/client/styles/tTimerSettings.scss
new file mode 100644
index 00000000000..80402bdfff5
--- /dev/null
+++ b/packages/webui/src/client/styles/tTimerSettings.scss
@@ -0,0 +1,255 @@
+@import 'colorScheme';
+
+.t-timer-settings {
+ margin-top: 1rem;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0.75rem;
+
+ h3 {
+ margin: 0;
+ }
+ }
+}
+
+.t-timer-card {
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ background: #fafafa;
+ padding: 1rem 1.25rem;
+ margin-bottom: 0.75rem;
+ transition: box-shadow 0.15s ease;
+
+ &:hover {
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+ }
+
+ &--disabled {
+ opacity: 0.5;
+ }
+
+ &__top-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 0.75rem;
+ }
+
+ &__toggle {
+ flex-shrink: 0;
+ }
+
+ &__name {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ width: 200px;
+ min-width: 200px;
+ max-width: 200px;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ input {
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: none;
+ border-bottom: 1px dashed #ccc;
+ background: transparent;
+ outline: none;
+ padding: 2px 4px;
+ width: 100%;
+
+ &:focus {
+ border-bottom-color: #333;
+ }
+ }
+ }
+
+ &__edit-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #999;
+ padding: 0;
+ }
+
+ &__transport {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: 2rem;
+
+ button {
+ background: none;
+ border: 1px solid #ccc;
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 0.75rem;
+ color: #555;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background: #eee;
+ border-color: #999;
+ }
+ }
+ }
+
+ &__body {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ }
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+
+ &--disabled {
+ opacity: 0.35;
+ pointer-events: none;
+ }
+
+ &-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: #888;
+ font-weight: 600;
+ margin-bottom: 0.15rem;
+ }
+ }
+
+ &__radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+
+ label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ white-space: nowrap;
+
+ input[type='radio'] {
+ margin: 0;
+ accent-color: #4caf50;
+ }
+ }
+ }
+
+ &__checkbox-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+
+ label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ white-space: nowrap;
+
+ input[type='checkbox'] {
+ margin: 0;
+ accent-color: #4caf50;
+ }
+ }
+ }
+}
+
+.time-segment-group {
+ display: flex;
+ align-items: center;
+ gap: 0;
+}
+
+.time-segment {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ &__btn {
+ background: none;
+ border: none;
+ width: 26px;
+ height: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 0.55rem;
+ color: #ccc;
+ padding: 0;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: #888;
+ }
+ }
+
+ &__input {
+ width: 26px;
+ text-align: center;
+ border: none;
+ background: transparent;
+ padding: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #333;
+ outline: none;
+
+ &:focus {
+ background: rgba(0, 0, 0, 0.03);
+ border-radius: 3px;
+ }
+ }
+
+ &__separator {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #333;
+ padding: 0;
+ align-self: center;
+ }
+
+ &__ampm {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #333;
+ text-transform: uppercase;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0 0 0 2px;
+ align-self: center;
+ vertical-align: super;
+ margin-top: -0.5rem;
+
+ &:hover {
+ color: #666;
+ }
+ }
+}
diff --git a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx
index 32d81caceec..f2b68ec4e8e 100644
--- a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx
+++ b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx
@@ -5,6 +5,7 @@ import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
import { useTranslation } from 'react-i18next'
import { EditAttribute } from '../../../lib/EditAttribute.js'
import { StudioBaselineStatus } from './Baseline.js'
+import { TTimerSettingsPanel } from './TTimerSettings.js'
import { ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids'
import { ShowStyleBases, Studios } from '../../../collections/index.js'
import { useHistory } from 'react-router-dom'
@@ -105,6 +106,8 @@ export function StudioGenericProperties({ studio }: IStudioGenericPropertiesProp
+
+
)
diff --git a/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx b/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx
new file mode 100644
index 00000000000..aa471f1b9fc
--- /dev/null
+++ b/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx
@@ -0,0 +1,449 @@
+import React, { useCallback, useState } from 'react'
+import moment from 'moment'
+import { useTranslation } from 'react-i18next'
+import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
+import { Studios } from '../../../collections/index.js'
+import { TTimerSettingsConfig, TTimerMode } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import {
+ faPlay,
+ faRedo,
+ faPen,
+ faChevronUp,
+ faChevronDown,
+ faChevronLeft,
+ faChevronRight,
+} from '@fortawesome/free-solid-svg-icons'
+import Form from 'react-bootstrap/Form'
+
+// --- Type aliases ---
+
+/** Which part of HH:MM:SS is being edited */
+type TimeSegment = 'h' | 'm' | 's'
+
+/** Shorthand for the fixed-length timer settings tuple */
+type TimerTuple = [TTimerSettingsConfig, TTimerSettingsConfig, TTimerSettingsConfig]
+
+/** Visibility toggle fields on TTimerSettingsConfig */
+type ShowOnField = 'showOnTopBar' | 'showOnDirectorScreen' | 'showOnPresenterScreen'
+
+// --- Constants ---
+
+const DEFAULT_TIMER_CONFIG: TTimerSettingsConfig = {
+ label: 'Stopwatch',
+ enabled: false,
+ mode: 'freeRun',
+ countdownDuration: 30 * 60 * 1000, // 30 minutes
+ timeOfDayTarget: '',
+ stopAtZero: true,
+ showOnTopBar: true,
+ showOnDirectorScreen: true,
+ showOnPresenterScreen: false,
+}
+
+const TIME_FORMAT = 'HH:mm:ss'
+
+// --- Data helpers ---
+
+function getTimerSettings(studio: DBStudio): TimerTuple {
+ const settings = studio.settingsWithOverrides?.defaults?.tTimerSettings
+ if (settings && Array.isArray(settings) && settings.length === 3) {
+ return settings as TimerTuple
+ }
+ return [
+ { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch A' },
+ { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch B' },
+ { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch C' },
+ ]
+}
+
+function saveTimerSettings(studioId: DBStudio['_id'], timers: TimerTuple): void {
+ Studios.update(studioId, {
+ $set: {
+ 'settingsWithOverrides.defaults.tTimerSettings': timers,
+ },
+ })
+}
+
+// --- Segmented time input ---
+
+interface SegmentInputProps {
+ readonly segment: TimeSegment
+ readonly value: number
+ readonly onIncrement: (seg: TimeSegment) => void
+ readonly onDecrement: (seg: TimeSegment) => void
+ readonly onChange: (seg: TimeSegment, raw: string) => void
+}
+
+function SegmentInput({ segment, value, onIncrement, onDecrement, onChange }: SegmentInputProps): JSX.Element {
+ const [editing, setEditing] = useState(false)
+ const [raw, setRaw] = useState('')
+
+ const displayValue = editing ? raw : String(value).padStart(2, '0')
+
+ const commit = () => {
+ onChange(segment, raw)
+ setEditing(false)
+ }
+
+ return (
+
+
+ {
+ setEditing(true)
+ setRaw(String(value).padStart(2, '0'))
+ e.target.select()
+ }}
+ onChange={(e) => setRaw(e.target.value.replace(/\D/g, '').slice(0, 2))}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ commit()
+ ;(e.target as HTMLInputElement).blur()
+ }
+ }}
+ maxLength={2}
+ />
+
+
+ )
+}
+
+interface TimeSegmentInputProps {
+ readonly value: string
+ readonly onChange: (newValue: string) => void
+ readonly showAmPm?: boolean
+}
+
+function TimeSegmentInput({ value, onChange, showAmPm }: TimeSegmentInputProps): JSX.Element {
+ const parsed = moment(value || '00:00:00', TIME_FORMAT)
+ const h = parsed.hours()
+ const m = parsed.minutes()
+ const s = parsed.seconds()
+
+ const clamp = (n: number, max: number) => Math.min(max, Math.max(0, n))
+
+ const update = (newH: number, newM: number, newS: number) => {
+ const ms = moment
+ .duration({ hours: clamp(newH, 99), minutes: clamp(newM, 59), seconds: clamp(newS, 59) })
+ .asMilliseconds()
+ onChange(moment.utc(ms).format(TIME_FORMAT))
+ }
+
+ const handleChange = (seg: TimeSegment, raw: string) => {
+ const n = Number.parseInt(raw, 10) || 0
+ if (seg === 'h') update(n, m, s)
+ else if (seg === 'm') update(h, n, s)
+ else update(h, m, n)
+ }
+
+ const adjust = (seg: TimeSegment, delta: number) => {
+ if (seg === 'h') update(h + delta, m, s)
+ else if (seg === 'm') update(h, m + delta, s)
+ else update(h, m, s + delta)
+ }
+
+ const toggleAmPm = () => update(h < 12 ? h + 12 : h - 12, m, s)
+
+ const displayH = showAmPm ? h % 12 || 12 : h
+ const isPm = h >= 12
+
+ return (
+
+ adjust(seg, 1)}
+ onDecrement={(seg) => adjust(seg, -1)}
+ onChange={handleChange}
+ />
+ :
+ adjust(seg, 1)}
+ onDecrement={(seg) => adjust(seg, -1)}
+ onChange={handleChange}
+ />
+ :
+ adjust(seg, 1)}
+ onDecrement={(seg) => adjust(seg, -1)}
+ onChange={handleChange}
+ />
+ {showAmPm && (
+
+ )}
+
+ )
+}
+
+/** Wrapper that reads/writes milliseconds instead of a time string */
+interface DurationSegmentInputProps {
+ readonly valueMs: number
+ readonly onChange: (newMs: number) => void
+}
+
+function DurationSegmentInput({ valueMs, onChange }: DurationSegmentInputProps): JSX.Element {
+ const timeStr = moment.utc(Math.max(0, valueMs || 0)).format(TIME_FORMAT)
+
+ const handleChange = (newTimeStr: string) => {
+ const t = moment(newTimeStr, TIME_FORMAT)
+ onChange(moment.duration({ hours: t.hours(), minutes: t.minutes(), seconds: t.seconds() }).asMilliseconds())
+ }
+
+ return
+}
+
+// --- Main panel ---
+
+interface TTimerSettingsPanelProps {
+ readonly studio: DBStudio
+}
+
+export function TTimerSettingsPanel({ studio }: TTimerSettingsPanelProps): JSX.Element {
+ const { t } = useTranslation()
+ const timers = getTimerSettings(studio)
+
+ const handleTimerChange = useCallback(
+ (index: number, updatedTimer: TTimerSettingsConfig) => {
+ const newTimers = [...timers] as TimerTuple
+ newTimers[index] = updatedTimer
+ saveTimerSettings(studio._id, newTimers)
+ },
+ [studio._id, timers]
+ )
+
+ return (
+
+
+
{t('T-Timer Settings')}
+
+ {timers.map((timer, index) => (
+
handleTimerChange(index, updated)} />
+ ))}
+
+ )
+}
+
+// --- Timer card ---
+
+interface TTimerCardProps {
+ readonly timer: TTimerSettingsConfig
+ readonly index: number
+ readonly onChange: (timer: TTimerSettingsConfig) => void
+}
+
+function TTimerCard({ timer, onChange }: TTimerCardProps): JSX.Element {
+ const { t } = useTranslation()
+ const [isEditingLabel, setIsEditingLabel] = useState(false)
+
+ const handleToggle = useCallback(() => {
+ onChange({ ...timer, enabled: !timer.enabled })
+ }, [timer, onChange])
+
+ const handleLabelChange = useCallback(
+ (e: React.ChangeEvent) => {
+ onChange({ ...timer, label: e.target.value })
+ },
+ [timer, onChange]
+ )
+
+ const handleModeChange = useCallback(
+ (mode: TTimerMode) => {
+ onChange({ ...timer, mode })
+ },
+ [timer, onChange]
+ )
+
+ const handleCountdownDurationChange = useCallback(
+ (countdownDuration: number) => {
+ onChange({ ...timer, countdownDuration })
+ },
+ [timer, onChange]
+ )
+
+ const handleTimeOfDayTargetChange = useCallback(
+ (timeOfDayTarget: string) => {
+ onChange({ ...timer, timeOfDayTarget })
+ },
+ [timer, onChange]
+ )
+
+ const handleStopAtZeroChange = useCallback(
+ (stopAtZero: boolean) => {
+ onChange({ ...timer, stopAtZero })
+ },
+ [timer, onChange]
+ )
+
+ const handleShowOnChange = useCallback(
+ (field: ShowOnField) => {
+ onChange({ ...timer, [field]: !timer[field] })
+ },
+ [timer, onChange]
+ )
+
+ const sectionClass = (activeMode: TTimerMode) =>
+ `t-timer-card__section${timer.mode === activeMode ? '' : ' t-timer-card__section--disabled'}`
+
+ return (
+
+
+
+
+
+
+ {isEditingLabel ? (
+ setIsEditingLabel(false)}
+ onKeyDown={(e) => e.key === 'Enter' && setIsEditingLabel(false)}
+ autoFocus
+ />
+ ) : (
+ <>
+ {timer.label}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('TIME OF DAY')}
+
{}} showAmPm />
+
+
+
+
+
+
+
+
+
+
{t('SHOW ON:')}
+
+
handleShowOnChange('showOnTopBar')}
+ id={`showOnTopBar-${timer.label}`}
+ />
+ handleShowOnChange('showOnDirectorScreen')}
+ id={`showOnDirectorScreen-${timer.label}`}
+ />
+ handleShowOnChange('showOnPresenterScreen')}
+ id={`showOnPresenterScreen-${timer.label}`}
+ />
+
+
+
+
+ )
+}