i18n vocabulaire exos (fr/pt-BR) + Web Push rappels de série
Build & Deploy / build (push) Successful in 17s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:46:43 +02:00
co-authored by Claude Opus 4.8
parent af32669931
commit fa3e59704b
23 changed files with 954 additions and 31 deletions
@@ -0,0 +1,78 @@
<?php
/**
* Traduction du vocabulaire contrôlé des exercices (zones, équipement, muscles, catégories).
* La DB reste en anglais (canonique) ; l'API traduit à la sortie selon la locale.
* Map à plat term(anglais, minuscule) => traduction. Repli = terme anglais si absent.
*/
return [
'fr' => [
// zones / catégories
'back' => 'dos', 'cardio' => 'cardio', 'chest' => 'pectoraux', 'lower arms' => 'avant-bras',
'lower legs' => 'mollets', 'neck' => 'cou', 'shoulders' => 'épaules', 'upper arms' => 'bras',
'upper legs' => 'cuisses', 'waist' => 'abdominaux',
// équipement
'assisted' => 'assisté', 'band' => 'élastique', 'barbell' => 'barre', 'body weight' => 'poids du corps',
'bosu ball' => 'bosu', 'cable' => 'poulie', 'dumbbell' => 'haltère', 'elliptical machine' => 'vélo elliptique',
'ez barbell' => 'barre EZ', 'hammer' => 'hammer', 'kettlebell' => 'kettlebell',
'leverage machine' => 'machine à levier', 'medicine ball' => 'medecine ball',
'olympic barbell' => 'barre olympique', 'resistance band' => 'bande de résistance', 'roller' => 'rouleau',
'rope' => 'corde', 'skierg machine' => 'skierg', 'sled machine' => 'traîneau', 'smith machine' => 'smith machine',
'stability ball' => 'swiss ball', 'stationary bike' => "vélo d'appartement", 'stepmill machine' => 'stepper',
'tire' => 'pneu', 'trap bar' => 'trap bar', 'upper body ergometer' => 'ergomètre bras',
'weighted' => 'lesté', 'wheel roller' => 'roue abdominale',
// muscles cibles
'abductors' => 'abducteurs', 'abs' => 'abdominaux', 'adductors' => 'adducteurs', 'biceps' => 'biceps',
'calves' => 'mollets', 'cardiovascular system' => 'système cardiovasculaire', 'delts' => 'deltoïdes',
'forearms' => 'avant-bras', 'glutes' => 'fessiers', 'hamstrings' => 'ischio-jambiers', 'lats' => 'grand dorsal',
'levator scapulae' => 'élévateur de la scapula', 'pectorals' => 'pectoraux', 'quads' => 'quadriceps',
'serratus anterior' => 'dentelé antérieur', 'spine' => 'colonne vertébrale', 'traps' => 'trapèzes',
'triceps' => 'triceps', 'upper back' => 'haut du dos',
// muscles secondaires (compléments)
'abdominals' => 'abdominaux', 'ankle stabilizers' => 'stabilisateurs de cheville', 'ankles' => 'chevilles',
'brachialis' => 'brachial', 'chest ' => 'pectoraux', 'core' => 'gainage', 'deltoids' => 'deltoïdes',
'feet' => 'pieds', 'grip muscles' => 'muscles de préhension', 'groin' => 'aine', 'hands' => 'mains',
'hip flexors' => 'fléchisseurs de hanche', 'inner thighs' => 'intérieur des cuisses',
'latissimus dorsi' => 'grand dorsal', 'lower abs' => 'bas des abdominaux', 'lower back' => 'bas du dos',
'obliques' => 'obliques', 'quadriceps' => 'quadriceps', 'rear deltoids' => 'deltoïdes postérieurs',
'rhomboids' => 'rhomboïdes', 'rotator cuff' => 'coiffe des rotateurs', 'shins' => 'tibias',
'soleus' => 'soléaire', 'sternocleidomastoid' => 'sterno-cléido-mastoïdien', 'trapezius' => 'trapèze',
'upper chest' => 'haut des pectoraux', 'wrist extensors' => 'extenseurs du poignet',
'wrist flexors' => 'fléchisseurs du poignet', 'wrists' => 'poignets',
],
'pt-BR' => [
// zonas / categorias
'back' => 'costas', 'cardio' => 'cardio', 'chest' => 'peito', 'lower arms' => 'antebraços',
'lower legs' => 'panturrilhas', 'neck' => 'pescoço', 'shoulders' => 'ombros', 'upper arms' => 'braços',
'upper legs' => 'coxas', 'waist' => 'abdômen',
// equipamento
'assisted' => 'assistido', 'band' => 'faixa elástica', 'barbell' => 'barra', 'body weight' => 'peso corporal',
'bosu ball' => 'bola bosu', 'cable' => 'cabo', 'dumbbell' => 'halter', 'elliptical machine' => 'elíptico',
'ez barbell' => 'barra EZ', 'hammer' => 'hammer', 'kettlebell' => 'kettlebell',
'leverage machine' => 'máquina de alavanca', 'medicine ball' => 'bola medicinal',
'olympic barbell' => 'barra olímpica', 'resistance band' => 'faixa de resistência', 'roller' => 'rolo',
'rope' => 'corda', 'skierg machine' => 'skierg', 'sled machine' => 'trenó', 'smith machine' => 'máquina smith',
'stability ball' => 'bola de estabilidade', 'stationary bike' => 'bicicleta ergométrica',
'stepmill machine' => 'escada ergométrica', 'tire' => 'pneu', 'trap bar' => 'barra hexagonal',
'upper body ergometer' => 'ergômetro de braços', 'weighted' => 'com peso', 'wheel roller' => 'roda abdominal',
// músculos alvo
'abductors' => 'abdutores', 'abs' => 'abdômen', 'adductors' => 'adutores', 'biceps' => 'bíceps',
'calves' => 'panturrilhas', 'cardiovascular system' => 'sistema cardiovascular', 'delts' => 'deltoides',
'forearms' => 'antebraços', 'glutes' => 'glúteos', 'hamstrings' => 'posteriores da coxa', 'lats' => 'dorsais',
'levator scapulae' => 'levantador da escápula', 'pectorals' => 'peitorais', 'quads' => 'quadríceps',
'serratus anterior' => 'serrátil anterior', 'spine' => 'coluna', 'traps' => 'trapézios',
'triceps' => 'tríceps', 'upper back' => 'parte superior das costas',
// músculos secundários (complementos)
'abdominals' => 'abdominais', 'ankle stabilizers' => 'estabilizadores do tornozelo', 'ankles' => 'tornozelos',
'brachialis' => 'braquial', 'core' => 'core', 'deltoids' => 'deltoides', 'feet' => 'pés',
'grip muscles' => 'músculos de preensão', 'groin' => 'virilha', 'hands' => 'mãos',
'hip flexors' => 'flexores do quadril', 'inner thighs' => 'parte interna das coxas',
'latissimus dorsi' => 'grande dorsal', 'lower abs' => 'abdômen inferior', 'lower back' => 'lombar',
'obliques' => 'oblíquos', 'quadriceps' => 'quadríceps', 'rear deltoids' => 'deltoides posteriores',
'rhomboids' => 'romboides', 'rotator cuff' => 'manguito rotador', 'shins' => 'canelas',
'soleus' => 'sóleo', 'sternocleidomastoid' => 'esternocleidomastóideo', 'trapezius' => 'trapézio',
'upper chest' => 'parte superior do peito', 'wrist extensors' => 'extensores do punho',
'wrist flexors' => 'flexores do punho', 'wrists' => 'punhos',
],
];
@@ -30,6 +30,8 @@ class ExercisesController
->orderBy('name')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
ExerciseListResource::$locale = $this->resolveLocale($request);
return ExerciseListResource::collection($exercises);
}
@@ -39,14 +41,24 @@ class ExercisesController
return new ExerciseResource($exercise, $this->resolveLocale($request));
}
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
public function facets(): JsonResponse
/**
* GET /api/exercises/facets valeurs distinctes pour les filtres.
* Chaque valeur = { value: anglais (canonique, envoyé au filtre), label: traduit }.
*/
public function facets(Request $request): JsonResponse
{
$locale = $this->resolveLocale($request);
$facet = fn (string $column) => Exercise::query()
->whereNotNull($column)->distinct()->orderBy($column)->pluck($column)
->map(fn ($v) => ['value' => $v, 'label' => Exercise::localizeTerm($v, $locale)])
->values();
return response()->json([
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
'body_parts' => $facet('body_part'),
'equipments' => $facet('equipment'),
'targets' => $facet('target'),
'categories' => $facet('category'),
]);
}
@@ -2,23 +2,29 @@
namespace Functional\Exercises\Http\Resources;
use Functional\Exercises\Models\Exercise;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
* Forme allégée d'un exercice pour les listes (GET /api/exercises), libellés localisés.
* La locale est posée par le contrôleur avant `collection()` (collection() n'accepte pas d'argument par item).
*/
class ExerciseListResource extends JsonResource
{
public static string $locale = 'fr';
public function toArray(Request $request): array
{
$l = self::$locale;
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'body_part' => $this->body_part,
'equipment' => $this->equipment,
'target' => $this->target,
'body_part' => Exercise::localizeTerm($this->body_part, $l),
'equipment' => Exercise::localizeTerm($this->equipment, $l),
'target' => Exercise::localizeTerm($this->target, $l),
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
];
@@ -17,16 +17,20 @@ class ExerciseResource extends JsonResource
public function toArray(Request $request): array
{
$l = $this->locale;
$model = $this->resource;
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'category' => $this->category,
'equipment' => $this->equipment,
'target' => $this->target,
'muscle_group' => $this->muscle_group,
'secondary_muscles' => $this->secondary_muscles ?? [],
'instructions' => $this->resource->instructionsFor($this->locale),
'body_part' => $model::localizeTerm($this->body_part, $l),
'category' => $model::localizeTerm($this->category, $l),
'equipment' => $model::localizeTerm($this->equipment, $l),
'target' => $model::localizeTerm($this->target, $l),
'muscle_group' => $model::localizeTerm($this->muscle_group, $l),
'secondary_muscles' => $model::localizeTerms($this->secondary_muscles, $l),
'instructions' => $model->instructionsFor($l),
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
'attribution' => $this->attribution,
@@ -82,6 +82,24 @@ class Exercise extends Model
return [];
}
/** Traduit un terme du vocabulaire contrôlé (zone/équipement/muscle) selon la locale. Repli = anglais. */
public static function localizeTerm(?string $value, string $locale): ?string
{
if ($value === null || $value === '') {
return $value;
}
$map = config('exercise_i18n.' . $locale, []);
return $map[mb_strtolower($value)] ?? $value;
}
/** @param array<int,string>|null $values @return array<int,string> */
public static function localizeTerms(?array $values, string $locale): array
{
return array_values(array_map(fn ($v) => self::localizeTerm($v, $locale), $values ?? []));
}
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
public function scopeSearch(Builder $query, ?string $term): Builder
{
@@ -28,5 +28,6 @@ class ExercisesServiceProvider extends LayerServiceProvider
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
$this->mergeConfigFrom(__DIR__ . '/../../config/exercise_i18n.php', 'exercise_i18n');
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('streaks', function (Blueprint $table) {
// Date du dernier rappel push envoyé (anti-doublon : 1 rappel/jour max).
$table->date('last_reminded_on')->nullable()->after('freeze_last_granted_on');
});
}
public function down(): void
{
Schema::table('streaks', function (Blueprint $table) {
$table->dropColumn('last_reminded_on');
});
}
};
+3 -3
View File
@@ -1,6 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
// Rappels de série : chaque heure, la commande cible les users en fenêtre du soir (18h-22h locale), 1x/jour max.
Schedule::command('streaks:remind')->hourly();
@@ -0,0 +1,79 @@
<?php
namespace Functional\Streaks\Console\Commands;
use Carbon\Carbon;
use Functional\Streaks\Models\Streak;
use Functional\Streaks\Notifications\StreakAtRiskNotification;
use Illuminate\Console\Command;
/**
* Rappelle aux utilisateurs de ne pas perdre leur série.
* Planifiée toutes les heures : pour chaque user dont la série est VIVANTE mais pas entretenue
* aujourd'hui, on envoie un push le soir (fenêtre 18h-22h locale), 1 fois/jour max.
*/
class RemindStreaksCommand extends Command
{
protected $signature = 'streaks:remind {--force : ignore la fenêtre horaire (test)}';
protected $description = 'Envoie un rappel push aux utilisateurs dont la série est en danger.';
private const WINDOW_START = 18;
private const WINDOW_END = 22;
public function handle(): int
{
$defaultTz = config('app.timezone', 'UTC');
$force = (bool) $this->option('force');
$sent = 0;
Streak::query()
->where('current_count', '>=', 1)
->with('user')
->chunkById(200, function ($streaks) use ($defaultTz, $force, &$sent) {
foreach ($streaks as $streak) {
$user = $streak->user;
if (! $user) {
continue;
}
$tz = $user->timezone ?: $defaultTz;
$now = Carbon::now($tz);
$today = $now->toDateString();
$yesterday = $now->copy()->subDay()->toDateString();
$lastActive = $streak->last_active_date?->toDateString();
$lastReminded = $streak->last_reminded_on?->toDateString();
// Fenêtre horaire du soir (sauf --force).
if (! $force && ($now->hour < self::WINDOW_START || $now->hour >= self::WINDOW_END)) {
continue;
}
// Déjà entraîné aujourd'hui → rien à rappeler.
if ($lastActive === $today) {
continue;
}
// Série vivante = dernière activité HIER (sinon déjà cassée / gérée par les freezes).
if ($lastActive !== $yesterday) {
continue;
}
// Déjà rappelé aujourd'hui.
if ($lastReminded === $today) {
continue;
}
// Pas d'abonnement push → inutile.
if ($user->pushSubscriptions()->count() === 0) {
continue;
}
$user->notify(new StreakAtRiskNotification((int) $streak->current_count));
$streak->forceFill(['last_reminded_on' => $today])->save();
$sent++;
}
});
$this->info("Rappels envoyés : {$sent}");
return self::SUCCESS;
}
}
+2 -1
View File
@@ -26,7 +26,7 @@ class Streak extends Model
protected $fillable = [
'user_id', 'current_count', 'longest_count', 'last_active_date',
'freezes_available', 'freeze_last_granted_on',
'freezes_available', 'freeze_last_granted_on', 'last_reminded_on',
];
protected $casts = [
@@ -35,6 +35,7 @@ class Streak extends Model
'last_active_date' => 'date',
'freezes_available' => 'integer',
'freeze_last_granted_on' => 'date',
'last_reminded_on' => 'date',
];
public function user(): BelongsTo
@@ -0,0 +1,44 @@
<?php
namespace Functional\Streaks\Notifications;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
/**
* Rappel « ta série est en danger » push envoyé le soir si l'utilisateur n'a pas encore
* entraîné aujourd'hui alors que sa série est vivante. Textes localisés selon la locale du user.
*/
class StreakAtRiskNotification extends Notification
{
public function __construct(private readonly int $currentCount)
{
}
/** @return array<int,string> */
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
public function toWebPush(object $notifiable): WebPushMessage
{
$locale = $notifiable->locale ?? 'fr';
$n = $this->currentCount;
[$title, $body] = match ($locale) {
'en' => ["🔥 Your {$n}-day streak!", 'Train today so you don\'t lose it.'],
'pt-BR' => ["🔥 Sua sequência de {$n} dias!", 'Treine hoje para não perdê-la.'],
default => ["🔥 Ta série de {$n} jours !", 'Entraîne-toi aujourd\'hui pour ne pas la perdre.'],
};
return (new WebPushMessage())
->title($title)
->body($body)
->icon('/pwa-192.png')
->badge('/pwa-192.png')
->data(['url' => '/'])
->options(['TTL' => 12 * 3600, 'urgency' => 'high']);
}
}
@@ -2,6 +2,7 @@
namespace Functional\Streaks\Providers;
use Functional\Streaks\Console\Commands\RemindStreaksCommand;
use Functional\Streaks\Database\Seeders\StreaksSeeder;
use Functional\Streaks\Listeners\UpdateStreakOnWorkoutCompleted;
use Functional\Streaks\Services\StreakService;
@@ -16,6 +17,7 @@ class StreaksServiceProvider extends LayerServiceProvider
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([StreaksSeeder::class]);
$this->commands([RemindStreaksCommand::class]);
}
$this->withRouting(
@@ -24,7 +26,6 @@ class StreaksServiceProvider extends LayerServiceProvider
commands: __DIR__ . '/../../routes/console.php',
channels: __DIR__ . '/../../routes/channels.php',
);
$this->loadSeeders([\Functional\Streaks\Database\Seeders\StreaksSeeder::class]);
// Le streak se met à jour à chaque séance terminée (événement porté par la couche tracking).
Event::listen(WorkoutCompleted::class, UpdateStreakOnWorkoutCompleted::class);
+10 -2
View File
@@ -1,7 +1,15 @@
<?php
use Functional\Users\Http\Controllers\AuthController;
use Functional\Users\Http\Controllers\PushController;
use Illuminate\Support\Facades\Route;
// Le préfixe api/ est ajouté par UsersServiceProvider::withRouting() : GET /api/me.
Route::get('me', [AuthController::class, 'me'])->middleware('auth:sanctum');
// Le préfixe api/ est ajouté par UsersServiceProvider::withRouting().
Route::middleware('auth:sanctum')->group(function () {
Route::get('me', [AuthController::class, 'me']);
// Web Push (rappels de série pilotés par le back)
Route::post('push/subscribe', [PushController::class, 'subscribe']);
Route::post('push/unsubscribe', [PushController::class, 'unsubscribe']);
Route::post('push/test', [PushController::class, 'test']);
});
@@ -85,6 +85,10 @@ class AuthController
'longest' => $streak->longest_count,
'freezes' => $streak->freezes_available,
],
'push' => [
'vapid_public_key' => config('webpush.vapid.public_key'),
'enabled' => $user->pushSubscriptions()->exists(),
],
]);
}
@@ -0,0 +1,50 @@
<?php
namespace Functional\Users\Http\Controllers;
use Functional\Users\Notifications\PushTestNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Abonnement Web Push (PWA). Le front envoie sa subscription ; on la stocke sur l'utilisateur.
* L'envoi réel des rappels est piloté par le back (commande planifiée) cf couche streaks.
*/
class PushController
{
/** POST /api/push/subscribe — enregistre/actualise l'abonnement push du navigateur. */
public function subscribe(Request $request): JsonResponse
{
$data = $request->validate([
'endpoint' => ['required', 'string'],
'keys.p256dh' => ['required', 'string'],
'keys.auth' => ['required', 'string'],
]);
$request->user()->updatePushSubscription(
$data['endpoint'],
$data['keys']['p256dh'],
$data['keys']['auth'],
);
return response()->json(['status' => 'subscribed']);
}
/** POST /api/push/unsubscribe — supprime l'abonnement (désactivation des rappels). */
public function unsubscribe(Request $request): Response
{
$request->validate(['endpoint' => ['required', 'string']]);
$request->user()->deletePushSubscription($request->input('endpoint'));
return response()->noContent();
}
/** POST /api/push/test — envoie une notif de test (bouton "tester" côté profil). */
public function test(Request $request): JsonResponse
{
$request->user()->notify(new PushTestNotification());
return response()->json(['status' => 'sent']);
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use NotificationChannels\WebPush\HasPushSubscriptions;
/**
* @property string $locale
@@ -27,7 +28,7 @@ use Laravel\Sanctum\HasApiTokens;
#[UseFactory(UserFactory::class)]
class User extends Authenticatable implements FilamentUser
{
use HasApiTokens, HasFactory, Notifiable;
use HasApiTokens, HasFactory, HasPushSubscriptions, Notifiable;
/** Accès au back-office Filament (à affiner : rôle admin plus tard). */
public function canAccessPanel(Panel $panel): bool
@@ -0,0 +1,28 @@
<?php
namespace Functional\Users\Notifications;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
/** Notification de test envoyée quand l'utilisateur active/teste les rappels push. */
class PushTestNotification extends Notification
{
/** @return array<int,string> */
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
public function toWebPush(object $notifiable): WebPushMessage
{
return (new WebPushMessage())
->title('🔥 StreakFit')
->body('Notifications activées — on te préviendra pour garder ta flamme !')
->icon('/pwa-192.png')
->badge('/pwa-192.png')
->data(['url' => '/'])
->options(['TTL' => 3600]);
}
}