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,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);