diff --git a/src/lib/live-timer.ts b/src/lib/live-timer.ts new file mode 100644 index 0000000..0d95625 --- /dev/null +++ b/src/lib/live-timer.ts @@ -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')}` +} diff --git a/tests/unit/live-timer.test.ts b/tests/unit/live-timer.test.ts new file mode 100644 index 0000000..374ea91 --- /dev/null +++ b/tests/unit/live-timer.test.ts @@ -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') + }) +})