feat(timer): allow swapping the secondary source into the main timer slot

The timer view lets operators show an aux timer or the secondary message
as a smaller secondary timer under the main event timer. This adds a
placement control so that selected source can be promoted into the main
slot, swapping positions with the event timer.

The swap is deliberate: the event timer is demoted to the secondary slot
rather than removed, so the show-critical countdown (and its phase colour)
is never lost from screen.

- add `SecondaryPlacement` ('below' | 'main') to the timer message, with
  server validation and a store default
- expose the aux timer direction to the timer view and honour it when
  formatting a promoted aux (previously hard-coded to count-down)
- add a `getTimerSlots` helper that assigns the event timer and secondary
  content to the main/secondary slots, routing phase colour, paused/finished
  styling and font sizing to whichever slot holds the event timer
- add a Placement radio control to the timer view panel (disabled until a
  secondary source is active) and reflect the swap in the control preview

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbgWVAXTJUX7E5pAmv6Rs7
This commit is contained in:
Claude
2026-07-18 07:27:32 +00:00
parent daa032e92c
commit 077748e5a4
13 changed files with 302 additions and 32 deletions
@@ -33,4 +33,11 @@ describe('validateTimerMessage()', () => {
expect(validateTimerMessage(payload)).toStrictEqual(expected);
});
it('coerces the secondary placement to a permitted value', () => {
expect(validateTimerMessage({ secondaryPlacement: 'main' })).toStrictEqual({ secondaryPlacement: 'main' });
expect(validateTimerMessage({ secondaryPlacement: 'below' })).toStrictEqual({ secondaryPlacement: 'below' });
});
it('falls back to below for an invalid placement', () => {
expect(validateTimerMessage({ secondaryPlacement: 'nonsense' })).toStrictEqual({ secondaryPlacement: 'below' });
});
});
@@ -25,6 +25,7 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
if ('blink' in message) result.blink = coerceBoolean(message.blink);
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
if ('secondaryPlacement' in message) result.secondaryPlacement = coercePlacement(message.secondaryPlacement);
return result;
}
@@ -45,3 +46,20 @@ function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
}
return source;
}
/**
* Asserts that the placement value is one of the permitted values
*/
function assertPlacement(placement: unknown): placement is TimerMessage['secondaryPlacement'] {
return placement === 'below' || placement === 'main';
}
/**
* Ensures that the placement value is one of the permitted values
*/
function coercePlacement(placement: unknown): TimerMessage['secondaryPlacement'] {
if (!assertPlacement(placement)) {
return 'below';
}
return placement;
}