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,369 @@
# Skill — Audio Engine (useAudioEngine)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
## Responsabilité de ce module
L'audio engine gère 4 couches sonores indépendantes qui s'activent
en réponse aux événements du timer. Il ne connaît pas le timer
directement — il s'abonne à ses événements via useTimerSync.
**Objectif principal : zéro latence audio sur les transitions de phase.**
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
---
## Architecture du module audio
```
src/features/audio/
types.ts
hooks/
useAudioEngine.ts ← moteur central expo-av
useAudioSettings.ts ← préférences utilisateur persistées
data/
tracks.ts ← catalogue musique (offline, paths locaux)
sounds.ts ← catalogue signaux de phase
components/
MusicPicker.tsx ← sélecteur d'ambiance musicale
SoundPicker.tsx ← sélecteur de signal de phase
AudioSettingsPanel.tsx ← panneau complet des réglages
index.ts
```
---
## Types
```typescript
// src/features/audio/types.ts
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
export interface AudioTrack {
id: string
ambiance: MusicAmbiance
intensity: MusicIntensity
asset: number // require('../assets/audio/...') — offline
bpm: number
durationMs: number
}
export interface AudioSettings {
musicEnabled: boolean
ambiance: MusicAmbiance
musicVolume: number // 0.0 → 1.0
soundsEnabled: boolean
soundsVolume: number // 0.0 → 1.0
hapticsEnabled: boolean
voiceEnabled: boolean // annonces vocales
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
workPhaseSound: PhaseSound
restPhaseSound: PhaseSound
countdownSound: PhaseSound
completionSound: PhaseSound
}
export interface AudioState {
isLoaded: boolean
isMusicPlaying: boolean
currentAmbiance: MusicAmbiance
currentIntensity: MusicIntensity
error: string | null
}
export interface AudioActions {
preloadAll: () => Promise<void>
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
stopMusic: (fadeMs?: number) => Promise<void>
playPhaseSound: (sound: PhaseSound) => Promise<void>
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
unloadAll: () => Promise<void>
}
```
---
## Implémentation — useAudioEngine
### Configuration de la session audio iOS/Android
**Critique à faire en premier, avant tout chargement de son.**
```typescript
import { Audio } from 'expo-av'
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
export async function configureAudioSession(): Promise<void> {
await Audio.setAudioModeAsync({
// iOS : jouer par-dessus la musique de l'utilisateur
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
allowsRecordingIOS: false,
staysActiveInBackground: true, // CRITIQUE — timer continue en background
// Android
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
playThroughEarpieceAndroid: false,
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
interruptionModeAndroid: 1,
})
}
```
### Preloading — charger AVANT la séance
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
Si on charge pendant la séance → latence inacceptable.
```typescript
const soundRefs = useRef<Record<string, Audio.Sound>>({})
async function preloadAll(settings: AudioSettings): Promise<void> {
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
const trackWork = getTrack(settings.ambiance, 'HIGH')
const trackRest = getTrack(settings.ambiance, 'LOW')
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
// 2. Charger tous les signaux de phase
const soundAssets = getSoundAssets(settings)
for (const [key, asset] of Object.entries(soundAssets)) {
soundRefs.current[key] = await loadSound(asset)
}
// 3. Mettre en loop les tracks musicales
await soundRefs.current['music_work'].setIsLoopingAsync(true)
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
}
async function loadSound(asset: number): Promise<Audio.Sound> {
const { sound } = await Audio.Sound.createAsync(asset, {
shouldPlay: false,
volume: 1.0,
})
return sound
}
```
### Crossfade entre phases
```typescript
async function switchIntensity(
to: MusicIntensity,
fadeMs: number = 500
): Promise<void> {
const from = currentIntensityRef.current
if (from === to) return
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
const outSound = soundRefs.current[outKey]
const inSound = soundRefs.current[inKey]
if (!outSound || !inSound) return
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
await inSound.setPositionAsync(0)
await inSound.setVolumeAsync(0)
await inSound.playAsync()
// Fade simultané
const steps = 10
const stepMs = fadeMs / steps
const volumeStep = settings.musicVolume / steps
for (let i = 0; i <= steps; i++) {
await Promise.all([
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
])
await delay(stepMs)
}
await outSound.stopAsync()
currentIntensityRef.current = to
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
```
### Répondre aux événements timer
```typescript
// Dans useTimerSync — pont entre timer et audio
import { useTimerEngine } from '../timer'
import { useAudioEngine } from '../audio'
export function useTimerSync() {
const timer = useTimerEngine()
const audio = useAudioEngine()
useEffect(() => {
const unsubscribe = timer.addEventListener((event) => {
switch (event.type) {
case 'PHASE_CHANGED':
handlePhaseChange(event.from, event.to)
break
case 'COUNTDOWN_TICK':
audio.playCountdown(event.secondsLeft)
break
case 'SESSION_COMPLETE':
audio.playPhaseSound('completion')
audio.stopMusic(1000)
break
}
})
return unsubscribe
}, [])
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
switch (to) {
case 'GET_READY':
await audio.startMusic(settings.ambiance, 'MEDIUM')
break
case 'WORK':
await audio.playPhaseSound(settings.workPhaseSound)
await audio.switchIntensity('HIGH', 500)
break
case 'REST':
await audio.playPhaseSound(settings.restPhaseSound)
await audio.switchIntensity('LOW', 500)
break
}
}
}
```
---
## Catalogue des tracks (offline)
```typescript
// src/features/audio/data/tracks.ts
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
export const TRACKS: AudioTrack[] = [
{
id: 'electro_high',
ambiance: 'ELECTRO',
intensity: 'HIGH',
asset: require('../../../assets/audio/music/electro_high.mp3'),
bpm: 140,
durationMs: 180000,
},
{
id: 'electro_low',
ambiance: 'ELECTRO',
intensity: 'LOW',
asset: require('../../../assets/audio/music/electro_low.mp3'),
bpm: 90,
durationMs: 180000,
},
// ... (9 tracks au total)
]
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
return track
}
```
## Catalogue des sons de phase
```typescript
// src/features/audio/data/sounds.ts
export const PHASE_SOUNDS = {
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
bell: require('../../../assets/audio/sounds/bell.mp3'),
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
clap: require('../../../assets/audio/sounds/applause.mp3'),
}
```
---
## Gestion du switch silencieux iOS
```typescript
// Si overrideMuteSwitch = true dans les settings
await Audio.setAudioModeAsync({
playsInSilentModeIOS: settings.overrideMuteSwitch,
})
```
**Règle UX :** Les signaux de phase ont l'override activé par défaut
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
La musique d'ambiance respecte le switch silencieux par défaut.
---
## Cleanup obligatoire
```typescript
// Dans useAudioEngine — cleanup au unmount ou fin de séance
async function unloadAll(): Promise<void> {
await Promise.all(
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
)
soundRefs.current = {}
}
useEffect(() => {
return () => {
// Cleanup synchrone au unmount
Object.values(soundRefs.current).forEach(sound => {
sound.unloadAsync().catch(console.error)
})
}
}, [])
```
---
## Tests minimaux
```typescript
describe('useAudioEngine', () => {
it('configure la session audio au mount', () => {})
it('preloadAll charge les sons sans erreur', () => {})
it('playPhaseSound joue le bon son dans les 50ms', () => {})
it('switchIntensity effectue un crossfade', () => {})
it('stopMusic fait un fade-out', () => {})
it('unloadAll libère toutes les ressources', () => {})
it('ne plante pas si un asset est manquant (fallback)', () => {})
})
```
---
## Erreurs classiques à éviter
```typescript
// ❌ Charger les sons à la demande → latence
case 'PHASE_CHANGED':
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
await sound.playAsync()
// ✅ Utiliser les sons préchargés
case 'PHASE_CHANGED':
await soundRefs.current['work_start'].replayAsync() // ← instantané
// ❌ Oublier de configurer la session audio → muet sur iOS background
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
```

View File

@@ -0,0 +1,428 @@
# Skill — Exercices (useExercise + ExerciseDisplay)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
## Responsabilité de ce module
Gérer la bibliothèque d'exercices, la sélection selon le programme,
et l'affichage contextuel selon la phase du timer.
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
---
## Architecture du module exercices
```
src/features/exercises/
types.ts
hooks/
useExercise.ts ← sélection + navigation entre exercices
useExerciseLibrary.ts ← recherche, filtrage, favoris
components/
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
ExerciseCard.tsx ← carte pour la bibliothèque/programme
ExerciseCues.tsx ← affichage des cues de forme
data/
exercises.ts ← les 38 exercices (source de vérité)
categories.ts ← définition des catégories
index.ts
```
---
## Types
```typescript
// src/features/exercises/types.ts
export type ExerciseCategory =
| 'CARDIO'
| 'LOWER_BODY'
| 'UPPER_BODY'
| 'CORE'
| 'LOW_IMPACT'
| 'EQUIPMENT'
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
export interface Exercise {
id: string
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
category: ExerciseCategory
difficulty: ExerciseDifficulty
musclesTargeted: string[]
description: Record<string, string> // max 80 caractères par langue
cues: Record<string, string[]> // 2-3 points clés de forme par langue
gifAsset: number | null // require(...) ou null si pas de GIF
thumbnailAsset: number | null
hasModification: boolean // variante plus facile disponible
modificationId: string | null // id de l'exercice modifié
equipmentNeeded: string[] // [] = aucun matériel
estimatedCaloriesPerMinute: number // approximatif
}
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
export interface ExerciseProgram {
id: string
name: Record<string, string>
description: Record<string, string>
mode: ProgramMode
// Pour SINGLE : [exerciseId] — même exercice répété
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
exerciseIds: string[]
difficulty: ExerciseDifficulty
totalRounds: number
isDefault: boolean // programmes pré-définis vs créés par user
}
// Résout l'exercice pour un round donné selon le mode du programme
export interface ExerciseState {
current: Exercise
next: Exercise | null // null sur le dernier round
roundIndex: number // 0-indexé
}
```
---
## Données — les 38 exercices V1
Structure à respecter pour chaque exercice :
```typescript
// src/features/exercises/data/exercises.ts
import { Exercise } from '../types'
export const EXERCISES: Exercise[] = [
{
id: 'burpee_classic',
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
category: 'CARDIO',
difficulty: 'INTERMEDIATE',
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
description: {
fr: 'Position debout → squat → planche → pompe → saut vertical',
en: 'Stand → squat → plank → push-up → jump',
es: 'De pie → sentadilla → plancha → flexión → salto',
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
pt: 'Em pé → agachamento → prancha → flexão → salto',
},
cues: {
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
},
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
hasModification: true,
modificationId: 'burpee_modified',
equipmentNeeded: [],
estimatedCaloriesPerMinute: 12,
},
// ... 37 autres exercices
]
// Accès rapide par ID
export const EXERCISES_MAP = Object.fromEntries(
EXERCISES.map(ex => [ex.id, ex])
) as Record<string, Exercise>
export function getExerciseById(id: string): Exercise {
const ex = EXERCISES_MAP[id]
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
return ex
}
```
**Liste des 38 exercices V1 à implémenter :**
```
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
mountain_climbers, high_knees, jump_rope_sim,
box_jump_sim, lateral_shuffles
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
lunge_jump, glute_bridge, wall_sit, calf_raises,
sumo_squat
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
tricep_dip, shoulder_taps, inchworm
CORE (6) : crunch_classic, plank_hold, russian_twist,
bicycle_crunch, leg_raises, dead_bug
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
standing_oblique, slow_pushup, chair_stand
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
medicine_ball_slam
```
---
## Hook useExercise
```typescript
// src/features/exercises/hooks/useExercise.ts
export function useExercise(program: ExerciseProgram): {
getExerciseForRound: (round: number) => ExerciseState
currentExercise: Exercise
nextExercise: Exercise | null
} {
function getExerciseForRound(round: number): ExerciseState {
// round est 1-indexé
const index = round - 1
let currentId: string
let nextId: string | null
switch (program.mode) {
case 'SINGLE':
currentId = program.exerciseIds[0]
nextId = null // même exercice → pas de "suivant" différent
break
case 'DUO':
currentId = program.exerciseIds[index % 2]
nextId = program.exerciseIds[(index + 1) % 2]
break
case 'CIRCUIT_4':
currentId = program.exerciseIds[index % 4]
nextId = program.exerciseIds[(index + 1) % 4]
break
case 'FREE':
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
nextId = program.exerciseIds[index + 1] ?? null
break
}
return {
current: getExerciseById(currentId),
next: nextId ? getExerciseById(nextId) : null,
roundIndex: index,
}
}
return { getExerciseForRound, /* ... */ }
}
```
---
## Composant ExerciseDisplay — affichage contextuel
Le composant adapte son layout selon la phase du timer.
```typescript
interface ExerciseDisplayProps {
phase: TimerPhase
exercise: Exercise
nextExercise: Exercise | null
lang: string // code langue actuel
}
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
switch (phase) {
case 'GET_READY':
return <GetReadyView exercise={exercise} lang={lang} />
// ↑ GIF grand format + nom + "Prépare-toi !"
case 'WORK':
return <WorkView exercise={exercise} lang={lang} />
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
case 'REST':
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
default:
return null
}
}
```
### GetReadyView
```
┌─────────────────────────────┐
│ │
│ ┌─────────────────┐ │
│ │ [GIF 200x200] │ │ ← Grand GIF centré
│ └─────────────────┘ │
│ │
│ Burpees │ ← Nom, police bold 28px
│ ← Dos droit en planche │ ← Cue #1
│ ← Explose vers le haut │ ← Cue #2
│ │
└─────────────────────────────┘
```
### WorkView
```
┌─────────────────────────────┐
│ Burpees │ ← Nom, 22px, coin haut-gauche
│ ← Dos droit │ ← Cue #1, 16px
│ ← Explose vers le haut │ ← Cue #2, 16px
│ │
│ [Timer central] │
│ │
│ ┌────┐ │
│ │GIF │ │ ← GIF 80x80, coin bas-droit
│ └────┘ │
└─────────────────────────────┘
```
### RestView
```
┌─────────────────────────────┐
│ │
│ REPOS │ ← "REPOS" centré, grand
│ │
│ Prochain : Mountain │ ← Si nextExercise != null
│ Climbers │
│ ┌──────┐ │
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
│ └──────┘ │
└─────────────────────────────┘
```
---
## Composant ExerciseGif — robuste et accessible
```typescript
interface ExerciseGifProps {
exercise: Exercise
size: 'small' | 'medium' | 'large'
lang: string
}
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
const [hasError, setHasError] = useState(false)
const dimension = GIF_SIZES[size]
if (!exercise.gifAsset || hasError) {
// Fallback : icône + initiales de l'exercice
return (
<View
style={[styles.fallback, { width: dimension, height: dimension }]}
accessible={true}
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
>
<Text style={styles.fallbackIcon}>💪</Text>
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
</View>
)
}
return (
<Image
source={exercise.gifAsset}
style={{ width: dimension, height: dimension }}
onError={() => setHasError(true)}
accessible={true}
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
/>
)
}
```
---
## Localisation des exercices
```typescript
// Utilitaire pour extraire la bonne langue avec fallback
export function getLocalizedText(
record: Record<string, string>,
lang: string
): string {
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
}
// Utilisation
const name = getLocalizedText(exercise.name, userLang)
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
```
---
## Programmes pré-définis V1
```typescript
// src/features/exercises/data/defaultPrograms.ts
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
{
id: 'beginner_classic',
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
mode: 'SINGLE',
exerciseIds: ['squat_classic'],
difficulty: 'BEGINNER',
totalRounds: 8,
isDefault: true,
},
{
id: 'full_body_duo',
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
mode: 'DUO',
exerciseIds: ['squat_jump', 'pushup_classic'],
difficulty: 'INTERMEDIATE',
totalRounds: 8,
isDefault: true,
},
{
id: 'cardio_blast',
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
mode: 'CIRCUIT_4',
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
difficulty: 'ADVANCED',
totalRounds: 8,
isDefault: true,
},
// + programmes par objectif (perte de poids, force, low impact, etc.)
]
```
---
## Tests minimaux
```typescript
describe('useExercise', () => {
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
it('getExerciseById lance une erreur si id inconnu', () => {})
})
describe('ExerciseDisplay', () => {
it('affiche le GIF grand format en GET_READY', () => {})
it('affiche les cues en WORK', () => {})
it('affiche nextExercise en REST si disponible', () => {})
it('affiche le fallback si gifAsset est null', () => {})
it('est accessible (accessibilityLabel présent)', () => {})
})
```
---
## Checklist assets exercices
Avant de lancer en production, vérifier :
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
- [ ] Tous les `modificationId` pointent vers un exercice existant
- [ ] Bundle total des assets exercices < 10MB

View 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)
}
}, [])
```

View File

@@ -0,0 +1,233 @@
# Skill — Workflow de Développement TabataGo
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
## Principe fondamental
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
1. Analyse les specs dans le PRD (`docs/PRD.md`)
2. Liste les edge cases et les dépendances
3. Propose l'architecture (types, hooks, composants)
4. Attends validation avant de générer du code
Ne jamais écrire de code avant d'avoir une architecture approuvée
sur les features cœur (timer, audio, exercices).
---
## Cycle de développement par feature
### Étape 1 — Lire les specs
Avant toute implémentation, lis la section PRD correspondante.
Identifie exactement :
- Les entrées/sorties du système
- Les états possibles
- Les cas limites (interruptions, background, erreurs)
### Étape 2 — Typer en premier
Commence TOUJOURS par le fichier `types.ts` de la feature.
Les types sont le contrat — ils précèdent l'implémentation.
```typescript
// Exemple pour le timer — types.ts
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number
totalRounds: number
isRunning: boolean
}
export interface TimerConfig {
workDuration: number // secondes, défaut 20
restDuration: number // secondes, défaut 10
rounds: number // défaut 8
getReadyDuration: number // secondes, défaut 10
}
export interface TimerActions {
start: () => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void
}
```
**Ne passe à l'étape 3 que si les types semblent corrects.**
### Étape 3 — Implémenter le hook (logique métier)
Implémenter le hook central de la feature.
Règles :
- Aucun import depuis React Native UI dans les hooks métier
- Chaque hook expose un état + des actions (pattern Zustand-like)
- Les side effects sont dans useEffect avec cleanup systématique
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
### Étape 4 — Implémenter les composants
Les composants reçoivent tout par props — ils ne fetchent rien,
ne font aucune logique métier.
```typescript
// ✅ Correct
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
return <Text>{secondsLeft}</Text>
}
// ❌ Interdit
function TimerDisplay() {
const timer = useTimerEngine() // logique dans le composant
return <Text>{timer.secondsLeft}</Text>
}
```
### Étape 5 — Tester avant de continuer
Avant de passer à la feature suivante, écrire les tests minimaux :
- État initial correct
- Transitions de phase correctes
- Cleanup des side effects (pas de memory leak)
---
## Patterns obligatoires
### Gestion d'état avec state machine explicite
Toujours utiliser une state machine pour les flows complexes.
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
```typescript
// ✅ State machine explicite
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
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)
}
```
### Cleanup systématique des useEffect
```typescript
useEffect(() => {
const subscription = AppState.addEventListener('change', handleAppState)
return () => subscription.remove() // TOUJOURS un cleanup
}, [])
```
### AsyncStorage — wrapper typé uniquement
Ne jamais appeler AsyncStorage directement dans les composants.
Passer par un hook ou un service :
```typescript
// shared/utils/storage.ts
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
}
export async function loadTimerConfig(): Promise<TimerConfig | null> {
const raw = await AsyncStorage.getItem('timer_config')
return raw ? JSON.parse(raw) : null
}
```
### Constantes centralisées
```typescript
// shared/constants/timer.ts
export const TIMER_DEFAULTS = {
WORK_DURATION: 20,
REST_DURATION: 10,
ROUNDS: 8,
GET_READY_DURATION: 10,
} as const
// shared/constants/colors.ts
export const PHASE_COLORS = {
GET_READY: '#EAB308', // Jaune
WORK: '#F97316', // Orange
REST: '#3B82F6', // Bleu
COMPLETE: '#22C55E', // Vert
IDLE: '#1E1E2E', // Dark
} as const
```
---
## Gestion des erreurs
### Pattern try/catch dans les hooks async
```typescript
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
async function loadData() {
try {
setIsLoading(true)
setError(null)
const data = await fetchSomething()
setData(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur inconnue')
} finally {
setIsLoading(false)
}
}
```
### Fallback dans les composants
Tout composant qui charge des ressources (GIF, audio) doit avoir
un état de fallback visible et non-bloquant.
---
## Checklist avant de soumettre du code
Avant de considérer une feature terminée, vérifier :
- [ ] Les types sont définis dans `types.ts`
- [ ] La logique est dans des hooks, pas dans des composants
- [ ] Tous les `useEffect` ont un cleanup
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
- [ ] Les constantes sont dans `shared/constants/`
- [ ] Les fonctions async ont un try/catch
- [ ] Un test minimal existe pour les cas principaux
- [ ] Le barrel export `index.ts` est mis à jour
---
## Workflow de debugging avec les logs Expo
Pour surveiller les logs en temps réel et donner du contexte à Claude :
```bash
# Terminal 1 — démarrer expo avec logs dans un fichier
npx expo start 2>&1 | tee .expo-logs/dev.log
# Ensuite dans Claude Code
> Surveille .expo-logs/dev.log et identifie les warnings
liés au module [nom du module en cours]
```
Ajouter des logs structurés dans les hooks critiques :
```typescript
if (__DEV__) {
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
}
```
---
## Commandes Claude Code utiles
| Situation | Commande |
|---|---|
| Début de projet / onboarding | `/init` |
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
| Avant chaque commit | `/review` |
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |