# StreakFit — Modèle de domaine (spec OSDD)

App de cross-training / suivi muscu. **Concept central = le _streak_** (régularité : jours d'entraînement
consécutifs, gamification à la Duolingo). Stack : Laravel 13 + OSDD (`xefi/laravel-osdd`) + Octane/FrankenPHP,
Filament 5 (admin), Sanctum (auth cookie SPA), lomkit REST API + access-control, MySQL (mysql-shared), RustFS/S3 (médias).

## Règles d'architecture (NON négociables)
- **Le front ne contient AUCUNE logique métier, même l'auth.** Tout est calculé/validé côté back ; l'API renvoie
  du prêt-à-afficher (streak courant, PR, progression, prochaine séance, instructions dans la locale). Auth = cookie
  de session Sanctum, flow OIDC piloté 100% par le back.
- **OSDD** : une couche = un package `type: layer` sous `functional/` (métier) ou `technical/` (infra), autonome
  (`src/`, `database/migrations|factories|seeders`, `routes/`, provider `extends LayerServiceProvider`). Placer chaque
  fichier via `php artisan osdd:<gen> --layer=<functional|technical>/<nom>`.
- **i18n** : fr, pt-BR, en. Instructions d'exos = en+fr (dataset), pt-BR retombe sur en/fr.
- FK toujours vers `users(id)` (table possédée par `functional/users`). Ne pas recréer la table users.
- Pattern de référence = `functional/exercises` (déjà fait : model + migration + config + import command).

## Couches & schéma

### functional/users  (existe — auth)
- `users` : + `locale` (string, def 'fr'), `units` enum('metric','imperial') def 'metric', `timezone` (string, nullable),
  `avatar_path` (nullable), `oidc_sub` (string nullable, unique). Profil léger sur le user.

### functional/exercises  (FAIT — lecture seule, catalogue)
- `exercises` : external_id(unique), name, slug, category, body_part, equipment, target, muscle_group,
  secondary_muscles(json), instructions(json {en:[],fr:[]}), image_path, gif_path, attribution.

### functional/routines
Une routine = liste ordonnée d'exercices avec séries/reps/repos.
- `routines` : user_id(fk), name, slug, description(text null), is_public(bool def false), estimated_minutes(int null).
- `routine_exercises` (pivot enrichi) : routine_id(fk), exercise_id(fk), position(int), sets(int), target_reps(string, ex "8-12"),
  rest_seconds(int null), tempo(string null), target_weight(decimal 8,2 null), notes(text null).
- Relations : Routine belongsTo User ; Routine hasMany RoutineExercise (ordered by position) ; RoutineExercise belongsTo Exercise.

### functional/programs
Un programme = planning de routines sur plusieurs semaines.
- `programs` : user_id(fk), name, slug, description(text null), duration_weeks(int), days_per_week(int null), is_public(bool def false).
- `program_slots` : program_id(fk), week(int, 1..duration_weeks), weekday(tinyint 1..7), routine_id(fk null), label(string null).
- Relations : Program belongsTo User ; Program hasMany ProgramSlot ; ProgramSlot belongsTo Routine.

### functional/tracking
Séances réalisées + sets loggés + records + mensurations.
- `workout_sessions` : user_id(fk), routine_id(fk null), program_id(fk null), title(string null), started_at(datetime),
  ended_at(datetime null), status enum('active','completed','abandoned') def 'active', notes(text null), total_volume(decimal 10,2 null).
- `set_logs` : workout_session_id(fk), exercise_id(fk), set_number(int), reps(int null), weight(decimal 8,2 null),
  rpe(decimal 3,1 null), duration_seconds(int null), distance_meters(decimal 8,2 null), is_warmup(bool def false),
  is_completed(bool def true), logged_at(datetime).
- `personal_records` : user_id(fk), exercise_id(fk), type enum('1rm','max_weight','max_reps','max_volume','best_time'),
  value(decimal 10,2), unit(string), achieved_at(datetime), workout_session_id(fk null). unique(user_id,exercise_id,type).
- `body_metrics` : user_id(fk), measured_on(date), weight(decimal 6,2 null), body_fat(decimal 5,2 null), notes(text null). unique(user_id,measured_on).
- **Événement** : à la complétion d'une WorkoutSession, dispatch `WorkoutCompleted(session)` (listener côté streaks).

### functional/streaks  (LE CONCEPT)
- `streaks` (1 par user) : user_id(fk, unique), current_count(int def 0), longest_count(int def 0),
  last_active_date(date null), freezes_available(int def 0), freeze_last_granted_on(date null).
- `streak_days` : user_id(fk), date(date), source enum('workout','manual','freeze','rest'),
  workout_session_id(fk null). unique(user_id,date).
- `goals` : user_id(fk), type enum('weekly_sessions','weekly_minutes','weekly_volume'), target(int),
  period enum('week') def 'week', is_active(bool def true).
- `achievements` (badges) : user_id(fk), key(string, ex 'streak_7','streak_30','first_workout'), earned_at(datetime),
  meta(json null). unique(user_id,key).
- **Logique (service `StreakService`, côté back)** : listener sur `WorkoutCompleted` → enregistre un `streak_day`
  (source=workout) pour la date → recalcule : si last_active_date == veille → current++ ; == aujourd'hui → no-op ;
  sinon si un freeze couvre le trou → consomme ; sinon reset à 1. Met à jour longest. Débloque les achievements aux paliers.

### technical/integrations  (EXTENSIBILITÉ Garmin/Strava)
Générique : aucune référence en dur à un provider. Garmin/Strava = futures couches `functional/strava`, `functional/garmin`
qui implémentent le contrat et s'enregistrent dans le registre.
- `integrations` (comptes connectés) : user_id(fk), provider(string), external_user_id(string null),
  access_token(text null, cast encrypted), refresh_token(text null, cast encrypted), token_expires_at(datetime null),
  scopes(json null), status enum('connected','revoked','error') def 'connected', connected_at(datetime), last_synced_at(datetime null), meta(json null). unique(user_id,provider).
- `external_activities` : integration_id(fk), user_id(fk), provider(string), external_id(string), type(string),
  started_at(datetime), duration_seconds(int null), distance_meters(decimal 10,2 null), calories(int null), raw(json null). unique(provider,external_id).
- **Contrats** (`src/Contracts/`) : interface `ActivityProvider` { key():string; label():string; connectUrl(User):string;
  handleCallback(User,array):Integration; fetchActivities(Integration, ?Carbon $since):iterable; } — et un singleton
  `ProviderRegistry` { register(ActivityProvider), get(string):?ActivityProvider, all():array }. Enregistré dans le provider de la couche.

## API (lomkit REST) — fait plus tard, couche par couche
Resources lomkit sous `src/Rest/Resources`, contrôlées par access-control (chaque user ne voit que ses données ;
exercises = public en lecture). Endpoints REST search/mutate. Vues "prêtes à afficher" (dashboard streak, séance courante)
exposées via contrôleurs dédiés quand un simple CRUD ne suffit pas.
