# 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: --layer=/`. - **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.