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:
349
.claude/skills/timer/SKILL.md
Normal file
349
.claude/skills/timer/SKILL.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Skill — Timer Engine (useTimerEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Le timer est le cœur battant de l'app. Il doit être :
|
||||
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
|
||||
- **Résilient** : continue en background, survit aux appels téléphoniques
|
||||
- **Découplé** : aucune dépendance vers l'audio ou les exercices
|
||||
- **Testable** : logique pure, facile à tester sans simulateur
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module timer
|
||||
|
||||
```
|
||||
src/features/timer/
|
||||
types.ts ← Commencer ICI
|
||||
hooks/
|
||||
useTimerEngine.ts ← Moteur central (logique pure)
|
||||
useTimerSync.ts ← Pont vers audio + exercices
|
||||
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
|
||||
components/
|
||||
TimerDisplay.tsx ← Écran plein écran (props-only)
|
||||
TimerControls.tsx ← Boutons start/pause/stop/skip
|
||||
TimerPhaseIndicator.tsx ← Barre de progression des rounds
|
||||
TimerRing.tsx ← Anneau circulaire animé
|
||||
index.ts ← Barrel export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types — commencer par ici
|
||||
|
||||
```typescript
|
||||
// src/features/timer/types.ts
|
||||
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // défaut : 20s
|
||||
restDuration: number // défaut : 10s
|
||||
rounds: number // défaut : 8
|
||||
getReadyDuration: number // défaut : 10s
|
||||
cycles: number // défaut : 1 (Premium : jusqu'à 10)
|
||||
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number // 1-indexé
|
||||
totalRounds: number
|
||||
currentCycle: number // 1-indexé
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number // pour le résumé de séance
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config: TimerConfig) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void // passer à la phase suivante immédiatement
|
||||
}
|
||||
|
||||
// Événements émis par le timer — consommés par useTimerSync
|
||||
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 } // pour les dernières 3s
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useTimerEngine
|
||||
|
||||
### Principe de précision : Date.now() delta
|
||||
|
||||
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
|
||||
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
|
||||
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
|
||||
|
||||
```typescript
|
||||
// ✅ Approche correcte — delta sur Date.now()
|
||||
const tickRef = useRef<number | null>(null)
|
||||
const targetEndTimeRef = useRef<number>(0)
|
||||
|
||||
function tick() {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - now)
|
||||
const secondsLeft = Math.ceil(remaining / 1000)
|
||||
|
||||
setSecondsLeft(secondsLeft)
|
||||
|
||||
if (remaining <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
// Planifier le prochain tick dans ~100ms (pas 1000ms)
|
||||
// pour une réactivité maximale sur les transitions
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function startPhase(duration: number) {
|
||||
targetEndTimeRef.current = Date.now() + duration * 1000
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
```
|
||||
|
||||
### Gestion du background (AppState)
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
// Sauvegarder le timestamp cible — pas l'état courant
|
||||
// Quand on revient en foreground, on recalcule
|
||||
saveBackgroundTimestamp(targetEndTimeRef.current)
|
||||
}
|
||||
|
||||
if (nextState === 'active') {
|
||||
const savedTarget = loadBackgroundTimestamp()
|
||||
if (savedTarget && isRunningRef.current) {
|
||||
// Recalculer le temps restant depuis le timestamp sauvegardé
|
||||
targetEndTimeRef.current = savedTarget
|
||||
// Si le temps est dépassé, avancer aux phases manquées
|
||||
reconcilePhaseAfterBackground(savedTarget, Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
```
|
||||
|
||||
### Notification sticky pendant la séance
|
||||
|
||||
Utiliser expo-notifications pour afficher le décompte en background :
|
||||
|
||||
```typescript
|
||||
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: 'tabata-timer-sticky',
|
||||
content: {
|
||||
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
|
||||
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Ordre des transitions de phase
|
||||
|
||||
```
|
||||
IDLE
|
||||
↓ start()
|
||||
GET_READY (getReadyDuration)
|
||||
↓ temps écoulé
|
||||
WORK (workDuration)
|
||||
↓ temps écoulé
|
||||
REST (restDuration)
|
||||
↓ temps écoulé
|
||||
→ si currentRound < totalRounds : retour à WORK
|
||||
→ si currentRound === totalRounds ET currentCycle < totalCycles :
|
||||
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
|
||||
→ si currentRound === totalRounds ET currentCycle === totalCycles :
|
||||
COMPLETE
|
||||
↓ stop() ou auto-reset après 3s
|
||||
IDLE
|
||||
```
|
||||
|
||||
### Événements à émettre aux abonnés
|
||||
|
||||
```typescript
|
||||
// À chaque changement de phase
|
||||
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
// À chaque fin de round
|
||||
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
|
||||
|
||||
// Décompte final (3, 2, 1)
|
||||
if (secondsLeft <= 3) {
|
||||
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
|
||||
}
|
||||
|
||||
// Fin de séance
|
||||
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interface publique du hook
|
||||
|
||||
```typescript
|
||||
export function useTimerEngine(): TimerState & TimerActions & {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
```
|
||||
|
||||
Le retour doit permettre :
|
||||
```typescript
|
||||
const {
|
||||
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
|
||||
start, pause, resume, stop, skip,
|
||||
addEventListener,
|
||||
config,
|
||||
} = useTimerEngine()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant TimerDisplay — layout plein écran
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ← StatusBar cachée
|
||||
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
|
||||
│ └───────┘ │ ← Police monospace, taille ~120px
|
||||
│ │
|
||||
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
|
||||
│ │
|
||||
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Props obligatoires du composant :
|
||||
```typescript
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Animations requises
|
||||
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
|
||||
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
|
||||
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
|
||||
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
|
||||
- **Décompte final** : flash rouge sur les 3 dernières secondes
|
||||
|
||||
```typescript
|
||||
// Interpolation de couleur entre phases
|
||||
const backgroundAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const backgroundColor = backgroundAnim.interpolate({
|
||||
inputRange: [0, 1, 2, 3],
|
||||
outputRange: [
|
||||
PHASE_COLORS.IDLE,
|
||||
PHASE_COLORS.GET_READY,
|
||||
PHASE_COLORS.WORK,
|
||||
PHASE_COLORS.REST,
|
||||
],
|
||||
})
|
||||
|
||||
// Déclencher quand la phase change
|
||||
useEffect(() => {
|
||||
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
|
||||
Animated.timing(backgroundAnim, {
|
||||
toValue: phaseIndex[phase],
|
||||
duration: 600,
|
||||
useNativeDriver: false, // obligatoire pour backgroundColor
|
||||
}).start()
|
||||
}, [phase])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## expo-keep-awake — écran allumé pendant la séance
|
||||
|
||||
```typescript
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
// Dans useTimerEngine
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux à écrire
|
||||
|
||||
```typescript
|
||||
describe('useTimerEngine', () => {
|
||||
it('démarre en phase IDLE avec la config par défaut', () => {})
|
||||
it('passe à GET_READY quand start() est appelé', () => {})
|
||||
it('passe à WORK après la fin de GET_READY', () => {})
|
||||
it('passe à REST après la fin de WORK', () => {})
|
||||
it('incrémente currentRound après chaque REST', () => {})
|
||||
it('passe à COMPLETE après le dernier round', () => {})
|
||||
it('retourne à IDLE après stop()', () => {})
|
||||
it('skip() avance immédiatement à la phase suivante', () => {})
|
||||
it('émet PHASE_CHANGED à chaque transition', () => {})
|
||||
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
|
||||
it('cleanup useEffect enlève tous les listeners', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Drift — setInterval seul
|
||||
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
|
||||
|
||||
// ✅ Delta sur Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
setSecondsLeft(Math.ceil(remaining / 1000))
|
||||
|
||||
// ❌ State React pour les valeurs temps-critique
|
||||
const [targetEndTime, setTargetEndTime] = useState(0)
|
||||
|
||||
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
|
||||
const targetEndTimeRef = useRef(0)
|
||||
|
||||
// ❌ Pas de cleanup — memory leak
|
||||
useEffect(() => {
|
||||
setTimeout(tick, 100)
|
||||
})
|
||||
|
||||
// ✅ Cleanup systématique
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
Reference in New Issue
Block a user