feat: timer engine + full-screen timer UI

Implement the core timer feature following the src/features/ architecture:

- useTimerEngine hook: drift-free Date.now() delta countdown (100ms tick),
  explicit state machine (IDLE → GET_READY → WORK → REST → COMPLETE),
  event emitter for external consumers (PHASE_CHANGED, ROUND_COMPLETED,
  COUNTDOWN_TICK, SESSION_COMPLETE), auto-pause on AppState interruption
  (phone calls, background), expo-keep-awake during session
- TimerDisplay component: full-screen animated UI with 600ms color
  transitions between phases, pulse animation on countdown, flash red
  on last 3 seconds, round progress dots, IDLE/active/COMPLETE views
- TimerControls component: stop/pause-resume/skip buttons with Ionicons
- Timer route (app/timer.tsx): fullScreenModal wiring hook → display
- Home screen: dark theme with START button navigating to /timer
- Project docs: CLAUDE.md (constitution), PRD v1.1, skill files
- Shared constants: PHASE_COLORS, TIMER_DEFAULTS, formatTime utility
- Types: TimerPhase, TimerState, TimerConfig, TimerActions, TimerEvent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-17 19:05:25 +01:00
parent 5cefe864ec
commit 31bdb1586f
19 changed files with 3256 additions and 90 deletions

View File

@@ -0,0 +1,79 @@
import { Pressable, StyleSheet, View } from 'react-native'
import Ionicons from '@expo/vector-icons/Ionicons'
interface TimerControlsProps {
isRunning: boolean
isPaused: boolean
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
}
export function TimerControls({
isRunning,
isPaused,
onPause,
onResume,
onStop,
onSkip,
}: TimerControlsProps) {
return (
<View style={styles.container}>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onStop}
>
<Ionicons name="stop" size={28} color="#FFFFFF" />
</Pressable>
<Pressable
style={({ pressed }) => [
styles.button,
styles.mainButton,
pressed && styles.pressed,
]}
onPress={isPaused ? onResume : onPause}
>
<Ionicons
name={isPaused ? 'play' : 'pause'}
size={36}
color="#FFFFFF"
/>
</Pressable>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onSkip}
>
<Ionicons name="play-skip-forward" size={28} color="#FFFFFF" />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 32,
},
button: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
alignItems: 'center',
justifyContent: 'center',
},
mainButton: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
pressed: {
opacity: 0.6,
},
})

View File

@@ -0,0 +1,511 @@
import { useEffect, useRef, useState } from 'react'
import {
Animated,
Easing,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { PHASE_COLORS } from '@/src/shared/constants/colors'
import { formatTime } from '@/src/shared/utils/formatTime'
import type { TimerConfig, TimerState } from '../types'
import { TimerControls } from './TimerControls'
const PHASE_LABELS: Record<string, string> = {
GET_READY: 'PRÉPARE-TOI',
WORK: 'TRAVAIL',
REST: 'REPOS',
COMPLETE: 'TERMINÉ !',
}
interface TimerDisplayProps {
state: TimerState
config: TimerConfig
exerciseName: string
nextExerciseName: string
onStart: () => void
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
}
export function TimerDisplay({
state,
config,
exerciseName,
nextExerciseName,
onStart,
onPause,
onResume,
onStop,
onSkip,
}: TimerDisplayProps) {
const insets = useSafeAreaInsets()
if (state.phase === 'IDLE') {
return (
<IdleView
config={config}
onStart={onStart}
topInset={insets.top}
bottomInset={insets.bottom}
/>
)
}
if (state.phase === 'COMPLETE') {
return (
<CompleteView
totalElapsedSeconds={state.totalElapsedSeconds}
totalRounds={state.totalRounds}
onStop={onStop}
topInset={insets.top}
bottomInset={insets.bottom}
/>
)
}
return (
<ActiveView
state={state}
exerciseName={exerciseName}
nextExerciseName={nextExerciseName}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
topInset={insets.top}
bottomInset={insets.bottom}
/>
)
}
// --- IDLE view ---
function IdleView({
config,
onStart,
topInset,
bottomInset,
}: {
config: TimerConfig
onStart: () => void
topInset: number
bottomInset: number
}) {
return (
<View
style={[
styles.screen,
{ backgroundColor: PHASE_COLORS.IDLE, paddingTop: topInset, paddingBottom: bottomInset },
]}
>
<StatusBar style="light" />
<View style={styles.idleContent}>
<Text style={styles.idleTitle}>TABATAGO</Text>
<View style={styles.configSummary}>
<Text style={styles.configLine}>
{config.workDuration}s travail / {config.restDuration}s repos
</Text>
<Text style={styles.configLine}>
{config.rounds} rounds
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={onStart}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
</View>
</View>
)
}
// --- COMPLETE view ---
function CompleteView({
totalElapsedSeconds,
totalRounds,
onStop,
topInset,
bottomInset,
}: {
totalElapsedSeconds: number
totalRounds: number
onStop: () => void
topInset: number
bottomInset: number
}) {
return (
<View
style={[
styles.screen,
{ backgroundColor: PHASE_COLORS.COMPLETE, paddingTop: topInset, paddingBottom: bottomInset },
]}
>
<StatusBar style="light" />
<View style={styles.idleContent}>
<Text style={styles.completeTitle}>TERMINÉ !</Text>
<Text style={styles.completeTime}>{formatTime(totalElapsedSeconds)}</Text>
<Text style={styles.completeRounds}>
{totalRounds} rounds complétés
</Text>
<Pressable
style={({ pressed }) => [
styles.doneButton,
pressed && styles.startButtonPressed,
]}
onPress={onStop}
>
<Text style={styles.doneButtonText}>Terminer</Text>
</Pressable>
</View>
</View>
)
}
// --- ACTIVE view (GET_READY, WORK, REST) ---
function ActiveView({
state,
exerciseName,
nextExerciseName,
onPause,
onResume,
onStop,
onSkip,
topInset,
bottomInset,
}: {
state: TimerState
exerciseName: string
nextExerciseName: string
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
topInset: number
bottomInset: number
}) {
// --- Background color transition ---
const [prevColor, setPrevColor] = useState<string>(PHASE_COLORS.IDLE)
const fadeAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
const targetColor = PHASE_COLORS[state.phase]
fadeAnim.setValue(0)
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false,
}).start(() => {
setPrevColor(targetColor)
})
}, [state.phase])
const backgroundColor = fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [prevColor, PHASE_COLORS[state.phase]],
})
// --- Countdown pulse ---
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
if (state.isRunning) {
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 80,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 80,
useNativeDriver: true,
}),
]).start()
}
}, [state.secondsLeft])
// --- Progress ---
const totalPhaseDuration =
state.phase === 'GET_READY'
? 0
: (state.currentRound - 1) / state.totalRounds
const isLastSeconds = state.secondsLeft <= 3 && state.secondsLeft > 0
// Top info line
const topLabel =
state.phase === 'GET_READY'
? exerciseName
: state.phase === 'REST'
? `Prochain : ${nextExerciseName}`
: exerciseName
return (
<Animated.View
style={[
styles.screen,
{ backgroundColor, paddingTop: topInset, paddingBottom: bottomInset },
]}
>
<StatusBar hidden />
{/* Zone haute — exercice + round */}
<View style={styles.topZone}>
<Text style={styles.exerciseName} numberOfLines={1}>
{topLabel}
</Text>
{state.currentRound > 0 && (
<Text style={styles.roundIndicator}>
Round {state.currentRound}/{state.totalRounds}
</Text>
)}
</View>
{/* Zone centrale — countdown */}
<View style={styles.centerZone}>
<Text style={styles.phaseLabel}>
{PHASE_LABELS[state.phase] ?? ''}
</Text>
<Animated.Text
style={[
styles.countdown,
{ transform: [{ scale: pulseAnim }] },
isLastSeconds && styles.countdownFlash,
]}
>
{state.secondsLeft}
</Animated.Text>
{state.isPaused && (
<Text style={styles.pausedLabel}>EN PAUSE</Text>
)}
</View>
{/* Zone basse haute — progress bar */}
<View style={styles.progressZone}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${totalPhaseDuration * 100}%` },
]}
/>
</View>
<View style={styles.roundDots}>
{Array.from({ length: state.totalRounds }, (_, i) => (
<View
key={i}
style={[
styles.dot,
i < state.currentRound && styles.dotFilled,
i === state.currentRound - 1 &&
state.phase === 'WORK' &&
styles.dotActive,
]}
/>
))}
</View>
</View>
{/* Zone basse — controls */}
<View style={styles.controlsZone}>
<TimerControls
isRunning={state.isRunning}
isPaused={state.isPaused}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
/>
</View>
</Animated.View>
)
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
// --- IDLE ---
idleContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 24,
},
idleTitle: {
fontSize: 40,
fontWeight: '900',
color: '#FFFFFF',
letterSpacing: 6,
},
configSummary: {
alignItems: 'center',
gap: 4,
marginTop: 16,
},
configLine: {
fontSize: 18,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
startButton: {
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: '#F97316',
alignItems: 'center',
justifyContent: 'center',
marginTop: 40,
},
startButtonPressed: {
opacity: 0.7,
},
startButtonText: {
fontSize: 28,
fontWeight: '900',
color: '#FFFFFF',
letterSpacing: 3,
},
// --- COMPLETE ---
completeTitle: {
fontSize: 40,
fontWeight: '900',
color: '#FFFFFF',
},
completeTime: {
fontSize: 64,
fontWeight: '900',
color: '#FFFFFF',
fontVariant: ['tabular-nums'],
marginTop: 8,
},
completeRounds: {
fontSize: 18,
color: 'rgba(255, 255, 255, 0.8)',
fontWeight: '500',
},
doneButton: {
paddingHorizontal: 48,
paddingVertical: 16,
borderRadius: 32,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
marginTop: 40,
},
doneButtonText: {
fontSize: 20,
fontWeight: '700',
color: '#FFFFFF',
},
// --- ACTIVE ---
topZone: {
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
exerciseName: {
fontSize: 18,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.9)',
flex: 1,
},
roundIndicator: {
fontSize: 16,
fontWeight: '700',
color: 'rgba(255, 255, 255, 0.8)',
marginLeft: 12,
},
centerZone: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
phaseLabel: {
fontSize: 22,
fontWeight: '700',
color: 'rgba(255, 255, 255, 0.8)',
letterSpacing: 3,
marginBottom: 8,
},
countdown: {
fontSize: 120,
fontWeight: '900',
color: '#FFFFFF',
fontVariant: ['tabular-nums'],
},
countdownFlash: {
color: '#EF4444',
},
pausedLabel: {
fontSize: 18,
fontWeight: '700',
color: 'rgba(255, 255, 255, 0.6)',
letterSpacing: 4,
marginTop: 12,
},
progressZone: {
paddingHorizontal: 24,
gap: 12,
marginBottom: 8,
},
progressTrack: {
height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 2,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 2,
},
roundDots: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
dotFilled: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
dotActive: {
backgroundColor: '#FFFFFF',
width: 10,
height: 10,
borderRadius: 5,
},
controlsZone: {
paddingBottom: 24,
paddingTop: 16,
},
})

View File

@@ -0,0 +1,377 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import {
activateKeepAwakeAsync,
deactivateKeepAwake,
} from 'expo-keep-awake'
import { TIMER_DEFAULTS, TICK_INTERVAL_MS } from '@/src/shared/constants/timer'
import type {
TimerConfig,
TimerEngine,
TimerEvent,
TimerEventListener,
TimerPhase,
} from '../types'
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
IDLE: ['GET_READY'],
GET_READY: ['WORK', 'IDLE'],
WORK: ['REST', 'IDLE'],
REST: ['WORK', 'COMPLETE', 'IDLE'],
COMPLETE: ['IDLE'],
}
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
return VALID_TRANSITIONS[from].includes(to)
}
function buildDefaultConfig(): TimerConfig {
return {
workDuration: TIMER_DEFAULTS.WORK_DURATION,
restDuration: TIMER_DEFAULTS.REST_DURATION,
rounds: TIMER_DEFAULTS.ROUNDS,
getReadyDuration: TIMER_DEFAULTS.GET_READY_DURATION,
cycles: TIMER_DEFAULTS.CYCLES,
cyclePauseDuration: TIMER_DEFAULTS.CYCLE_PAUSE_DURATION,
}
}
function getDurationForPhase(phase: TimerPhase, config: TimerConfig): number {
switch (phase) {
case 'GET_READY':
return config.getReadyDuration
case 'WORK':
return config.workDuration
case 'REST':
return config.restDuration
default:
return 0
}
}
export function useTimerEngine(): TimerEngine {
// --- UI state (triggers re-renders) ---
const [phase, setPhase] = useState<TimerPhase>('IDLE')
const [secondsLeft, setSecondsLeft] = useState(0)
const [currentRound, setCurrentRound] = useState(0)
const [currentCycle, setCurrentCycle] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [totalElapsedSeconds, setTotalElapsedSeconds] = useState(0)
const [config, setConfig] = useState<TimerConfig>(buildDefaultConfig)
// --- Refs for time-critical values (no stale closures) ---
const targetEndTimeRef = useRef(0)
const remainingMsRef = useRef(0)
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const phaseRef = useRef<TimerPhase>('IDLE')
const currentRoundRef = useRef(0)
const currentCycleRef = useRef(0)
const configRef = useRef<TimerConfig>(buildDefaultConfig())
const isRunningRef = useRef(false)
const isPausedRef = useRef(false)
const elapsedRef = useRef(0)
const lastTickTimeRef = useRef(0)
const lastEmittedSecondRef = useRef(-1)
const listenersRef = useRef<Set<TimerEventListener>>(new Set())
// --- Helpers ---
function emit(event: TimerEvent): void {
listenersRef.current.forEach((listener) => {
try {
listener(event)
} catch (e) {
if (__DEV__) console.warn('[TimerEngine] Listener error:', e)
}
})
}
function clearTick(): void {
if (tickRef.current !== null) {
clearTimeout(tickRef.current)
tickRef.current = null
}
}
function transitionTo(nextPhase: TimerPhase): void {
const prevPhase = phaseRef.current
if (!canTransition(prevPhase, nextPhase)) {
if (__DEV__) {
console.warn(
`[TimerEngine] Invalid transition: ${prevPhase}${nextPhase}`
)
}
return
}
if (__DEV__) {
console.log(`[TimerEngine] ${prevPhase}${nextPhase}`)
}
phaseRef.current = nextPhase
setPhase(nextPhase)
emit({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
const duration = getDurationForPhase(nextPhase, configRef.current)
if (duration > 0) {
startCountdown(duration)
}
}
function startCountdown(durationSeconds: number): void {
clearTick()
targetEndTimeRef.current = Date.now() + durationSeconds * 1000
lastEmittedSecondRef.current = -1
scheduleTick()
}
function scheduleTick(): void {
tickRef.current = setTimeout(tick, TICK_INTERVAL_MS)
}
function tick(): void {
const now = Date.now()
// Accumulate elapsed time
if (lastTickTimeRef.current > 0) {
const delta = (now - lastTickTimeRef.current) / 1000
elapsedRef.current += delta
setTotalElapsedSeconds(Math.floor(elapsedRef.current))
}
lastTickTimeRef.current = now
const remainingMs = Math.max(0, targetEndTimeRef.current - now)
const seconds = Math.ceil(remainingMs / 1000)
setSecondsLeft(seconds)
// Emit COUNTDOWN_TICK for the last 3 seconds (once per second)
if (seconds <= 3 && seconds > 0 && seconds !== lastEmittedSecondRef.current) {
lastEmittedSecondRef.current = seconds
emit({ type: 'COUNTDOWN_TICK', secondsLeft: seconds })
}
if (remainingMs <= 0) {
advancePhase()
} else {
scheduleTick()
}
}
function advancePhase(): void {
clearTick()
const current = phaseRef.current
const cfg = configRef.current
switch (current) {
case 'GET_READY':
currentRoundRef.current = 1
setCurrentRound(1)
transitionTo('WORK')
break
case 'WORK':
transitionTo('REST')
break
case 'REST': {
const round = currentRoundRef.current
emit({ type: 'ROUND_COMPLETED', round })
if (round < cfg.rounds) {
// Next round
currentRoundRef.current = round + 1
setCurrentRound(round + 1)
transitionTo('WORK')
} else if (currentCycleRef.current < cfg.cycles) {
// Next cycle (Premium, future) — for V1 cycles=1, so this is dead code
currentCycleRef.current += 1
setCurrentCycle(currentCycleRef.current)
currentRoundRef.current = 1
setCurrentRound(1)
transitionTo('WORK')
} else {
// Session complete
const totalSeconds = Math.floor(elapsedRef.current)
setTotalElapsedSeconds(totalSeconds)
emit({ type: 'SESSION_COMPLETE', totalSeconds })
transitionTo('COMPLETE')
setIsRunning(false)
isRunningRef.current = false
}
break
}
default:
break
}
}
// --- Actions ---
const start = useCallback((overrides?: Partial<TimerConfig>) => {
if (phaseRef.current !== 'IDLE') return
const cfg: TimerConfig = { ...buildDefaultConfig(), ...overrides }
configRef.current = cfg
setConfig(cfg)
// Reset all state
currentRoundRef.current = 0
currentCycleRef.current = 1
elapsedRef.current = 0
lastTickTimeRef.current = Date.now()
lastEmittedSecondRef.current = -1
setCurrentRound(0)
setCurrentCycle(1)
setTotalElapsedSeconds(0)
setIsRunning(true)
setIsPaused(false)
isRunningRef.current = true
isPausedRef.current = false
transitionTo('GET_READY')
}, [])
const pause = useCallback(() => {
if (!isRunningRef.current || isPausedRef.current) return
clearTick()
remainingMsRef.current = Math.max(0, targetEndTimeRef.current - Date.now())
isPausedRef.current = true
setIsPaused(true)
setIsRunning(false)
isRunningRef.current = false
if (__DEV__) {
console.log('[TimerEngine] Paused, remaining:', remainingMsRef.current, 'ms')
}
}, [])
const resume = useCallback(() => {
if (!isPausedRef.current) return
targetEndTimeRef.current = Date.now() + remainingMsRef.current
lastTickTimeRef.current = Date.now()
isPausedRef.current = false
isRunningRef.current = true
setIsPaused(false)
setIsRunning(true)
scheduleTick()
if (__DEV__) {
console.log('[TimerEngine] Resumed')
}
}, [])
const stop = useCallback(() => {
if (phaseRef.current === 'IDLE') return
clearTick()
phaseRef.current = 'IDLE'
isRunningRef.current = false
isPausedRef.current = false
lastTickTimeRef.current = 0
setPhase('IDLE')
setSecondsLeft(0)
setCurrentRound(0)
setCurrentCycle(0)
setIsRunning(false)
setIsPaused(false)
if (__DEV__) {
console.log('[TimerEngine] Stopped')
}
}, [])
const skip = useCallback(() => {
if (phaseRef.current === 'IDLE' || phaseRef.current === 'COMPLETE') return
if (!isRunningRef.current && !isPausedRef.current) return
// If paused, un-pause first
if (isPausedRef.current) {
isPausedRef.current = false
isRunningRef.current = true
setIsPaused(false)
setIsRunning(true)
}
clearTick()
advancePhase()
}, [])
const addEventListener = useCallback((listener: TimerEventListener) => {
listenersRef.current.add(listener)
return () => {
listenersRef.current.delete(listener)
}
}, [])
// --- AppState: auto-pause on interruption ---
// iOS: active → inactive (phone call, control center) → background
// Android: active → background (directly)
// With auto-pause, we pause on any departure from 'active'.
// User must manually resume — no reconcile needed.
useEffect(() => {
const handleAppState = (nextState: AppStateStatus) => {
if (nextState !== 'active') {
if (isRunningRef.current && !isPausedRef.current) {
pause()
if (__DEV__) {
console.log(`[TimerEngine] Auto-paused (${nextState})`)
}
}
}
}
const subscription = AppState.addEventListener('change', handleAppState)
return () => subscription.remove()
}, [pause])
// --- Keep-awake ---
useEffect(() => {
if (isRunning || isPaused) {
activateKeepAwakeAsync('tabata-session')
} else {
deactivateKeepAwake('tabata-session')
}
}, [isRunning, isPaused])
// --- Cleanup on unmount ---
useEffect(() => {
return () => {
clearTick()
deactivateKeepAwake('tabata-session')
}
}, [])
return {
phase,
secondsLeft,
currentRound,
totalRounds: config.rounds,
currentCycle,
totalCycles: config.cycles,
isRunning,
isPaused,
totalElapsedSeconds,
start,
pause,
resume,
stop,
skip,
addEventListener,
config,
}
}

View File

@@ -0,0 +1,12 @@
export { useTimerEngine } from './hooks/useTimerEngine'
export { TimerDisplay } from './components/TimerDisplay'
export { TimerControls } from './components/TimerControls'
export type {
TimerPhase,
TimerConfig,
TimerState,
TimerActions,
TimerEvent,
TimerEventListener,
TimerEngine,
} from './types'

View File

@@ -0,0 +1,43 @@
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerConfig {
workDuration: number
restDuration: number
rounds: number
getReadyDuration: number
cycles: number
cyclePauseDuration: number
}
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number
totalRounds: number
currentCycle: number
totalCycles: number
isRunning: boolean
isPaused: boolean
totalElapsedSeconds: number
}
export interface TimerActions {
start: (config?: Partial<TimerConfig>) => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void
}
export type TimerEvent =
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
| { type: 'ROUND_COMPLETED'; round: number }
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
| { type: 'COUNTDOWN_TICK'; secondsLeft: number }
export type TimerEventListener = (event: TimerEvent) => void
export interface TimerEngine extends TimerState, TimerActions {
addEventListener: (listener: TimerEventListener) => () => void
config: TimerConfig
}

View File

@@ -0,0 +1,7 @@
export const PHASE_COLORS = {
IDLE: '#1E1E2E',
GET_READY: '#EAB308',
WORK: '#F97316',
REST: '#3B82F6',
COMPLETE: '#22C55E',
} as const

View File

@@ -0,0 +1,10 @@
export const TIMER_DEFAULTS = {
WORK_DURATION: 20,
REST_DURATION: 10,
ROUNDS: 8,
GET_READY_DURATION: 10,
CYCLES: 1,
CYCLE_PAUSE_DURATION: 60,
} as const
export const TICK_INTERVAL_MS = 100

View File

@@ -0,0 +1,8 @@
export function formatTime(totalSeconds: number): string {
const mins = Math.floor(totalSeconds / 60)
const secs = totalSeconds % 60
if (mins > 0) {
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return `${secs}`
}