i18n vocabulaire exos (fr/pt-BR) + Web Push rappels de série
Build & Deploy / build (push) Successful in 17s
Build & Deploy / build (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+23
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user