feat(finale): server-stamped phase timer helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
36
src/lib/live-timer.ts
Normal file
36
src/lib/live-timer.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Server-stamped phase timer math for the grand-finale ceremony.
|
||||||
|
*
|
||||||
|
* The cursor stores `phaseStartedAt` + `phaseDurationSeconds` plus a pause
|
||||||
|
* accumulator; every client derives the countdown locally from those stamps,
|
||||||
|
* so all screens agree and overtime is just a negative remainder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PhaseTimerState = {
|
||||||
|
phaseStartedAt: Date | string | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: Date | string | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
|
||||||
|
if (!t.phaseStartedAt) return 0
|
||||||
|
const start = new Date(t.phaseStartedAt).getTime()
|
||||||
|
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
|
||||||
|
return Math.max(0, end - start - t.phasePausedAccumMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds left on the phase timer; negative = overtime; null = no timer running. */
|
||||||
|
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
|
||||||
|
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
|
||||||
|
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `5:05` for positive seconds, `+1:23` for overtime. */
|
||||||
|
export function formatClock(seconds: number): string {
|
||||||
|
const over = seconds < 0
|
||||||
|
const abs = Math.abs(seconds)
|
||||||
|
const m = Math.floor(abs / 60)
|
||||||
|
const s = abs % 60
|
||||||
|
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
75
tests/unit/live-timer.test.ts
Normal file
75
tests/unit/live-timer.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Server-stamped phase timer math: every screen (admin, jury, big screen)
|
||||||
|
* derives the same countdown from cursor timestamps — no client-local clocks.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||||
|
|
||||||
|
const t0 = new Date('2026-06-11T10:00:00Z')
|
||||||
|
const at = (s: number) => new Date(t0.getTime() + s * 1000)
|
||||||
|
|
||||||
|
describe('live-timer', () => {
|
||||||
|
it('elapsedMs counts from phaseStartedAt', () => {
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
at(90)
|
||||||
|
)
|
||||||
|
).toBe(90_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
|
||||||
|
// paused at +60s, asked at +90s → frozen at 60s
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 },
|
||||||
|
at(90)
|
||||||
|
)
|
||||||
|
).toBe(60_000)
|
||||||
|
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 },
|
||||||
|
at(120)
|
||||||
|
)
|
||||||
|
).toBe(90_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elapsedMs accepts ISO strings (serialized cursor)', () => {
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{
|
||||||
|
phaseStartedAt: t0.toISOString(),
|
||||||
|
phaseDurationSeconds: 300,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
},
|
||||||
|
at(45)
|
||||||
|
)
|
||||||
|
).toBe(45_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remainingSeconds goes negative on overtime', () => {
|
||||||
|
expect(
|
||||||
|
remainingSeconds(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
at(75)
|
||||||
|
)
|
||||||
|
).toBe(-15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remainingSeconds is null without a running timer', () => {
|
||||||
|
expect(
|
||||||
|
remainingSeconds(
|
||||||
|
{ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
t0
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatClock renders m:ss and overtime as +m:ss', () => {
|
||||||
|
expect(formatClock(305)).toBe('5:05')
|
||||||
|
expect(formatClock(0)).toBe('0:00')
|
||||||
|
expect(formatClock(-83)).toBe('+1:23')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user